clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 5cba919c783e
tarballunverified16 files changed+6031-5855
@@ -1,20 +1,163 @@ # beer -A fast, software-rendered, **Wayland-native** terminal emulator written in Rust.-Lightweight in dependencies, on disk, and in memory.+`beer` is a small Wayland-native terminal emulator written in Rust. It renders+through `wl_shm` with the CPU, uses a real PTY for the child shell, and keeps+the architecture close to the model used by terminals like foot: no GPU+renderer, no tabs, no ligatures, and no async runtime.++The project is still pre-1.0, but it is already usable as a single-window+terminal on Wayland.++## Features++- Software-rendered Wayland window with server-side decoration negotiation.+- PTY-backed login shell with resize propagation through `TIOCSWINSZ`.+- VT parsing through `vte`, including cursor movement, erase/edit operations,+ scroll regions, alternate screen, SGR attributes, truecolor, 256-color, OSC+ palette/theme escapes, title updates, DA/DSR, XTGETTCAP, and synchronized+ output.+- Font discovery and rasterization through fontconfig and FreeType, including+ fallback faces, styled variants, bounded glyph caching, and color emoji.+- Scrollback with wheel and key scrolling, reflow on resize, and incremental+ search.+- Mouse selection with word, line, and rectangular selection; clipboard and+ primary-selection integration; bracketed paste; OSC 52 set/query.+- Mouse and focus reporting for terminal applications.+- TOML configuration with live reload on `SIGUSR1`.+- Shell integration through OSC 7 and OSC 133 for cwd-aware new windows, prompt+ jumping, and piping the last command's output.+- OSC 8 hyperlinks, visible URL hint mode, and OSC 9/777/99 notifications.++## Not Implemented Yet++- Daemon/server mode and client mode.+- Kitty keyboard protocol and Unicode codepoint input mode.+- Touchscreen input.+- Conformance and performance hardening passes beyond the current unit tests. ## Build -A Nix dev shell provides the toolchain and native libraries:+The repository provides a Nix dev shell with the Rust toolchain and native+Wayland/font dependencies: ```sh-# Enter a devshell for necessary deps+# Enter the development shell. $ nix develop -# Build in release mode-cargo build --release+# Build an optimized local binary.+$ cargo build --release+```++The Nix package builds the binary, terminfo entry, and man pages:++```sh+# Build the Nix package.+$ nix build+```++## Run++From a Wayland session:++```sh+# Start Beer from your terminal.+$ beer ``` +or after a release build:++```sh+# Run the release binary directly.+$ ./target/release/beer+```++Useful flags:++```sh+# Pass a config file to Beer.+$ beer --config /path/to/beer.toml++# Check the version with -V or --version.+$ beer --version++# See the help text.+$ beer --help+```++`--server` exists as a future mode switch and currently returns an error.++## Configuration++By default, `beer` reads:++```text+$XDG_CONFIG_HOME/beer/beer.toml+```++or, if `XDG_CONFIG_HOME` is unset:++```text+~/.config/beer/beer.toml+```++A missing file uses defaults. A malformed file logs a warning and falls back to+defaults. Unknown keys are ignored for forward compatibility.++Example:++```toml+[main]+font = "monospace"+font-size = 16+pad-x = 2+pad-y = 2++[colors]+background = "#181818"+foreground = "#c5c8c6"+alpha = 1.0++[key-bindings]+"Ctrl+Shift+C" = "copy"+"Ctrl+Shift+V" = "paste"+"Ctrl+Shift+F" = "search"++[url]+launch = ["xdg-open"]+```++See `doc/beer.toml.5.scd` for the full configuration reference.++## Development++The expected verification set is:++```sh+# Check Rust formatting.+$ cargo fmt --all -- --check++# Run Clippy with warnings denied.+$ cargo clippy --all-targets --all-features -- -D warnings++# Build a release-optimized binary.+$ cargo build --release++# Run the test suite.+$ cargo test++# Check dependency policy.+$ cargo deny check++# Verify the Nix package.+$ nix build+```++The code is organized as a single crate with internal modules for the PTY, VT+model, grid, font pipeline, renderer, input encoding, configuration, and Wayland+frontend. Keep feature logic in the module that owns the concept; avoid adding+more state directly to the Wayland event root or the core grid when a focused+submodule can own it.+ ## License EUPL-1.2.@@ -114,7 +114,7 @@ _jump-prompt-up_, _jump-prompt-down_, _pipe-command-output_, _url-mode_. ``` [key-bindings] "Ctrl+Shift+C" = "copy"-"Ctrl+grave" = "none"+"Ctrl+`" = "none" ``` # [shell-integration]@@ -10,66 +10,68 @@ libxkbcommon, freetype, fontconfig,-}:-rustPlatform.buildRustPackage (finalAttrs: {- pname = "beer";- version = "0.0.1";+}: let+ cargoTOML = (lib.importTOML ../Cargo.toml).package.version;+in+ rustPlatform.buildRustPackage (finalAttrs: {+ pname = "beer";+ version = cargoTOML.package.version; - src = let- fs = lib.fileset;- s = ../.;- in- fs.toSource {- root = s;- fileset = fs.unions [- (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))- (s + /Cargo.lock)- (s + /Cargo.toml)- (s + /terminfo)- (s + /doc)- ];- };+ src = let+ fs = lib.fileset;+ s = ../.;+ in+ fs.toSource {+ root = s;+ fileset = fs.unions [+ (s + /src)+ (s + /Cargo.lock)+ (s + /Cargo.toml)+ (s + /terminfo)+ (s + /doc)+ ];+ }; - cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";- enableParallelBuilding = true;+ cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";+ enableParallelBuilding = true; - strictDeps = true;- nativeBuildInputs = [- pkg-config- makeBinaryWrapper- ncurses # tic- scdoc # man page generation- installShellFiles # installManPage- ];+ strictDeps = true;+ nativeBuildInputs = [+ pkg-config+ makeBinaryWrapper+ ncurses # tic+ scdoc # man page generation+ installShellFiles # installManPage+ ]; - buildInputs = [- wayland- libxkbcommon- freetype- fontconfig- ];+ buildInputs = [+ wayland+ libxkbcommon+ freetype+ fontconfig+ ]; - # Install the terminfo entry, and make the Wayland/xkb libraries (loaded via- # dlopen, so not captured by rpath) discoverable at runtime.- postInstall = ''- mkdir -p "$out/share/terminfo"- tic -x -o "$out/share/terminfo" terminfo/beer.info+ # Install the terminfo entry, and make the Wayland/xkb libraries (loaded via+ # dlopen, so not captured by rpath) discoverable at runtime.+ postInstall = ''+ mkdir -p "$out/share/terminfo"+ tic -x -o "$out/share/terminfo" terminfo/beer.info - # Generate the man pages from their scdoc sources.- scdoc < doc/beer.1.scd > beer.1- scdoc < doc/beer.toml.5.scd > beer.toml.5- installManPage beer.1 beer.toml.5+ # Generate the man pages from their scdoc sources.+ scdoc < doc/beer.1.scd > beer.1+ scdoc < doc/beer.toml.5.scd > beer.toml.5+ installManPage beer.1 beer.toml.5 - wrapProgram "$out/bin/beer" \- --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [wayland libxkbcommon]}- '';+ wrapProgram "$out/bin/beer" \+ --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [wayland libxkbcommon]}+ ''; - meta = {- description = "A fast, software-rendered, Wayland-native terminal emulator";- homepage = "https://github.com/NotAShelf/beer";- license = lib.licenses.eupl12;- maintainers = with lib.maintainers; [NotAShelf];- mainProgram = "beer";- platforms = lib.platforms.linux;- };-})+ meta = {+ description = "A fast, software-rendered, Wayland-native terminal emulator";+ homepage = "https://github.com/NotAShelf/beer";+ license = lib.licenses.eupl12;+ maintainers = with lib.maintainers; [NotAShelf];+ mainProgram = "beer";+ platforms = lib.platforms.linux;+ };+ })@@ -1,1914 +0,0 @@-//! The terminal screen: a grid of styled cells, a cursor, and the editing-//! operations the VT parser drives.--use std::collections::VecDeque;-use std::num::NonZeroU16;--use unicode_width::UnicodeWidthChar;--/// Maximum scrollback lines retained for the main screen.-const SCROLLBACK_CAP: usize = 10_000;--/// A cell colour: terminal default, a palette index, or direct RGB.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Color {- #[default]- Default,- Indexed(u8),- Rgb(u8, u8, u8),-}--/// Per-cell style flags, packed into a `u16`.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub struct Flags(u16);--impl Flags {- pub const BOLD: Self = Self(1 << 0);- pub const DIM: Self = Self(1 << 1);- pub const ITALIC: Self = Self(1 << 2);- pub const BLINK: Self = Self(1 << 4);- pub const REVERSE: Self = Self(1 << 5);- pub const HIDDEN: Self = Self(1 << 6);- pub const STRIKE: Self = Self(1 << 7);- /// Trailing column of a double-width glyph; holds no character of its own.- pub const WIDE_CONT: Self = Self(1 << 8);- pub const OVERLINE: Self = Self(1 << 9);-- pub const fn empty() -> Self {- Self(0)- }-- pub const fn union(self, other: Self) -> Self {- Self(self.0 | other.0)- }-- pub fn contains(self, other: Self) -> bool {- self.0 & other.0 == other.0- }-- pub fn insert(&mut self, other: Self) {- self.0 |= other.0;- }-- pub fn remove(&mut self, other: Self) {- self.0 &= !other.0;- }-}--/// Underline style (SGR 4 / 4:x / 21).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Underline {- #[default]- None,- Single,- Double,- Curly,- Dotted,- Dashed,-}--/// Cursor shape (DECSCUSR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum CursorShape {- #[default]- Block,- Underline,- Beam,-}--/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseProtocol {- /// No reporting; the pointer drives local selection/scroll.- #[default]- Off,- /// X10 (9): button presses only.- X10,- /// Normal (1000): button press and release.- Normal,- /// Button-event (1002): press, release, and motion while a button is held.- Button,- /// Any-event (1003): press, release, and all pointer motion.- Any,-}--/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseEncoding {- /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).- #[default]- X10,- /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).- Utf8,- /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).- Sgr,-}--/// One grid cell: a character plus its rendering style.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct Cell {- pub c: char,- pub fg: Color,- pub bg: Color,- pub flags: Flags,- pub underline: Underline,- /// Underline colour; `Default` means "follow the foreground".- pub underline_color: Color,- /// Zero-width combining marks attached to `c`, in arrival order. `None` for- /// the common case; the renderer stacks each over the base glyph. This is- /// the grapheme cluster a future shaper (HarfBuzz) would consume.- pub combining: Option<Box<str>>,- /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.- pub link: Option<NonZeroU16>,-}--impl Default for Cell {- fn default() -> Self {- Self {- c: ' ',- fg: Color::Default,- bg: Color::Default,- flags: Flags::empty(),- underline: Underline::None,- underline_color: Color::Default,- combining: None,- link: None,- }- }-}--#[derive(Clone, Copy, Debug, Default)]-struct Cursor {- x: usize,- y: usize,-}--/// A URL detected in the visible viewport, with the `(row, col)` of its first-/// character in viewport coordinates.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct UrlHit {- pub url: String,- pub row: usize,- pub col: usize,-}--/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the-/// delimiters that conventionally bound a URL in flowing text).-fn is_url_char(c: char) -> bool {- !c.is_whitespace()- && !c.is_control()- && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')-}--/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.-/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs-/// to the first non-URL character, with trailing sentence punctuation trimmed.-fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {- let mut out = Vec::new();- let mut i = 0;- while i + 2 < chars.len() {- if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {- // Backtrack over the scheme.- let mut start = i;- while start > 0 {- let c = chars[start - 1];- if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {- start -= 1;- } else {- break;- }- }- if start < i && chars[start].is_ascii_alphabetic() {- let mut end = i + 3;- while end < chars.len() && is_url_char(chars[end]) {- end += 1;- }- // Trim trailing punctuation that is usually sentence-level.- while end > i + 3- && matches!(- chars[end - 1],- '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'- )- {- end -= 1;- }- if end > i + 3 {- out.push((start, end));- i = end;- continue;- }- }- }- i += 1;- }- out-}--/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,-/// the start of typed command input, the start of command output, or the line-/// where the command finished.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum PromptKind {- PromptStart,- CmdStart,- OutputStart,- CmdEnd,-}--/// One screen/scrollback row: its cells plus whether it soft-wrapped into the-/// next row (autowrap continuation, as opposed to a hard line break). The flag-/// is what lets resize rejoin and rewrap paragraphs.-#[derive(Clone, PartialEq, Eq, Debug)]-struct Line {- cells: Vec<Cell>,- wrapped: bool,- /// OSC 133 mark attached to this (logical) line, if any.- prompt: Option<PromptKind>,-}--impl Line {- fn blank(cols: usize) -> Self {- Self {- cells: vec![Cell::default(); cols],- wrapped: false,- prompt: None,- }- }-}--/// A point in the combined scrollback+live coordinate space: `row` indexes-/// scrollback lines first (oldest at 0), then the live screen.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub struct Point {- pub row: usize,- pub col: usize,-}--/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct Match {- row: usize,- col: usize,- len: usize,-}--/// Incremental scrollback search: the query, every hit, and the focused one.-#[derive(Clone, Debug, Default)]-struct SearchState {- query: String,- matches: Vec<Match>,- current: usize,-}--/// The active screen plus cursor, scroll region, and current pen.-#[derive(Debug)]-pub struct Grid {- cols: usize,- rows: usize,- lines: Vec<Line>,- cursor: Cursor,- saved: Cursor,- /// Inclusive top/bottom rows of the scroll region.- top: usize,- bottom: usize,- /// Template cell carrying the current SGR colours/flags.- pen: Cell,- autowrap: bool,- origin: bool,- insert: bool,- /// Cursor parked past the last column, awaiting the next print to wrap.- wrap_pending: bool,- tabs: Vec<bool>,- /// Saved primary screen while the alternate screen is active.- alt_saved: Option<Vec<Line>>,- /// Lines that have scrolled off the top of the main screen, newest last.- scrollback: VecDeque<Line>,- /// How many lines the viewport is scrolled back from the live bottom.- view_offset: usize,- cursor_shape: CursorShape,- /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).- cursor_blink: bool,- cursor_visible: bool,- /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.- app_cursor: bool,- /// Cursor colour from OSC 12; `None` follows the cell under the cursor.- cursor_color: Option<(u8, u8, u8)>,- /// Active mouse selection as (anchor, head) in absolute coordinates.- selection: Option<(Point, Point)>,- /// Whether the selection is a rectangular block rather than linear flow.- selection_block: bool,- /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.- bracketed_paste: bool,- /// Synchronized output (DECSET 2026): hold presentation while a frame is- /// being assembled, so the screen never shows a half-drawn update.- sync: bool,- /// Which mouse events the application wants reported.- mouse_protocol: MouseProtocol,- /// Wire framing for those reports.- mouse_encoding: MouseEncoding,- /// Focus in/out reporting (DECSET 1004).- focus_events: bool,- /// Active incremental scrollback search, if any.- search: Option<SearchState>,- /// Characters that break a word for double-click selection.- word_delimiters: String,- /// History retention cap for the main screen.- scrollback_cap: usize,- /// Position of the last printed base cell, so a following zero-width- /// combining mark can attach to it.- last_base: Option<(usize, usize)>,- /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.- links: Vec<Box<str>>,-}--fn default_tabs(cols: usize) -> Vec<bool> {- (0..cols).map(|i| i % 8 == 0 && i != 0).collect()-}--/// Default characters that terminate a word for double-click selection (the-/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`-/// are deliberately *not* delimiters so paths, URLs, and option flags select as-/// one unit.-const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";--/// Whether `c` is part of a word (not whitespace, not in `delims`).-fn is_word(c: char, delims: &str) -> bool {- !c.is_whitespace() && !delims.contains(c)-}--impl Grid {- pub fn new(cols: usize, rows: usize) -> Self {- let cols = cols.max(1);- let rows = rows.max(1);- Self {- cols,- rows,- lines: vec![Line::blank(cols); rows],- cursor: Cursor::default(),- saved: Cursor::default(),- top: 0,- bottom: rows - 1,- pen: Cell::default(),- autowrap: true,- origin: false,- insert: false,- wrap_pending: false,- tabs: default_tabs(cols),- alt_saved: None,- scrollback: VecDeque::new(),- view_offset: 0,- cursor_shape: CursorShape::default(),- cursor_blink: false,- cursor_visible: true,- cursor_color: None,- app_cursor: false,- selection: None,- selection_block: false,- bracketed_paste: false,- sync: false,- mouse_protocol: MouseProtocol::Off,- mouse_encoding: MouseEncoding::X10,- focus_events: false,- search: None,- word_delimiters: WORD_DELIMITERS.to_string(),- scrollback_cap: SCROLLBACK_CAP,- last_base: None,- links: Vec::new(),- }- }-- /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An- /// empty/`None` URI ends the current link.- pub fn set_link(&mut self, uri: Option<&str>) {- self.pen.link = match uri {- Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),- _ => None,- };- }-- /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table- /// is capped so a pathological stream cannot grow it without bound).- fn intern_link(&mut self, uri: &str) -> NonZeroU16 {- if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {- return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");- }- // u16::MAX distinct links is far past any real document; reuse the last- // slot once saturated rather than overflow the id space.- if self.links.len() < usize::from(u16::MAX) - 1 {- self.links.push(uri.into());- } else {- *self.links.last_mut().expect("table is non-empty when full") = uri.into();- }- NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")- }-- /// The URI for a hyperlink id, if it is still in the table.- pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {- self.links- .get(usize::from(id.get()) - 1)- .map(|s| s.as_ref())- }-- /// The hyperlink id of the cell at an absolute `(row, col)`, if any.- pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {- self.abs_row(abs_row).get(col).and_then(|c| c.link)- }-- /// Override the word-delimiter set; `None` keeps the built-in default.- pub fn set_word_delimiters(&mut self, delims: Option<String>) {- if let Some(d) = delims {- self.word_delimiters = d;- }- }-- /// Set the scrollback retention cap, trimming history if it shrank.- pub fn set_scrollback_cap(&mut self, cap: usize) {- self.scrollback_cap = cap;- while self.scrollback.len() > cap {- self.scrollback.pop_front();- }- self.view_offset = self.view_offset.min(self.scrollback.len());- }-- pub fn cols(&self) -> usize {- self.cols- }-- pub fn rows(&self) -> usize {- self.rows- }-- /// Resize the screen. On the main screen this reflows: soft-wrapped runs are- /// rejoined into logical lines and rewrapped to the new width, across both- /// scrollback and the live screen, keeping the cursor on its content. The- /// alternate screen just clips, since its apps repaint on resize.- pub fn resize(&mut self, cols: usize, rows: usize) {- let cols = cols.max(1);- let rows = rows.max(1);- if self.alt_saved.is_some() {- self.clip_resize(cols, rows);- } else {- self.reflow_resize(cols, rows);- }- self.cols = cols;- self.rows = rows;- self.top = 0;- self.bottom = rows - 1;- self.tabs = default_tabs(cols);- self.cursor.x = self.cursor.x.min(cols - 1);- self.cursor.y = self.cursor.y.min(rows - 1);- self.wrap_pending = false;- self.view_offset = 0;- }-- /// Clip-resize the live screen and the saved primary (alternate screen).- fn clip_resize(&mut self, cols: usize, rows: usize) {- for line in &mut self.lines {- line.cells.resize(cols, Cell::default());- }- self.lines.resize(rows, Line::blank(cols));- if let Some(saved) = self.alt_saved.as_mut() {- for line in saved.iter_mut() {- line.cells.resize(cols, Cell::default());- }- saved.resize(rows, Line::blank(cols));- }- self.clear_selection();- self.clear_search();- }-- /// Reflow scrollback + live content to a new width, rewrapping soft-wrapped- /// paragraphs and repositioning the cursor onto its character.- fn reflow_resize(&mut self, cols: usize, rows: usize) {- let cursor_abs = self.scrollback.len() + self.cursor.y;- let total = self.scrollback.len() + self.lines.len();-- // 1. Rejoin soft-wrapped rows into logical lines. Track which logical- // line the cursor falls in and its offset within that line.- let mut logicals: Vec<Vec<Cell>> = Vec::new();- let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();- let mut acc: Vec<Cell> = Vec::new();- let mut acc_mark: Option<PromptKind> = None;- let mut cur_logical = 0usize;- let mut cur_off = 0usize;- for abs in 0..total {- if abs == cursor_abs {- cur_logical = logicals.len();- cur_off = acc.len() + self.cursor.x;- }- let line = if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- };- // The mark on the first physical row of a logical line carries over.- if acc.is_empty() {- acc_mark = line.prompt;- }- acc.extend_from_slice(&line.cells);- if !line.wrapped {- logicals.push(std::mem::take(&mut acc));- logical_marks.push(acc_mark.take());- }- }- if !acc.is_empty() {- logicals.push(acc);- logical_marks.push(acc_mark);- }- // Drop trailing all-blank lines (empty screen below the content), but- // never above the cursor's line, so the cursor keeps its row.- let last_content = logicals- .iter()- .rposition(|l| l.iter().any(|c| *c != Cell::default()))- .unwrap_or(0);- logicals.truncate(last_content.max(cur_logical) + 1);- logical_marks.truncate(last_content.max(cur_logical) + 1);-- // 2. Rewrap each logical line to the new width, recording where the- // cursor lands. Trailing blanks are dropped so a hard line does not- // rewrap its padding onto extra rows.- let mut new_lines: Vec<Line> = Vec::new();- let mut new_cursor_abs = 0usize;- let mut new_cursor_col = 0usize;- for (li, mut logical) in logicals.into_iter().enumerate() {- let trim = logical- .iter()- .rposition(|c| *c != Cell::default())- .map_or(0, |p| p + 1);- logical.truncate(trim);- let first = new_lines.len();- let chunks = logical.len().div_ceil(cols).max(1);- for ci in 0..chunks {- let start = ci * cols;- let end = (start + cols).min(logical.len());- let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();- cells.resize(cols, Cell::default());- new_lines.push(Line {- cells,- wrapped: ci + 1 < chunks,- // The mark belongs to the first physical row of the line.- prompt: if ci == 0 { logical_marks[li] } else { None },- });- }- if li == cur_logical {- let off = cur_off.min(logical.len());- let chunk = (off / cols).min(chunks - 1);- new_cursor_abs = first + chunk;- new_cursor_col = (off - chunk * cols).min(cols - 1);- }- }-- // 3. The last `rows` lines are the live screen; the rest is scrollback.- let live_start = new_lines.len().saturating_sub(rows);- let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();- let mut live = new_lines;- while live.len() < rows {- live.push(Line::blank(cols));- }- while scrollback.len() > self.scrollback_cap {- scrollback.pop_front();- }-- self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);- self.cursor.x = new_cursor_col;- self.lines = live;- self.scrollback = scrollback;- self.clear_selection();- self.clear_search();- }-- pub fn cursor(&self) -> (usize, usize) {- (self.cursor.x, self.cursor.y)- }-- // --- pen / attributes ----- pub fn pen_mut(&mut self) -> &mut Cell {- &mut self.pen- }-- pub fn reset_pen(&mut self) {- self.pen = Cell::default();- }-- pub fn set_autowrap(&mut self, on: bool) {- self.autowrap = on;- }-- pub fn set_origin(&mut self, on: bool) {- self.origin = on;- self.move_to(0, 0);- }-- pub fn set_insert(&mut self, on: bool) {- self.insert = on;- }-- pub fn autowrap(&self) -> bool {- self.autowrap- }-- pub fn origin(&self) -> bool {- self.origin- }-- pub fn insert(&self) -> bool {- self.insert- }-- pub fn alt_active(&self) -> bool {- self.alt_saved.is_some()- }-- pub fn set_cursor_shape(&mut self, shape: CursorShape) {- self.cursor_shape = shape;- }-- pub fn cursor_shape(&self) -> CursorShape {- self.cursor_shape- }-- pub fn set_cursor_blink(&mut self, blink: bool) {- self.cursor_blink = blink;- }-- pub fn cursor_blink(&self) -> bool {- self.cursor_blink- }-- pub fn set_cursor_visible(&mut self, visible: bool) {- self.cursor_visible = visible;- }-- pub fn cursor_visible(&self) -> bool {- self.cursor_visible- }-- pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {- self.cursor_color = color;- }-- pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {- self.cursor_color- }-- pub fn set_app_cursor(&mut self, on: bool) {- self.app_cursor = on;- }-- pub fn app_cursor(&self) -> bool {- self.app_cursor- }-- // --- printing ----- /// Place a printable character at the cursor, honouring width and autowrap.- pub fn print(&mut self, c: char) {- let width = c.width().unwrap_or(0);- if width == 0 {- // A zero-width combining mark attaches to the last base cell.- self.add_combining(c);- return;- }- if self.wrap_pending {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- self.wrap_pending = false;- }- if width == 2 && self.cursor.x + 1 >= self.cols {- // A double-width glyph cannot straddle the right edge: wrap first.- if self.autowrap {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- } else {- return;- }- }- if self.insert {- self.shift_right(width);- }-- let (x, y) = (self.cursor.x, self.cursor.y);- let mut cell = self.pen.clone();- cell.c = c;- cell.combining = None;- cell.flags.remove(Flags::WIDE_CONT);- self.lines[y].cells[x] = cell;- self.last_base = Some((x, y));- if width == 2 && x + 1 < self.cols {- let mut cont = self.pen.clone();- cont.c = ' ';- cont.combining = None;- cont.flags.insert(Flags::WIDE_CONT);- self.lines[y].cells[x + 1] = cont;- }-- let advance = width;- if self.cursor.x + advance >= self.cols {- if self.autowrap {- self.cursor.x = self.cols - 1;- self.wrap_pending = true;- } else {- self.cursor.x = self.cols - 1;- }- } else {- self.cursor.x += advance;- }- }-- /// Attach a zero-width combining mark to the most recently printed base- /// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.- fn add_combining(&mut self, mark: char) {- const MAX_MARKS: usize = 8;- let Some((x, y)) = self.last_base else {- return;- };- let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {- return;- };- let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();- if s.chars().count() < MAX_MARKS {- s.push(mark);- }- cell.combining = Some(s.into_boxed_str());- }-- fn shift_right(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let end = self.cols;- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in (x + n..end).rev() {- row[i] = row[i - n].clone();- }- for cell in &mut row[x..(x + n).min(end)] {- *cell = blank.clone();- }- }-- fn pen_blank(&self) -> Cell {- // A space carrying only the current background (back-colour erase).- Cell {- bg: self.pen.bg,- ..Cell::default()- }- }-- // --- cursor movement ----- fn region(&self) -> (usize, usize) {- if self.origin {- (self.top, self.bottom)- } else {- (0, self.rows - 1)- }- }-- pub fn move_to(&mut self, x: usize, y: usize) {- let (rt, rb) = self.region();- self.cursor.x = x.min(self.cols - 1);- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn move_to_col(&mut self, x: usize) {- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn move_to_row(&mut self, y: usize) {- let (rt, rb) = self.region();- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_up(&mut self, n: usize) {- let (rt, _) = self.region();- self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_down(&mut self, n: usize) {- let (_, rb) = self.region();- self.cursor.y = (self.cursor.y + n).min(rb);- self.wrap_pending = false;- }-- pub fn cursor_fwd(&mut self, n: usize) {- self.cursor.x = (self.cursor.x + n).min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn cursor_back(&mut self, n: usize) {- self.cursor.x = self.cursor.x.saturating_sub(n);- self.wrap_pending = false;- }-- pub fn save_cursor(&mut self) {- self.saved = self.cursor;- }-- pub fn restore_cursor(&mut self) {- self.cursor = self.saved;- self.cursor.x = self.cursor.x.min(self.cols - 1);- self.cursor.y = self.cursor.y.min(self.rows - 1);- self.wrap_pending = false;- }-- // --- line discipline ----- pub fn carriage_return(&mut self) {- self.cursor.x = 0;- self.wrap_pending = false;- }-- pub fn backspace(&mut self) {- self.cursor.x = self.cursor.x.saturating_sub(1);- self.wrap_pending = false;- }-- /// LF/VT/FF: move down one row, scrolling at the region bottom.- pub fn line_feed(&mut self) {- if self.cursor.y == self.bottom {- self.scroll_up(1);- } else if self.cursor.y < self.rows - 1 {- self.cursor.y += 1;- }- self.wrap_pending = false;- }-- /// RI: move up one row, scrolling down at the region top.- pub fn reverse_index(&mut self) {- if self.cursor.y == self.top {- self.scroll_down(1);- } else if self.cursor.y > 0 {- self.cursor.y -= 1;- }- self.wrap_pending = false;- }-- /// NEL: carriage return plus line feed.- pub fn next_line(&mut self) {- self.carriage_return();- self.line_feed();- }-- // --- tab stops ----- pub fn tab(&mut self) {- let mut x = self.cursor.x + 1;- while x < self.cols && !self.tabs[x] {- x += 1;- }- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn set_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = true;- }- }-- pub fn clear_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = false;- }- }-- pub fn clear_all_tabs(&mut self) {- self.tabs.iter_mut().for_each(|t| *t = false);- }-- // --- scrolling within the region ----- pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {- if top < bottom && bottom < self.rows {- self.top = top;- self.bottom = bottom;- } else {- self.top = 0;- self.bottom = self.rows - 1;- }- self.move_to(0, 0);- }-- pub fn scroll_up(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- // Lines leaving the top of the *whole* main screen become scrollback;- // a DECSTBM region scroll (top > 0) or the alt screen does not.- if self.top == 0 && self.alt_saved.is_none() {- for y in 0..n {- let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));- self.scrollback.push_back(line);- }- let mut evicted = 0;- while self.scrollback.len() > self.scrollback_cap {- self.scrollback.pop_front();- evicted += 1;- }- if evicted > 0 {- self.shift_selection(evicted);- self.shift_search(evicted);- }- // Keep a scrolled-back viewport anchored to the same content.- if self.view_offset > 0 {- self.view_offset = (self.view_offset + n).min(self.scrollback.len());- }- }- for y in self.top..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- pub fn scroll_down(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- for y in (self.top..=self.bottom).rev() {- if y >= self.top + n {- self.lines.swap(y, y - n);- }- }- for y in self.top..(self.top + n) {- self.blank_row(y);- }- }-- fn blank_row(&mut self, y: usize) {- let blank = self.pen_blank();- let line = &mut self.lines[y];- for cell in &mut line.cells {- *cell = blank.clone();- }- line.wrapped = false;- }-- // --- erase ----- /// ED: 0=below, 1=above, 2/3=all.- pub fn erase_display(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => {- self.erase_in_row(y, x, self.cols);- for r in (y + 1)..self.rows {- self.blank_row(r);- }- }- 1 => {- for r in 0..y {- self.blank_row(r);- }- self.erase_in_row(y, 0, x + 1);- }- _ => {- for r in 0..self.rows {- self.blank_row(r);- }- }- }- self.wrap_pending = false;- }-- /// EL: 0=right, 1=left, 2=line.- pub fn erase_line(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => self.erase_in_row(y, x, self.cols),- 1 => self.erase_in_row(y, 0, x + 1),- _ => self.erase_in_row(y, 0, self.cols),- }- self.wrap_pending = false;- }-- /// ECH: erase n characters from the cursor without moving it.- pub fn erase_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- self.erase_in_row(y, x, (x + n).min(self.cols));- }-- fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {- let blank = self.pen_blank();- for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {- *cell = blank.clone();- }- }-- // --- intra-line editing ----- /// ICH: insert n blanks at the cursor, shifting the rest right.- pub fn insert_chars(&mut self, n: usize) {- let saved = self.insert;- self.insert = true;- self.shift_right(n.min(self.cols));- self.insert = saved;- }-- /// DCH: delete n characters at the cursor, shifting the rest left.- pub fn delete_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let n = n.min(self.cols - x);- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in x..self.cols {- row[i] = if i + n < self.cols {- row[i + n].clone()- } else {- blank.clone()- };- }- }-- /// IL: insert n blank lines at the cursor row, within the scroll region.- pub fn insert_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in (self.cursor.y..=self.bottom).rev() {- if y >= self.cursor.y + n {- self.lines.swap(y, y - n);- }- }- for y in self.cursor.y..(self.cursor.y + n) {- self.blank_row(y);- }- }-- /// DL: delete n lines at the cursor row, within the scroll region.- pub fn delete_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in self.cursor.y..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- // --- alternate screen ----- pub fn enter_alt_screen(&mut self) {- if self.alt_saved.is_some() {- return;- }- self.view_offset = 0;- let blank = vec![Line::blank(self.cols); self.rows];- self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));- }-- pub fn leave_alt_screen(&mut self) {- if let Some(main) = self.alt_saved.take() {- self.lines = main;- }- }-- // --- scrollback viewport ----- /// Scroll the viewport by `delta` lines: positive = back into history,- /// negative = toward the live screen. No-op on the alternate screen.- pub fn scroll_view(&mut self, delta: isize) {- if self.alt_saved.is_some() {- return;- }- let max = self.scrollback.len() as isize;- self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;- }-- pub fn scroll_to_bottom(&mut self) {- self.view_offset = 0;- }-- /// Whether the viewport is showing the live screen (not scrolled back).- pub fn view_at_bottom(&self) -> bool {- self.view_offset == 0- }-- /// One page (a screenful) of lines, for page-scroll bindings.- pub fn page(&self) -> usize {- self.rows.max(1)- }-- /// The cells shown at viewport row `y` (0 = top of the window), accounting- /// for the scrollback offset. May differ from `cols` in width if the line- /// predates a resize (no reflow yet), so callers must not assume length.- pub fn view_row(&self, y: usize) -> &[Cell] {- let start = self.scrollback.len() - self.view_offset;- let idx = start + y;- if idx < self.scrollback.len() {- &self.scrollback[idx].cells- } else {- &self.lines[idx - self.scrollback.len()].cells- }- }-- // --- selection ----- /// The absolute row currently shown at viewport row `y`.- pub fn view_to_abs(&self, y: usize) -> usize {- self.scrollback.len() - self.view_offset + y- }-- /// The line at an absolute row (scrollback first, then the live screen).- fn line_at_abs(&self, abs: usize) -> &Line {- if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- }- }-- /// Detect plain-text URLs across the visible viewport, returning each with- /// the viewport `(row, col)` of its first character. Soft-wrapped rows are- /// joined so a URL split across a wrap is found whole; hard line breaks end- /// a URL.- pub fn visible_urls(&self) -> Vec<UrlHit> {- let mut chars: Vec<char> = Vec::new();- let mut pos: Vec<(usize, usize)> = Vec::new();- for y in 0..self.rows {- let line = self.line_at_abs(self.view_to_abs(y));- for (x, cell) in line.cells.iter().enumerate() {- if cell.flags.contains(Flags::WIDE_CONT) {- continue;- }- chars.push(cell.c);- pos.push((y, x));- }- if !line.wrapped {- chars.push('\n'); // a hard break terminates any URL- pos.push((y, usize::MAX));- }- }- find_urls(&chars)- .into_iter()- .map(|(s, e)| {- let (row, col) = pos[s];- UrlHit {- url: chars[s..e].iter().collect(),- row,- col,- }- })- .collect()- }-- /// Cells of an absolute row (scrollback first, then the live screen).- fn abs_row(&self, row: usize) -> &[Cell] {- if row < self.scrollback.len() {- &self.scrollback[row].cells- } else {- &self.lines[row - self.scrollback.len()].cells- }- }-- /// Total rows across scrollback and the live screen.- fn total_lines(&self) -> usize {- self.scrollback.len() + self.lines.len()- }-- /// The OSC 133 mark on an absolute row, if any.- fn abs_prompt(&self, row: usize) -> Option<PromptKind> {- if row < self.scrollback.len() {- self.scrollback[row].prompt- } else {- self.lines- .get(row - self.scrollback.len())- .and_then(|l| l.prompt)- }- }-- // --- shell integration (OSC 133) ----- /// Attach an OSC 133 prompt mark to the live line under the cursor.- pub fn set_prompt_mark(&mut self, kind: PromptKind) {- let y = self.cursor.y;- if let Some(line) = self.lines.get_mut(y) {- line.prompt = Some(kind);- }- }-- /// Scroll the viewport to the previous (`up`) or next prompt, placing that- /// prompt line at the top of the window. No-op on the alternate screen or- /// when there is no prompt in that direction.- pub fn jump_prompt(&mut self, up: bool) {- if self.alt_saved.is_some() {- return;- }- let top = self.scrollback.len().saturating_sub(self.view_offset);- let total = self.total_lines();- let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);- let target = if up {- (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))- } else {- ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))- };- if let Some(t) = target {- let offset = self.scrollback.len() as isize - t as isize;- self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;- }- }-- /// Text of the most recent command's output: the rows from the last- /// output-start (OSC 133 C) up to the command-end (D) or next prompt.- pub fn last_command_output(&self) -> Option<String> {- let total = self.total_lines();- let start = (0..total)- .rev()- .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;- let mut lines: Vec<String> = Vec::new();- for r in start..total {- if r > start- && matches!(- self.abs_prompt(r),- Some(PromptKind::CmdEnd | PromptKind::PromptStart)- )- {- break;- }- lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());- }- // Drop trailing blank rows (e.g. the empty live screen below the output).- while lines.last().is_some_and(|l| l.is_empty()) {- lines.pop();- }- let mut out = lines.join("\n");- out.push('\n');- Some(out)- }-- /// Slide an active selection up by `n` rows after scrollback eviction,- /// dropping it if either endpoint scrolled off the top.- fn shift_selection(&mut self, n: usize) {- if let Some((a, b)) = self.selection {- if a.row < n || b.row < n {- self.selection = None;- } else {- self.selection = Some((- Point {- row: a.row - n,- ..a- },- Point {- row: b.row - n,- ..b- },- ));- }- }- }-- pub fn clear_selection(&mut self) {- self.selection = None;- }-- /// Begin a linear selection at an absolute point (drag anchor).- pub fn start_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = false;- }-- /// Begin a rectangular (block) selection at an absolute point.- pub fn start_block_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = true;- }-- /// Move the selection head (drag), keeping the anchor fixed.- pub fn extend_selection(&mut self, row: usize, col: usize) {- if let Some((_, head)) = self.selection.as_mut() {- *head = Point { row, col };- }- }-- /// Select the word at an absolute point, breaking on whitespace and the- /// default delimiter set.- pub fn select_word(&mut self, row: usize, col: usize) {- let delims = &self.word_delimiters;- let line = self.abs_row(row);- if col >= line.len() || !is_word(line[col].c, delims) {- self.start_selection(row, col);- return;- }- let mut lo = col;- while lo > 0 && is_word(line[lo - 1].c, delims) {- lo -= 1;- }- let mut hi = col;- while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {- hi += 1;- }- self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));- self.selection_block = false;- }-- /// Select the whole line at an absolute row.- pub fn select_line(&mut self, row: usize) {- let last = self.abs_row(row).len().saturating_sub(1);- self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));- self.selection_block = false;- }-- /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block- /// selection.- fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {- let (a, b) = self.selection?;- Some((- a.row.min(b.row),- a.row.max(b.row),- a.col.min(b.col),- a.col.max(b.col),- ))- }-- /// Normalized selection (start <= end in reading order), if any.- fn ordered_selection(&self) -> Option<(Point, Point)> {- self.selection.map(|(a, b)| {- if (a.row, a.col) <= (b.row, b.col) {- (a, b)- } else {- (b, a)- }- })- }-- /// Whether the cell at an absolute `(row, col)` falls inside the selection.- pub fn is_selected(&self, row: usize, col: usize) -> bool {- if self.selection_block {- let Some((r0, r1, c0, c1)) = self.block_rect() else {- return false;- };- return row >= r0 && row <= r1 && col >= c0 && col <= c1;- }- let Some((start, end)) = self.ordered_selection() else {- return false;- };- if row < start.row || row > end.row {- return false;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row { end.col } else { usize::MAX };- col >= lo && col <= hi- }-- /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if- /// any part of that row is selected.- pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- return (row >= r0 && row <= r1).then_some((c0, c1));- }- let (start, end) = self.ordered_selection()?;- if row < start.row || row > end.row {- return None;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- end.col- } else {- self.abs_row(row).len().saturating_sub(1)- };- Some((lo, hi))- }-- /// The selected text, with trailing blanks trimmed per line and rows joined- /// by newlines. `None` if there is no selection.- pub fn selection_text(&self) -> Option<String> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- let mut out = String::new();- for row in r0..=r1 {- out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());- if row != r1 {- out.push('\n');- }- }- return Some(out);- }- let (start, end) = self.ordered_selection()?;- let mut out = String::new();- for row in start.row..=end.row {- let line = self.abs_row(row);- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- (end.col + 1).min(line.len())- } else {- line.len()- };- out.push_str(self.row_slice_text(row, lo, hi).trim_end());- if row != end.row {- out.push('\n');- }- }- Some(out)- }-- /// The characters of an absolute row in `[from, to)`, skipping wide- /// continuation cells.- fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {- let mut out = String::new();- for cell in self- .abs_row(row)- .get(from..to.min(self.abs_row(row).len()))- .unwrap_or(&[])- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- {- out.push(cell.c);- if let Some(marks) = &cell.combining {- out.push_str(marks);- }- }- out- }-- // --- scrollback search ----- /// Set the search query and recompute matches over scrollback + the live- /// screen, focusing the most recent hit and scrolling it into view. An- /// empty query keeps search mode active but clears the hit list.- pub fn set_search(&mut self, query: &str) {- let matches = if query.is_empty() {- Vec::new()- } else {- self.compute_matches(query)- };- let current = matches.len().saturating_sub(1);- self.search = Some(SearchState {- query: query.to_string(),- matches,- current,- });- self.jump_to_current();- }-- /// Move the focused match `forward` (toward newer) or back (toward older),- /// wrapping, and scroll it into view.- pub fn search_step(&mut self, forward: bool) {- if let Some(s) = self.search.as_mut()- && !s.matches.is_empty()- {- let n = s.matches.len();- s.current = if forward {- (s.current + 1) % n- } else {- (s.current + n - 1) % n- };- }- self.jump_to_current();- }-- pub fn clear_search(&mut self) {- self.search = None;- }-- /// The current query, while search mode is active.- pub fn search_query(&self) -> Option<&str> {- self.search.as_ref().map(|s| s.query.as_str())- }-- /// `(focused match index 1-based, total matches)` for the search prompt.- pub fn search_count(&self) -> (usize, usize) {- self.search.as_ref().map_or((0, 0), |s| {- let total = s.matches.len();- (if total == 0 { 0 } else { s.current + 1 }, total)- })- }-- /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.- pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {- let Some(s) = self.search.as_ref() else {- return Vec::new();- };- s.matches- .iter()- .enumerate()- .filter(|(_, m)| m.row == row && m.len > 0)- .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))- .collect()- }-- fn compute_matches(&self, query: &str) -> Vec<Match> {- // Smart case: an uppercase letter in the query forces case sensitivity.- let sensitive = query.chars().any(|c| c.is_ascii_uppercase());- let fold = |c: char| {- if sensitive { c } else { c.to_ascii_lowercase() }- };- let needle: Vec<char> = query.chars().map(fold).collect();- if needle.is_empty() {- return Vec::new();- }- let total = self.scrollback.len() + self.rows;- let mut matches = Vec::new();- for row in 0..total {- let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();- let mut i = 0;- while i + needle.len() <= hay.len() {- if hay[i..i + needle.len()] == needle[..] {- matches.push(Match {- row,- col: i,- len: needle.len(),- });- i += needle.len();- } else {- i += 1;- }- }- }- matches- }-- /// Scroll the viewport so the focused match is on screen, centering it only- /// when it would otherwise be off the visible range.- fn jump_to_current(&mut self) {- let Some(abs) = self- .search- .as_ref()- .and_then(|s| s.matches.get(s.current))- .map(|m| m.row)- else {- return;- };- let sb = self.scrollback.len();- let top = sb - self.view_offset;- let bottom = top + self.rows - 1;- if abs < top || abs > bottom {- let target = sb as isize + self.rows as isize / 2 - abs as isize;- self.view_offset = target.clamp(0, sb as isize) as usize;- }- }-- /// Slide search hits up by `n` rows after scrollback eviction, dropping any- /// that scrolled off the top.- fn shift_search(&mut self, n: usize) {- if let Some(s) = self.search.as_mut() {- s.matches.retain(|m| m.row >= n);- for m in &mut s.matches {- m.row -= n;- }- s.current = s.current.min(s.matches.len().saturating_sub(1));- }- }-- pub fn set_bracketed_paste(&mut self, on: bool) {- self.bracketed_paste = on;- }-- pub fn bracketed_paste(&self) -> bool {- self.bracketed_paste- }-- pub fn set_sync(&mut self, on: bool) {- self.sync = on;- }-- pub fn sync_active(&self) -> bool {- self.sync- }-- pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {- self.mouse_protocol = protocol;- }-- pub fn mouse_protocol(&self) -> MouseProtocol {- self.mouse_protocol- }-- pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {- self.mouse_encoding = encoding;- }-- pub fn mouse_encoding(&self) -> MouseEncoding {- self.mouse_encoding- }-- pub fn set_focus_events(&mut self, on: bool) {- self.focus_events = on;- }-- pub fn focus_events(&self) -> bool {- self.focus_events- }-- // --- inspection (logging + tests) ----- /// The visible text of one row, trailing blanks trimmed.- #[cfg(test)]- pub fn row_text(&self, y: usize) -> String {- self.lines[y]- .cells- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- .map(|c| c.c)- .collect::<String>()- .trim_end()- .to_string()- }-- pub fn cell(&self, x: usize, y: usize) -> &Cell {- &self.lines[y].cells[x]- }-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn prints_and_wraps() {- let mut g = Grid::new(4, 2);- for c in "abcde".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "e");- assert_eq!(g.cursor(), (1, 1));- }-- #[test]- fn combining_marks_attach_to_base_cell() {- let mut g = Grid::new(8, 1);- // "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the- // 'x' lands in the next cell (the mark advanced nothing).- for c in "e\u{0301}x".chars() {- g.print(c);- }- assert_eq!(g.cell(0, 0).c, 'e');- assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));- assert_eq!(g.cell(1, 0).c, 'x');- assert_eq!(g.cursor(), (2, 0));- // Copied text round-trips the full grapheme cluster.- g.start_selection(0, 0);- g.extend_selection(0, 1);- assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));- }-- #[test]- fn detects_urls_trimming_trailing_punctuation() {- let mut g = Grid::new(60, 2);- for c in "see https://example.com/p?q=1, ok".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/p?q=1");- assert_eq!((hits[0].row, hits[0].col), (0, 4));- }-- #[test]- fn detects_url_across_a_soft_wrap() {- // 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.- let mut g = Grid::new(20, 3);- for c in "x https://example.com/averylongpath".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/averylongpath");- }-- #[test]- fn prompt_mark_survives_reflow() {- let mut g = Grid::new(10, 4);- g.set_prompt_mark(PromptKind::OutputStart); // marks line 0- for c in "hello".chars() {- g.print(c);- }- g.resize(6, 4); // rewrap to a narrower width- // The mark followed its logical line, so the output is still found.- assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));- }-- #[test]- fn carriage_return_and_line_feed() {- let mut g = Grid::new(8, 4);- for c in "hi".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "yo".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "hi");- assert_eq!(g.row_text(1), "yo");- }-- #[test]- fn line_feed_scrolls_at_bottom() {- let mut g = Grid::new(4, 2);- g.print('a');- g.next_line();- g.print('b');- g.next_line(); // scrolls: row0 <- "b", row1 blank- assert_eq!(g.row_text(0), "b");- assert_eq!(g.row_text(1), "");- }-- #[test]- fn erase_line_to_right() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(2, 0);- g.erase_line(0);- assert_eq!(g.row_text(0), "ab");- }-- #[test]- fn delete_and_insert_chars() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(1, 0);- g.delete_chars(2);- assert_eq!(g.row_text(0), "adef");- g.move_to(1, 0);- g.insert_chars(2);- assert_eq!(g.row_text(0), "a def");- }-- #[test]- fn scrollback_captures_scrolled_lines() {- let mut g = Grid::new(8, 2);- for c in ['1', '2', '3'] {- g.print(c);- g.next_line();- }- // Live screen: newest line on top, cleared line below.- assert_eq!(g.view_row(0)[0].c, '3');- assert!(g.view_at_bottom());- // Scroll back to reveal the two captured lines.- g.scroll_view(2);- assert!(!g.view_at_bottom());- assert_eq!(g.view_row(0)[0].c, '1');- assert_eq!(g.view_row(1)[0].c, '2');- g.scroll_to_bottom();- assert_eq!(g.view_row(0)[0].c, '3');- }-- #[test]- fn wide_char_occupies_two_columns() {- let mut g = Grid::new(6, 1);- g.print('世');- g.print('x');- assert_eq!(g.cell(0, 0).c, '世');- assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));- assert_eq!(g.cell(2, 0).c, 'x');- }-- #[test]- fn selection_extracts_text_across_rows() {- let mut g = Grid::new(8, 2);- for c in "abcd".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "efgh".chars() {- g.print(c);- }- // Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).- g.start_selection(0, 2);- g.extend_selection(1, 1);- assert!(g.is_selected(0, 3));- assert!(g.is_selected(1, 0));- assert!(!g.is_selected(1, 2));- assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));- }-- #[test]- fn select_word_spans_one_word() {- let mut g = Grid::new(16, 1);- for c in "foo bar baz".chars() {- g.print(c);- }- g.select_word(0, 5); // inside "bar"- assert_eq!(g.selection_text().as_deref(), Some("bar"));- }-- #[test]- fn select_word_breaks_on_delimiters_but_keeps_paths() {- let mut g = Grid::new(32, 1);- for c in "run /usr/bin:next".chars() {- g.print(c);- }- // '/' and ':' are not delimiters, so the path selects whole.- g.select_word(0, 7); // inside "/usr/bin:next"- assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));- // '(' is a delimiter.- let mut g = Grid::new(16, 1);- for c in "f(arg)".chars() {- g.print(c);- }- g.select_word(0, 2); // inside "arg"- assert_eq!(g.selection_text().as_deref(), Some("arg"));- }-- #[test]- fn block_selection_is_rectangular() {- let mut g = Grid::new(8, 3);- for line in ["abcd", "efgh", "ijkl"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.- g.start_block_selection(0, 1);- g.extend_selection(2, 2);- assert!(g.is_selected(0, 1));- assert!(g.is_selected(2, 2));- assert!(!g.is_selected(1, 3));- assert!(!g.is_selected(1, 0));- assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));- }-- #[test]- fn search_finds_matches_across_history() {- let mut g = Grid::new(16, 2);- for line in ["alpha", "beta", "ALPHA", "gamma"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // Case-insensitive (no uppercase in query) matches both alphas.- g.set_search("alpha");- assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit- let spans0 = g.search_spans_on(0);- assert_eq!(spans0, vec![(0, 4, false)]);- // Smart case: an uppercase letter restricts to the exact-case hit.- g.set_search("ALPHA");- assert_eq!(g.search_count(), (1, 1));- assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);- // Stepping wraps and the no-match query reports zero.- g.set_search("zzz");- assert_eq!(g.search_count(), (0, 0));- assert_eq!(g.search_query(), Some("zzz"));- g.clear_search();- assert_eq!(g.search_query(), None);- }-- #[test]- fn reflow_rewraps_a_wrapped_paragraph() {- let mut g = Grid::new(4, 3);- for c in "abcdefgh".chars() {- g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)- }- // Widen: the two wrapped rows rejoin into one logical line.- g.resize(8, 3);- assert_eq!(g.row_text(0), "abcdefgh");- assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end- // Narrow back: it rewraps to the smaller width without losing text.- g.resize(4, 3);- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "efgh");- }-- #[test]- fn reflow_preserves_hard_breaks() {- let mut g = Grid::new(10, 3);- for c in "one".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "two".chars() {- g.print(c);- }- // A hard newline must not be rejoined when the width changes.- g.resize(6, 3);- assert_eq!(g.row_text(0), "one");- assert_eq!(g.row_text(1), "two");- }-- #[test]- fn scroll_region_limits_line_feed() {- let mut g = Grid::new(4, 4);- g.set_scroll_region(1, 2);- g.move_to(0, 2); // bottom of region (origin off: absolute row 2)- g.print('x');- g.move_to(0, 2);- g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1- assert_eq!(g.row_text(1), "x");- assert_eq!(g.row_text(2), "");- }-}@@ -0,0 +1,136 @@+use std::num::NonZeroU16;++use super::*;++/// A URL detected in the visible viewport, with the `(row, col)` of its first+/// character in viewport coordinates.+#[derive(Clone, PartialEq, Eq, Debug)]+pub struct UrlHit {+ pub url: String,+ pub row: usize,+ pub col: usize,+}++/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the+/// delimiters that conventionally bound a URL in flowing text).+fn is_url_char(c: char) -> bool {+ !c.is_whitespace()+ && !c.is_control()+ && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')+}++/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.+/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs+/// to the first non-URL character, with trailing sentence punctuation trimmed.+fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {+ let mut out = Vec::new();+ let mut i = 0;+ while i + 2 < chars.len() {+ if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {+ // Backtrack over the scheme.+ let mut start = i;+ while start > 0 {+ let c = chars[start - 1];+ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {+ start -= 1;+ } else {+ break;+ }+ }+ if start < i && chars[start].is_ascii_alphabetic() {+ let mut end = i + 3;+ while end < chars.len() && is_url_char(chars[end]) {+ end += 1;+ }+ // Trim trailing punctuation that is usually sentence-level.+ while end > i + 3+ && matches!(+ chars[end - 1],+ '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'+ )+ {+ end -= 1;+ }+ if end > i + 3 {+ out.push((start, end));+ i = end;+ continue;+ }+ }+ }+ i += 1;+ }+ out+}++impl Grid {+ /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An+ /// empty/`None` URI ends the current link.+ pub fn set_link(&mut self, uri: Option<&str>) {+ self.pen.link = match uri {+ Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),+ _ => None,+ };+ }++ /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table+ /// is capped so a pathological stream cannot grow it without bound).+ fn intern_link(&mut self, uri: &str) -> NonZeroU16 {+ if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {+ return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");+ }+ // u16::MAX distinct links is far past any real document; reuse the last+ // slot once saturated rather than overflow the id space.+ if self.links.len() < usize::from(u16::MAX) - 1 {+ self.links.push(uri.into());+ } else {+ *self.links.last_mut().expect("table is non-empty when full") = uri.into();+ }+ NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")+ }++ /// The URI for a hyperlink id, if it is still in the table.+ pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {+ self.links+ .get(usize::from(id.get()) - 1)+ .map(|s| s.as_ref())+ }++ /// The hyperlink id of the cell at an absolute `(row, col)`, if any.+ pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {+ self.abs_row(abs_row).get(col).and_then(|c| c.link)+ }+ /// Detect plain-text URLs across the visible viewport, returning each with+ /// the viewport `(row, col)` of its first character. Soft-wrapped rows are+ /// joined so a URL split across a wrap is found whole; hard line breaks end+ /// a URL.+ pub fn visible_urls(&self) -> Vec<UrlHit> {+ let mut chars: Vec<char> = Vec::new();+ let mut pos: Vec<(usize, usize)> = Vec::new();+ for y in 0..self.rows {+ let line = self.line_at_abs(self.view_to_abs(y));+ for (x, cell) in line.cells.iter().enumerate() {+ if cell.flags.contains(Flags::WIDE_CONT) {+ continue;+ }+ chars.push(cell.c);+ pos.push((y, x));+ }+ if !line.wrapped {+ chars.push('\n'); // a hard break terminates any URL+ pos.push((y, usize::MAX));+ }+ }+ find_urls(&chars)+ .into_iter()+ .map(|(s, e)| {+ let (row, col) = pos[s];+ UrlHit {+ url: chars[s..e].iter().collect(),+ row,+ col,+ }+ })+ .collect()+ }+}@@ -0,0 +1,1457 @@+//! The terminal screen: a grid of styled cells, a cursor, and the editing+//! operations the VT parser drives.++mod links;+mod search;+mod selection;++use std::collections::VecDeque;+use std::num::NonZeroU16;++use unicode_width::UnicodeWidthChar;++pub use links::UrlHit;+use search::SearchState;++/// Maximum scrollback lines retained for the main screen.+const SCROLLBACK_CAP: usize = 10_000;++/// A cell colour: terminal default, a palette index, or direct RGB.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Color {+ #[default]+ Default,+ Indexed(u8),+ Rgb(u8, u8, u8),+}++/// Per-cell style flags, packed into a `u16`.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub struct Flags(u16);++impl Flags {+ pub const BOLD: Self = Self(1 << 0);+ pub const DIM: Self = Self(1 << 1);+ pub const ITALIC: Self = Self(1 << 2);+ pub const BLINK: Self = Self(1 << 4);+ pub const REVERSE: Self = Self(1 << 5);+ pub const HIDDEN: Self = Self(1 << 6);+ pub const STRIKE: Self = Self(1 << 7);+ /// Trailing column of a double-width glyph; holds no character of its own.+ pub const WIDE_CONT: Self = Self(1 << 8);+ pub const OVERLINE: Self = Self(1 << 9);++ pub const fn empty() -> Self {+ Self(0)+ }++ pub const fn union(self, other: Self) -> Self {+ Self(self.0 | other.0)+ }++ pub fn contains(self, other: Self) -> bool {+ self.0 & other.0 == other.0+ }++ pub fn insert(&mut self, other: Self) {+ self.0 |= other.0;+ }++ pub fn remove(&mut self, other: Self) {+ self.0 &= !other.0;+ }+}++/// Underline style (SGR 4 / 4:x / 21).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Underline {+ #[default]+ None,+ Single,+ Double,+ Curly,+ Dotted,+ Dashed,+}++/// Cursor shape (DECSCUSR).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum CursorShape {+ #[default]+ Block,+ Underline,+ Beam,+}++/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum MouseProtocol {+ /// No reporting; the pointer drives local selection/scroll.+ #[default]+ Off,+ /// X10 (9): button presses only.+ X10,+ /// Normal (1000): button press and release.+ Normal,+ /// Button-event (1002): press, release, and motion while a button is held.+ Button,+ /// Any-event (1003): press, release, and all pointer motion.+ Any,+}++/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum MouseEncoding {+ /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).+ #[default]+ X10,+ /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).+ Utf8,+ /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).+ Sgr,+}++/// One grid cell: a character plus its rendering style.+#[derive(Clone, PartialEq, Eq, Debug)]+pub struct Cell {+ pub c: char,+ pub fg: Color,+ pub bg: Color,+ pub flags: Flags,+ pub underline: Underline,+ /// Underline colour; `Default` means "follow the foreground".+ pub underline_color: Color,+ /// Zero-width combining marks attached to `c`, in arrival order. `None` for+ /// the common case; the renderer stacks each over the base glyph. This is+ /// the grapheme cluster a future shaper (HarfBuzz) would consume.+ pub combining: Option<Box<str>>,+ /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.+ pub link: Option<NonZeroU16>,+}++impl Default for Cell {+ fn default() -> Self {+ Self {+ c: ' ',+ fg: Color::Default,+ bg: Color::Default,+ flags: Flags::empty(),+ underline: Underline::None,+ underline_color: Color::Default,+ combining: None,+ link: None,+ }+ }+}++#[derive(Clone, Copy, Debug, Default)]+struct Cursor {+ x: usize,+ y: usize,+}++/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,+/// the start of typed command input, the start of command output, or the line+/// where the command finished.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum PromptKind {+ PromptStart,+ CmdStart,+ OutputStart,+ CmdEnd,+}++/// One screen/scrollback row: its cells plus whether it soft-wrapped into the+/// next row (autowrap continuation, as opposed to a hard line break). The flag+/// is what lets resize rejoin and rewrap paragraphs.+#[derive(Clone, PartialEq, Eq, Debug)]+struct Line {+ cells: Vec<Cell>,+ wrapped: bool,+ /// OSC 133 mark attached to this (logical) line, if any.+ prompt: Option<PromptKind>,+}++impl Line {+ fn blank(cols: usize) -> Self {+ Self {+ cells: vec![Cell::default(); cols],+ wrapped: false,+ prompt: None,+ }+ }+}++/// A point in the combined scrollback+live coordinate space: `row` indexes+/// scrollback lines first (oldest at 0), then the live screen.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub struct Point {+ pub row: usize,+ pub col: usize,+}++/// The active screen plus cursor, scroll region, and current pen.+#[derive(Debug)]+pub struct Grid {+ cols: usize,+ rows: usize,+ lines: Vec<Line>,+ cursor: Cursor,+ saved: Cursor,+ /// Inclusive top/bottom rows of the scroll region.+ top: usize,+ bottom: usize,+ /// Template cell carrying the current SGR colours/flags.+ pen: Cell,+ autowrap: bool,+ origin: bool,+ insert: bool,+ /// Cursor parked past the last column, awaiting the next print to wrap.+ wrap_pending: bool,+ tabs: Vec<bool>,+ /// Saved primary screen while the alternate screen is active.+ alt_saved: Option<Vec<Line>>,+ /// Lines that have scrolled off the top of the main screen, newest last.+ scrollback: VecDeque<Line>,+ /// How many lines the viewport is scrolled back from the live bottom.+ view_offset: usize,+ cursor_shape: CursorShape,+ /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).+ cursor_blink: bool,+ cursor_visible: bool,+ /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.+ app_cursor: bool,+ /// Cursor colour from OSC 12; `None` follows the cell under the cursor.+ cursor_color: Option<(u8, u8, u8)>,+ /// Active mouse selection as (anchor, head) in absolute coordinates.+ selection: Option<(Point, Point)>,+ /// Whether the selection is a rectangular block rather than linear flow.+ selection_block: bool,+ /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.+ bracketed_paste: bool,+ /// Synchronized output (DECSET 2026): hold presentation while a frame is+ /// being assembled, so the screen never shows a half-drawn update.+ sync: bool,+ /// Which mouse events the application wants reported.+ mouse_protocol: MouseProtocol,+ /// Wire framing for those reports.+ mouse_encoding: MouseEncoding,+ /// Focus in/out reporting (DECSET 1004).+ focus_events: bool,+ /// Active incremental scrollback search, if any.+ search: Option<SearchState>,+ /// Characters that break a word for double-click selection.+ word_delimiters: String,+ /// History retention cap for the main screen.+ scrollback_cap: usize,+ /// Position of the last printed base cell, so a following zero-width+ /// combining mark can attach to it.+ last_base: Option<(usize, usize)>,+ /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.+ links: Vec<Box<str>>,+}++fn default_tabs(cols: usize) -> Vec<bool> {+ (0..cols).map(|i| i % 8 == 0 && i != 0).collect()+}++/// Default characters that terminate a word for double-click selection (the+/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`+/// are deliberately *not* delimiters so paths, URLs, and option flags select as+/// one unit.+const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";++/// Whether `c` is part of a word (not whitespace, not in `delims`).+fn is_word(c: char, delims: &str) -> bool {+ !c.is_whitespace() && !delims.contains(c)+}++impl Grid {+ pub fn new(cols: usize, rows: usize) -> Self {+ let cols = cols.max(1);+ let rows = rows.max(1);+ Self {+ cols,+ rows,+ lines: vec![Line::blank(cols); rows],+ cursor: Cursor::default(),+ saved: Cursor::default(),+ top: 0,+ bottom: rows - 1,+ pen: Cell::default(),+ autowrap: true,+ origin: false,+ insert: false,+ wrap_pending: false,+ tabs: default_tabs(cols),+ alt_saved: None,+ scrollback: VecDeque::new(),+ view_offset: 0,+ cursor_shape: CursorShape::default(),+ cursor_blink: false,+ cursor_visible: true,+ cursor_color: None,+ app_cursor: false,+ selection: None,+ selection_block: false,+ bracketed_paste: false,+ sync: false,+ mouse_protocol: MouseProtocol::Off,+ mouse_encoding: MouseEncoding::X10,+ focus_events: false,+ search: None,+ word_delimiters: WORD_DELIMITERS.to_string(),+ scrollback_cap: SCROLLBACK_CAP,+ last_base: None,+ links: Vec::new(),+ }+ }++ /// Override the word-delimiter set; `None` keeps the built-in default.+ pub fn set_word_delimiters(&mut self, delims: Option<String>) {+ if let Some(d) = delims {+ self.word_delimiters = d;+ }+ }++ /// Set the scrollback retention cap, trimming history if it shrank.+ pub fn set_scrollback_cap(&mut self, cap: usize) {+ self.scrollback_cap = cap;+ while self.scrollback.len() > cap {+ self.scrollback.pop_front();+ }+ self.view_offset = self.view_offset.min(self.scrollback.len());+ }++ pub fn cols(&self) -> usize {+ self.cols+ }++ pub fn rows(&self) -> usize {+ self.rows+ }++ /// Resize the screen. On the main screen this reflows: soft-wrapped runs are+ /// rejoined into logical lines and rewrapped to the new width, across both+ /// scrollback and the live screen, keeping the cursor on its content. The+ /// alternate screen just clips, since its apps repaint on resize.+ pub fn resize(&mut self, cols: usize, rows: usize) {+ let cols = cols.max(1);+ let rows = rows.max(1);+ if self.alt_saved.is_some() {+ self.clip_resize(cols, rows);+ } else {+ self.reflow_resize(cols, rows);+ }+ self.cols = cols;+ self.rows = rows;+ self.top = 0;+ self.bottom = rows - 1;+ self.tabs = default_tabs(cols);+ self.cursor.x = self.cursor.x.min(cols - 1);+ self.cursor.y = self.cursor.y.min(rows - 1);+ self.wrap_pending = false;+ self.view_offset = 0;+ }++ /// Clip-resize the live screen and the saved primary (alternate screen).+ fn clip_resize(&mut self, cols: usize, rows: usize) {+ for line in &mut self.lines {+ line.cells.resize(cols, Cell::default());+ }+ self.lines.resize(rows, Line::blank(cols));+ if let Some(saved) = self.alt_saved.as_mut() {+ for line in saved.iter_mut() {+ line.cells.resize(cols, Cell::default());+ }+ saved.resize(rows, Line::blank(cols));+ }+ self.clear_selection();+ self.clear_search();+ }++ /// Reflow scrollback + live content to a new width, rewrapping soft-wrapped+ /// paragraphs and repositioning the cursor onto its character.+ fn reflow_resize(&mut self, cols: usize, rows: usize) {+ let cursor_abs = self.scrollback.len() + self.cursor.y;+ let total = self.scrollback.len() + self.lines.len();++ // 1. Rejoin soft-wrapped rows into logical lines. Track which logical+ // line the cursor falls in and its offset within that line.+ let mut logicals: Vec<Vec<Cell>> = Vec::new();+ let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();+ let mut acc: Vec<Cell> = Vec::new();+ let mut acc_mark: Option<PromptKind> = None;+ let mut cur_logical = 0usize;+ let mut cur_off = 0usize;+ for abs in 0..total {+ if abs == cursor_abs {+ cur_logical = logicals.len();+ cur_off = acc.len() + self.cursor.x;+ }+ let line = if abs < self.scrollback.len() {+ &self.scrollback[abs]+ } else {+ &self.lines[abs - self.scrollback.len()]+ };+ // The mark on the first physical row of a logical line carries over.+ if acc.is_empty() {+ acc_mark = line.prompt;+ }+ acc.extend_from_slice(&line.cells);+ if !line.wrapped {+ logicals.push(std::mem::take(&mut acc));+ logical_marks.push(acc_mark.take());+ }+ }+ if !acc.is_empty() {+ logicals.push(acc);+ logical_marks.push(acc_mark);+ }+ // Drop trailing all-blank lines (empty screen below the content), but+ // never above the cursor's line, so the cursor keeps its row.+ let last_content = logicals+ .iter()+ .rposition(|l| l.iter().any(|c| *c != Cell::default()))+ .unwrap_or(0);+ logicals.truncate(last_content.max(cur_logical) + 1);+ logical_marks.truncate(last_content.max(cur_logical) + 1);++ // 2. Rewrap each logical line to the new width, recording where the+ // cursor lands. Trailing blanks are dropped so a hard line does not+ // rewrap its padding onto extra rows.+ let mut new_lines: Vec<Line> = Vec::new();+ let mut new_cursor_abs = 0usize;+ let mut new_cursor_col = 0usize;+ for (li, mut logical) in logicals.into_iter().enumerate() {+ let trim = logical+ .iter()+ .rposition(|c| *c != Cell::default())+ .map_or(0, |p| p + 1);+ logical.truncate(trim);+ let first = new_lines.len();+ let chunks = logical.len().div_ceil(cols).max(1);+ for ci in 0..chunks {+ let start = ci * cols;+ let end = (start + cols).min(logical.len());+ let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();+ cells.resize(cols, Cell::default());+ new_lines.push(Line {+ cells,+ wrapped: ci + 1 < chunks,+ // The mark belongs to the first physical row of the line.+ prompt: if ci == 0 { logical_marks[li] } else { None },+ });+ }+ if li == cur_logical {+ let off = cur_off.min(logical.len());+ let chunk = (off / cols).min(chunks - 1);+ new_cursor_abs = first + chunk;+ new_cursor_col = (off - chunk * cols).min(cols - 1);+ }+ }++ // 3. The last `rows` lines are the live screen; the rest is scrollback.+ let live_start = new_lines.len().saturating_sub(rows);+ let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();+ let mut live = new_lines;+ while live.len() < rows {+ live.push(Line::blank(cols));+ }+ while scrollback.len() > self.scrollback_cap {+ scrollback.pop_front();+ }++ self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);+ self.cursor.x = new_cursor_col;+ self.lines = live;+ self.scrollback = scrollback;+ self.clear_selection();+ self.clear_search();+ }++ pub fn cursor(&self) -> (usize, usize) {+ (self.cursor.x, self.cursor.y)+ }++ // --- pen / attributes ---++ pub fn pen_mut(&mut self) -> &mut Cell {+ &mut self.pen+ }++ pub fn reset_pen(&mut self) {+ self.pen = Cell::default();+ }++ pub fn set_autowrap(&mut self, on: bool) {+ self.autowrap = on;+ }++ pub fn set_origin(&mut self, on: bool) {+ self.origin = on;+ self.move_to(0, 0);+ }++ pub fn set_insert(&mut self, on: bool) {+ self.insert = on;+ }++ pub fn autowrap(&self) -> bool {+ self.autowrap+ }++ pub fn origin(&self) -> bool {+ self.origin+ }++ pub fn insert(&self) -> bool {+ self.insert+ }++ pub fn alt_active(&self) -> bool {+ self.alt_saved.is_some()+ }++ pub fn set_cursor_shape(&mut self, shape: CursorShape) {+ self.cursor_shape = shape;+ }++ pub fn cursor_shape(&self) -> CursorShape {+ self.cursor_shape+ }++ pub fn set_cursor_blink(&mut self, blink: bool) {+ self.cursor_blink = blink;+ }++ pub fn cursor_blink(&self) -> bool {+ self.cursor_blink+ }++ pub fn set_cursor_visible(&mut self, visible: bool) {+ self.cursor_visible = visible;+ }++ pub fn cursor_visible(&self) -> bool {+ self.cursor_visible+ }++ pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {+ self.cursor_color = color;+ }++ pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {+ self.cursor_color+ }++ pub fn set_app_cursor(&mut self, on: bool) {+ self.app_cursor = on;+ }++ pub fn app_cursor(&self) -> bool {+ self.app_cursor+ }++ // --- printing ---++ /// Place a printable character at the cursor, honouring width and autowrap.+ pub fn print(&mut self, c: char) {+ let width = c.width().unwrap_or(0);+ if width == 0 {+ // A zero-width combining mark attaches to the last base cell.+ self.add_combining(c);+ return;+ }+ if self.wrap_pending {+ self.cursor.x = 0;+ self.lines[self.cursor.y].wrapped = true;+ self.line_feed();+ self.wrap_pending = false;+ }+ if width == 2 && self.cursor.x + 1 >= self.cols {+ // A double-width glyph cannot straddle the right edge: wrap first.+ if self.autowrap {+ self.cursor.x = 0;+ self.lines[self.cursor.y].wrapped = true;+ self.line_feed();+ } else {+ return;+ }+ }+ if self.insert {+ self.shift_right(width);+ }++ let (x, y) = (self.cursor.x, self.cursor.y);+ let mut cell = self.pen.clone();+ cell.c = c;+ cell.combining = None;+ cell.flags.remove(Flags::WIDE_CONT);+ self.lines[y].cells[x] = cell;+ self.last_base = Some((x, y));+ if width == 2 && x + 1 < self.cols {+ let mut cont = self.pen.clone();+ cont.c = ' ';+ cont.combining = None;+ cont.flags.insert(Flags::WIDE_CONT);+ self.lines[y].cells[x + 1] = cont;+ }++ let advance = width;+ if self.cursor.x + advance >= self.cols {+ if self.autowrap {+ self.cursor.x = self.cols - 1;+ self.wrap_pending = true;+ } else {+ self.cursor.x = self.cols - 1;+ }+ } else {+ self.cursor.x += advance;+ }+ }++ /// Attach a zero-width combining mark to the most recently printed base+ /// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.+ fn add_combining(&mut self, mark: char) {+ const MAX_MARKS: usize = 8;+ let Some((x, y)) = self.last_base else {+ return;+ };+ let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {+ return;+ };+ let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();+ if s.chars().count() < MAX_MARKS {+ s.push(mark);+ }+ cell.combining = Some(s.into_boxed_str());+ }++ fn shift_right(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ let end = self.cols;+ let blank = self.pen_blank();+ let row = &mut self.lines[y].cells;+ for i in (x + n..end).rev() {+ row[i] = row[i - n].clone();+ }+ for cell in &mut row[x..(x + n).min(end)] {+ *cell = blank.clone();+ }+ }++ fn pen_blank(&self) -> Cell {+ // A space carrying only the current background (back-colour erase).+ Cell {+ bg: self.pen.bg,+ ..Cell::default()+ }+ }++ // --- cursor movement ---++ fn region(&self) -> (usize, usize) {+ if self.origin {+ (self.top, self.bottom)+ } else {+ (0, self.rows - 1)+ }+ }++ pub fn move_to(&mut self, x: usize, y: usize) {+ let (rt, rb) = self.region();+ self.cursor.x = x.min(self.cols - 1);+ self.cursor.y = (y + rt).min(rb).max(rt);+ self.wrap_pending = false;+ }++ pub fn move_to_col(&mut self, x: usize) {+ self.cursor.x = x.min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn move_to_row(&mut self, y: usize) {+ let (rt, rb) = self.region();+ self.cursor.y = (y + rt).min(rb).max(rt);+ self.wrap_pending = false;+ }++ pub fn cursor_up(&mut self, n: usize) {+ let (rt, _) = self.region();+ self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);+ self.wrap_pending = false;+ }++ pub fn cursor_down(&mut self, n: usize) {+ let (_, rb) = self.region();+ self.cursor.y = (self.cursor.y + n).min(rb);+ self.wrap_pending = false;+ }++ pub fn cursor_fwd(&mut self, n: usize) {+ self.cursor.x = (self.cursor.x + n).min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn cursor_back(&mut self, n: usize) {+ self.cursor.x = self.cursor.x.saturating_sub(n);+ self.wrap_pending = false;+ }++ pub fn save_cursor(&mut self) {+ self.saved = self.cursor;+ }++ pub fn restore_cursor(&mut self) {+ self.cursor = self.saved;+ self.cursor.x = self.cursor.x.min(self.cols - 1);+ self.cursor.y = self.cursor.y.min(self.rows - 1);+ self.wrap_pending = false;+ }++ // --- line discipline ---++ pub fn carriage_return(&mut self) {+ self.cursor.x = 0;+ self.wrap_pending = false;+ }++ pub fn backspace(&mut self) {+ self.cursor.x = self.cursor.x.saturating_sub(1);+ self.wrap_pending = false;+ }++ /// LF/VT/FF: move down one row, scrolling at the region bottom.+ pub fn line_feed(&mut self) {+ if self.cursor.y == self.bottom {+ self.scroll_up(1);+ } else if self.cursor.y < self.rows - 1 {+ self.cursor.y += 1;+ }+ self.wrap_pending = false;+ }++ /// RI: move up one row, scrolling down at the region top.+ pub fn reverse_index(&mut self) {+ if self.cursor.y == self.top {+ self.scroll_down(1);+ } else if self.cursor.y > 0 {+ self.cursor.y -= 1;+ }+ self.wrap_pending = false;+ }++ /// NEL: carriage return plus line feed.+ pub fn next_line(&mut self) {+ self.carriage_return();+ self.line_feed();+ }++ // --- tab stops ---++ pub fn tab(&mut self) {+ let mut x = self.cursor.x + 1;+ while x < self.cols && !self.tabs[x] {+ x += 1;+ }+ self.cursor.x = x.min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn set_tab(&mut self) {+ if self.cursor.x < self.cols {+ self.tabs[self.cursor.x] = true;+ }+ }++ pub fn clear_tab(&mut self) {+ if self.cursor.x < self.cols {+ self.tabs[self.cursor.x] = false;+ }+ }++ pub fn clear_all_tabs(&mut self) {+ self.tabs.iter_mut().for_each(|t| *t = false);+ }++ // --- scrolling within the region ---++ pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {+ if top < bottom && bottom < self.rows {+ self.top = top;+ self.bottom = bottom;+ } else {+ self.top = 0;+ self.bottom = self.rows - 1;+ }+ self.move_to(0, 0);+ }++ pub fn scroll_up(&mut self, n: usize) {+ let n = n.min(self.bottom - self.top + 1);+ // Lines leaving the top of the *whole* main screen become scrollback;+ // a DECSTBM region scroll (top > 0) or the alt screen does not.+ if self.top == 0 && self.alt_saved.is_none() {+ for y in 0..n {+ let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));+ self.scrollback.push_back(line);+ }+ let mut evicted = 0;+ while self.scrollback.len() > self.scrollback_cap {+ self.scrollback.pop_front();+ evicted += 1;+ }+ if evicted > 0 {+ self.shift_selection(evicted);+ self.shift_search(evicted);+ }+ // Keep a scrolled-back viewport anchored to the same content.+ if self.view_offset > 0 {+ self.view_offset = (self.view_offset + n).min(self.scrollback.len());+ }+ }+ for y in self.top..=self.bottom {+ if y + n <= self.bottom {+ self.lines.swap(y, y + n);+ }+ }+ for y in (self.bottom + 1 - n)..=self.bottom {+ self.blank_row(y);+ }+ }++ pub fn scroll_down(&mut self, n: usize) {+ let n = n.min(self.bottom - self.top + 1);+ for y in (self.top..=self.bottom).rev() {+ if y >= self.top + n {+ self.lines.swap(y, y - n);+ }+ }+ for y in self.top..(self.top + n) {+ self.blank_row(y);+ }+ }++ fn blank_row(&mut self, y: usize) {+ let blank = self.pen_blank();+ let line = &mut self.lines[y];+ for cell in &mut line.cells {+ *cell = blank.clone();+ }+ line.wrapped = false;+ }++ // --- erase ---++ /// ED: 0=below, 1=above, 2/3=all.+ pub fn erase_display(&mut self, mode: u16) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ match mode {+ 0 => {+ self.erase_in_row(y, x, self.cols);+ for r in (y + 1)..self.rows {+ self.blank_row(r);+ }+ }+ 1 => {+ for r in 0..y {+ self.blank_row(r);+ }+ self.erase_in_row(y, 0, x + 1);+ }+ _ => {+ for r in 0..self.rows {+ self.blank_row(r);+ }+ }+ }+ self.wrap_pending = false;+ }++ /// EL: 0=right, 1=left, 2=line.+ pub fn erase_line(&mut self, mode: u16) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ match mode {+ 0 => self.erase_in_row(y, x, self.cols),+ 1 => self.erase_in_row(y, 0, x + 1),+ _ => self.erase_in_row(y, 0, self.cols),+ }+ self.wrap_pending = false;+ }++ /// ECH: erase n characters from the cursor without moving it.+ pub fn erase_chars(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ self.erase_in_row(y, x, (x + n).min(self.cols));+ }++ fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {+ let blank = self.pen_blank();+ for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {+ *cell = blank.clone();+ }+ }++ // --- intra-line editing ---++ /// ICH: insert n blanks at the cursor, shifting the rest right.+ pub fn insert_chars(&mut self, n: usize) {+ let saved = self.insert;+ self.insert = true;+ self.shift_right(n.min(self.cols));+ self.insert = saved;+ }++ /// DCH: delete n characters at the cursor, shifting the rest left.+ pub fn delete_chars(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ let n = n.min(self.cols - x);+ let blank = self.pen_blank();+ let row = &mut self.lines[y].cells;+ for i in x..self.cols {+ row[i] = if i + n < self.cols {+ row[i + n].clone()+ } else {+ blank.clone()+ };+ }+ }++ /// IL: insert n blank lines at the cursor row, within the scroll region.+ pub fn insert_lines(&mut self, n: usize) {+ if self.cursor.y < self.top || self.cursor.y > self.bottom {+ return;+ }+ let n = n.min(self.bottom - self.cursor.y + 1);+ for y in (self.cursor.y..=self.bottom).rev() {+ if y >= self.cursor.y + n {+ self.lines.swap(y, y - n);+ }+ }+ for y in self.cursor.y..(self.cursor.y + n) {+ self.blank_row(y);+ }+ }++ /// DL: delete n lines at the cursor row, within the scroll region.+ pub fn delete_lines(&mut self, n: usize) {+ if self.cursor.y < self.top || self.cursor.y > self.bottom {+ return;+ }+ let n = n.min(self.bottom - self.cursor.y + 1);+ for y in self.cursor.y..=self.bottom {+ if y + n <= self.bottom {+ self.lines.swap(y, y + n);+ }+ }+ for y in (self.bottom + 1 - n)..=self.bottom {+ self.blank_row(y);+ }+ }++ // --- alternate screen ---++ pub fn enter_alt_screen(&mut self) {+ if self.alt_saved.is_some() {+ return;+ }+ self.view_offset = 0;+ let blank = vec![Line::blank(self.cols); self.rows];+ self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));+ }++ pub fn leave_alt_screen(&mut self) {+ if let Some(main) = self.alt_saved.take() {+ self.lines = main;+ }+ }++ // --- scrollback viewport ---++ /// Scroll the viewport by `delta` lines: positive = back into history,+ /// negative = toward the live screen. No-op on the alternate screen.+ pub fn scroll_view(&mut self, delta: isize) {+ if self.alt_saved.is_some() {+ return;+ }+ let max = self.scrollback.len() as isize;+ self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;+ }++ pub fn scroll_to_bottom(&mut self) {+ self.view_offset = 0;+ }++ /// Whether the viewport is showing the live screen (not scrolled back).+ pub fn view_at_bottom(&self) -> bool {+ self.view_offset == 0+ }++ /// One page (a screenful) of lines, for page-scroll bindings.+ pub fn page(&self) -> usize {+ self.rows.max(1)+ }++ /// The cells shown at viewport row `y` (0 = top of the window), accounting+ /// for the scrollback offset. May differ from `cols` in width if the line+ /// predates a resize (no reflow yet), so callers must not assume length.+ pub fn view_row(&self, y: usize) -> &[Cell] {+ let start = self.scrollback.len() - self.view_offset;+ let idx = start + y;+ if idx < self.scrollback.len() {+ &self.scrollback[idx].cells+ } else {+ &self.lines[idx - self.scrollback.len()].cells+ }+ }++ // --- selection ---++ /// The absolute row currently shown at viewport row `y`.+ pub fn view_to_abs(&self, y: usize) -> usize {+ self.scrollback.len() - self.view_offset + y+ }++ /// The line at an absolute row (scrollback first, then the live screen).+ fn line_at_abs(&self, abs: usize) -> &Line {+ if abs < self.scrollback.len() {+ &self.scrollback[abs]+ } else {+ &self.lines[abs - self.scrollback.len()]+ }+ }++ /// Cells of an absolute row (scrollback first, then the live screen).+ fn abs_row(&self, row: usize) -> &[Cell] {+ if row < self.scrollback.len() {+ &self.scrollback[row].cells+ } else {+ &self.lines[row - self.scrollback.len()].cells+ }+ }++ /// Total rows across scrollback and the live screen.+ fn total_lines(&self) -> usize {+ self.scrollback.len() + self.lines.len()+ }++ /// The OSC 133 mark on an absolute row, if any.+ fn abs_prompt(&self, row: usize) -> Option<PromptKind> {+ if row < self.scrollback.len() {+ self.scrollback[row].prompt+ } else {+ self.lines+ .get(row - self.scrollback.len())+ .and_then(|l| l.prompt)+ }+ }++ // --- shell integration (OSC 133) ---++ /// Attach an OSC 133 prompt mark to the live line under the cursor.+ pub fn set_prompt_mark(&mut self, kind: PromptKind) {+ let y = self.cursor.y;+ if let Some(line) = self.lines.get_mut(y) {+ line.prompt = Some(kind);+ }+ }++ /// Scroll the viewport to the previous (`up`) or next prompt, placing that+ /// prompt line at the top of the window. No-op on the alternate screen or+ /// when there is no prompt in that direction.+ pub fn jump_prompt(&mut self, up: bool) {+ if self.alt_saved.is_some() {+ return;+ }+ let top = self.scrollback.len().saturating_sub(self.view_offset);+ let total = self.total_lines();+ let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);+ let target = if up {+ (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))+ } else {+ ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))+ };+ if let Some(t) = target {+ let offset = self.scrollback.len() as isize - t as isize;+ self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;+ }+ }++ /// Text of the most recent command's output: the rows from the last+ /// output-start (OSC 133 C) up to the command-end (D) or next prompt.+ pub fn last_command_output(&self) -> Option<String> {+ let total = self.total_lines();+ let start = (0..total)+ .rev()+ .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;+ let mut lines: Vec<String> = Vec::new();+ for r in start..total {+ if r > start+ && matches!(+ self.abs_prompt(r),+ Some(PromptKind::CmdEnd | PromptKind::PromptStart)+ )+ {+ break;+ }+ lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());+ }+ // Drop trailing blank rows (e.g. the empty live screen below the output).+ while lines.last().is_some_and(|l| l.is_empty()) {+ lines.pop();+ }+ let mut out = lines.join("\n");+ out.push('\n');+ Some(out)+ }++ pub fn set_bracketed_paste(&mut self, on: bool) {+ self.bracketed_paste = on;+ }++ pub fn bracketed_paste(&self) -> bool {+ self.bracketed_paste+ }++ pub fn set_sync(&mut self, on: bool) {+ self.sync = on;+ }++ pub fn sync_active(&self) -> bool {+ self.sync+ }++ pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {+ self.mouse_protocol = protocol;+ }++ pub fn mouse_protocol(&self) -> MouseProtocol {+ self.mouse_protocol+ }++ pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {+ self.mouse_encoding = encoding;+ }++ pub fn mouse_encoding(&self) -> MouseEncoding {+ self.mouse_encoding+ }++ pub fn set_focus_events(&mut self, on: bool) {+ self.focus_events = on;+ }++ pub fn focus_events(&self) -> bool {+ self.focus_events+ }++ // --- inspection (logging + tests) ---++ /// The visible text of one row, trailing blanks trimmed.+ #[cfg(test)]+ pub fn row_text(&self, y: usize) -> String {+ self.lines[y]+ .cells+ .iter()+ .filter(|c| !c.flags.contains(Flags::WIDE_CONT))+ .map(|c| c.c)+ .collect::<String>()+ .trim_end()+ .to_string()+ }++ pub fn cell(&self, x: usize, y: usize) -> &Cell {+ &self.lines[y].cells[x]+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn prints_and_wraps() {+ let mut g = Grid::new(4, 2);+ for c in "abcde".chars() {+ g.print(c);+ }+ assert_eq!(g.row_text(0), "abcd");+ assert_eq!(g.row_text(1), "e");+ assert_eq!(g.cursor(), (1, 1));+ }++ #[test]+ fn combining_marks_attach_to_base_cell() {+ let mut g = Grid::new(8, 1);+ // "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the+ // 'x' lands in the next cell (the mark advanced nothing).+ for c in "e\u{0301}x".chars() {+ g.print(c);+ }+ assert_eq!(g.cell(0, 0).c, 'e');+ assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));+ assert_eq!(g.cell(1, 0).c, 'x');+ assert_eq!(g.cursor(), (2, 0));+ // Copied text round-trips the full grapheme cluster.+ g.start_selection(0, 0);+ g.extend_selection(0, 1);+ assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));+ }++ #[test]+ fn detects_urls_trimming_trailing_punctuation() {+ let mut g = Grid::new(60, 2);+ for c in "see https://example.com/p?q=1, ok".chars() {+ g.print(c);+ }+ let hits = g.visible_urls();+ assert_eq!(hits.len(), 1);+ assert_eq!(hits[0].url, "https://example.com/p?q=1");+ assert_eq!((hits[0].row, hits[0].col), (0, 4));+ }++ #[test]+ fn detects_url_across_a_soft_wrap() {+ // 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.+ let mut g = Grid::new(20, 3);+ for c in "x https://example.com/averylongpath".chars() {+ g.print(c);+ }+ let hits = g.visible_urls();+ assert_eq!(hits.len(), 1);+ assert_eq!(hits[0].url, "https://example.com/averylongpath");+ }++ #[test]+ fn prompt_mark_survives_reflow() {+ let mut g = Grid::new(10, 4);+ g.set_prompt_mark(PromptKind::OutputStart); // marks line 0+ for c in "hello".chars() {+ g.print(c);+ }+ g.resize(6, 4); // rewrap to a narrower width+ // The mark followed its logical line, so the output is still found.+ assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));+ }++ #[test]+ fn carriage_return_and_line_feed() {+ let mut g = Grid::new(8, 4);+ for c in "hi".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "yo".chars() {+ g.print(c);+ }+ assert_eq!(g.row_text(0), "hi");+ assert_eq!(g.row_text(1), "yo");+ }++ #[test]+ fn line_feed_scrolls_at_bottom() {+ let mut g = Grid::new(4, 2);+ g.print('a');+ g.next_line();+ g.print('b');+ g.next_line(); // scrolls: row0 <- "b", row1 blank+ assert_eq!(g.row_text(0), "b");+ assert_eq!(g.row_text(1), "");+ }++ #[test]+ fn erase_line_to_right() {+ let mut g = Grid::new(6, 1);+ for c in "abcdef".chars() {+ g.print(c);+ }+ g.move_to(2, 0);+ g.erase_line(0);+ assert_eq!(g.row_text(0), "ab");+ }++ #[test]+ fn delete_and_insert_chars() {+ let mut g = Grid::new(6, 1);+ for c in "abcdef".chars() {+ g.print(c);+ }+ g.move_to(1, 0);+ g.delete_chars(2);+ assert_eq!(g.row_text(0), "adef");+ g.move_to(1, 0);+ g.insert_chars(2);+ assert_eq!(g.row_text(0), "a def");+ }++ #[test]+ fn scrollback_captures_scrolled_lines() {+ let mut g = Grid::new(8, 2);+ for c in ['1', '2', '3'] {+ g.print(c);+ g.next_line();+ }+ // Live screen: newest line on top, cleared line below.+ assert_eq!(g.view_row(0)[0].c, '3');+ assert!(g.view_at_bottom());+ // Scroll back to reveal the two captured lines.+ g.scroll_view(2);+ assert!(!g.view_at_bottom());+ assert_eq!(g.view_row(0)[0].c, '1');+ assert_eq!(g.view_row(1)[0].c, '2');+ g.scroll_to_bottom();+ assert_eq!(g.view_row(0)[0].c, '3');+ }++ #[test]+ fn wide_char_occupies_two_columns() {+ let mut g = Grid::new(6, 1);+ g.print('世');+ g.print('x');+ assert_eq!(g.cell(0, 0).c, '世');+ assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));+ assert_eq!(g.cell(2, 0).c, 'x');+ }++ #[test]+ fn selection_extracts_text_across_rows() {+ let mut g = Grid::new(8, 2);+ for c in "abcd".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "efgh".chars() {+ g.print(c);+ }+ // Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).+ g.start_selection(0, 2);+ g.extend_selection(1, 1);+ assert!(g.is_selected(0, 3));+ assert!(g.is_selected(1, 0));+ assert!(!g.is_selected(1, 2));+ assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));+ }++ #[test]+ fn select_word_spans_one_word() {+ let mut g = Grid::new(16, 1);+ for c in "foo bar baz".chars() {+ g.print(c);+ }+ g.select_word(0, 5); // inside "bar"+ assert_eq!(g.selection_text().as_deref(), Some("bar"));+ }++ #[test]+ fn select_word_breaks_on_delimiters_but_keeps_paths() {+ let mut g = Grid::new(32, 1);+ for c in "run /usr/bin:next".chars() {+ g.print(c);+ }+ // '/' and ':' are not delimiters, so the path selects whole.+ g.select_word(0, 7); // inside "/usr/bin:next"+ assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));+ // '(' is a delimiter.+ let mut g = Grid::new(16, 1);+ for c in "f(arg)".chars() {+ g.print(c);+ }+ g.select_word(0, 2); // inside "arg"+ assert_eq!(g.selection_text().as_deref(), Some("arg"));+ }++ #[test]+ fn block_selection_is_rectangular() {+ let mut g = Grid::new(8, 3);+ for line in ["abcd", "efgh", "ijkl"] {+ for c in line.chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ }+ // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.+ g.start_block_selection(0, 1);+ g.extend_selection(2, 2);+ assert!(g.is_selected(0, 1));+ assert!(g.is_selected(2, 2));+ assert!(!g.is_selected(1, 3));+ assert!(!g.is_selected(1, 0));+ assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));+ }++ #[test]+ fn search_finds_matches_across_history() {+ let mut g = Grid::new(16, 2);+ for line in ["alpha", "beta", "ALPHA", "gamma"] {+ for c in line.chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ }+ // Case-insensitive (no uppercase in query) matches both alphas.+ g.set_search("alpha");+ assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit+ let spans0 = g.search_spans_on(0);+ assert_eq!(spans0, vec![(0, 4, false)]);+ // Smart case: an uppercase letter restricts to the exact-case hit.+ g.set_search("ALPHA");+ assert_eq!(g.search_count(), (1, 1));+ assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);+ // Stepping wraps and the no-match query reports zero.+ g.set_search("zzz");+ assert_eq!(g.search_count(), (0, 0));+ assert_eq!(g.search_query(), Some("zzz"));+ g.clear_search();+ assert_eq!(g.search_query(), None);+ }++ #[test]+ fn reflow_rewraps_a_wrapped_paragraph() {+ let mut g = Grid::new(4, 3);+ for c in "abcdefgh".chars() {+ g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)+ }+ // Widen: the two wrapped rows rejoin into one logical line.+ g.resize(8, 3);+ assert_eq!(g.row_text(0), "abcdefgh");+ assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end+ // Narrow back: it rewraps to the smaller width without losing text.+ g.resize(4, 3);+ assert_eq!(g.row_text(0), "abcd");+ assert_eq!(g.row_text(1), "efgh");+ }++ #[test]+ fn reflow_preserves_hard_breaks() {+ let mut g = Grid::new(10, 3);+ for c in "one".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "two".chars() {+ g.print(c);+ }+ // A hard newline must not be rejoined when the width changes.+ g.resize(6, 3);+ assert_eq!(g.row_text(0), "one");+ assert_eq!(g.row_text(1), "two");+ }++ #[test]+ fn scroll_region_limits_line_feed() {+ let mut g = Grid::new(4, 4);+ g.set_scroll_region(1, 2);+ g.move_to(0, 2); // bottom of region (origin off: absolute row 2)+ g.print('x');+ g.move_to(0, 2);+ g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1+ assert_eq!(g.row_text(1), "x");+ assert_eq!(g.row_text(2), "");+ }+}@@ -0,0 +1,148 @@+use super::*;++/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+struct Match {+ row: usize,+ col: usize,+ len: usize,+}++/// Incremental scrollback search: the query, every hit, and the focused one.+#[derive(Clone, Debug, Default)]+pub(super) struct SearchState {+ query: String,+ matches: Vec<Match>,+ current: usize,+}++impl Grid {+ // --- scrollback search ---++ /// Set the search query and recompute matches over scrollback + the live+ /// screen, focusing the most recent hit and scrolling it into view. An+ /// empty query keeps search mode active but clears the hit list.+ pub fn set_search(&mut self, query: &str) {+ let matches = if query.is_empty() {+ Vec::new()+ } else {+ self.compute_matches(query)+ };+ let current = matches.len().saturating_sub(1);+ self.search = Some(SearchState {+ query: query.to_string(),+ matches,+ current,+ });+ self.jump_to_current();+ }++ /// Move the focused match `forward` (toward newer) or back (toward older),+ /// wrapping, and scroll it into view.+ pub fn search_step(&mut self, forward: bool) {+ if let Some(s) = self.search.as_mut()+ && !s.matches.is_empty()+ {+ let n = s.matches.len();+ s.current = if forward {+ (s.current + 1) % n+ } else {+ (s.current + n - 1) % n+ };+ }+ self.jump_to_current();+ }++ pub fn clear_search(&mut self) {+ self.search = None;+ }++ /// The current query, while search mode is active.+ pub fn search_query(&self) -> Option<&str> {+ self.search.as_ref().map(|s| s.query.as_str())+ }++ /// `(focused match index 1-based, total matches)` for the search prompt.+ pub fn search_count(&self) -> (usize, usize) {+ self.search.as_ref().map_or((0, 0), |s| {+ let total = s.matches.len();+ (if total == 0 { 0 } else { s.current + 1 }, total)+ })+ }++ /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.+ pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {+ let Some(s) = self.search.as_ref() else {+ return Vec::new();+ };+ s.matches+ .iter()+ .enumerate()+ .filter(|(_, m)| m.row == row && m.len > 0)+ .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))+ .collect()+ }++ fn compute_matches(&self, query: &str) -> Vec<Match> {+ // Smart case: an uppercase letter in the query forces case sensitivity.+ let sensitive = query.chars().any(|c| c.is_ascii_uppercase());+ let fold = |c: char| {+ if sensitive { c } else { c.to_ascii_lowercase() }+ };+ let needle: Vec<char> = query.chars().map(fold).collect();+ if needle.is_empty() {+ return Vec::new();+ }+ let total = self.scrollback.len() + self.rows;+ let mut matches = Vec::new();+ for row in 0..total {+ let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();+ let mut i = 0;+ while i + needle.len() <= hay.len() {+ if hay[i..i + needle.len()] == needle[..] {+ matches.push(Match {+ row,+ col: i,+ len: needle.len(),+ });+ i += needle.len();+ } else {+ i += 1;+ }+ }+ }+ matches+ }++ /// Scroll the viewport so the focused match is on screen, centering it only+ /// when it would otherwise be off the visible range.+ fn jump_to_current(&mut self) {+ let Some(abs) = self+ .search+ .as_ref()+ .and_then(|s| s.matches.get(s.current))+ .map(|m| m.row)+ else {+ return;+ };+ let sb = self.scrollback.len();+ let top = sb - self.view_offset;+ let bottom = top + self.rows - 1;+ if abs < top || abs > bottom {+ let target = sb as isize + self.rows as isize / 2 - abs as isize;+ self.view_offset = target.clamp(0, sb as isize) as usize;+ }+ }++ /// Slide search hits up by `n` rows after scrollback eviction, dropping any+ /// that scrolled off the top.+ pub(super) fn shift_search(&mut self, n: usize) {+ if let Some(s) = self.search.as_mut() {+ s.matches.retain(|m| m.row >= n);+ for m in &mut s.matches {+ m.row -= n;+ }+ s.current = s.current.min(s.matches.len().saturating_sub(1));+ }+ }+}@@ -0,0 +1,190 @@+use super::*;++impl Grid {+ /// Slide an active selection up by `n` rows after scrollback eviction,+ /// dropping it if either endpoint scrolled off the top.+ pub(super) fn shift_selection(&mut self, n: usize) {+ if let Some((a, b)) = self.selection {+ if a.row < n || b.row < n {+ self.selection = None;+ } else {+ self.selection = Some((+ Point {+ row: a.row - n,+ ..a+ },+ Point {+ row: b.row - n,+ ..b+ },+ ));+ }+ }+ }++ pub fn clear_selection(&mut self) {+ self.selection = None;+ }++ /// Begin a linear selection at an absolute point (drag anchor).+ pub fn start_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = false;+ }++ /// Begin a rectangular (block) selection at an absolute point.+ pub fn start_block_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = true;+ }++ /// Move the selection head (drag), keeping the anchor fixed.+ pub fn extend_selection(&mut self, row: usize, col: usize) {+ if let Some((_, head)) = self.selection.as_mut() {+ *head = Point { row, col };+ }+ }++ /// Select the word at an absolute point, breaking on whitespace and the+ /// default delimiter set.+ pub fn select_word(&mut self, row: usize, col: usize) {+ let delims = &self.word_delimiters;+ let line = self.abs_row(row);+ if col >= line.len() || !is_word(line[col].c, delims) {+ self.start_selection(row, col);+ return;+ }+ let mut lo = col;+ while lo > 0 && is_word(line[lo - 1].c, delims) {+ lo -= 1;+ }+ let mut hi = col;+ while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {+ hi += 1;+ }+ self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));+ self.selection_block = false;+ }++ /// Select the whole line at an absolute row.+ pub fn select_line(&mut self, row: usize) {+ let last = self.abs_row(row).len().saturating_sub(1);+ self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));+ self.selection_block = false;+ }++ /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block+ /// selection.+ fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {+ let (a, b) = self.selection?;+ Some((+ a.row.min(b.row),+ a.row.max(b.row),+ a.col.min(b.col),+ a.col.max(b.col),+ ))+ }++ /// Normalized selection (start <= end in reading order), if any.+ fn ordered_selection(&self) -> Option<(Point, Point)> {+ self.selection.map(|(a, b)| {+ if (a.row, a.col) <= (b.row, b.col) {+ (a, b)+ } else {+ (b, a)+ }+ })+ }++ /// Whether the cell at an absolute `(row, col)` falls inside the selection.+ pub fn is_selected(&self, row: usize, col: usize) -> bool {+ if self.selection_block {+ let Some((r0, r1, c0, c1)) = self.block_rect() else {+ return false;+ };+ return row >= r0 && row <= r1 && col >= c0 && col <= c1;+ }+ let Some((start, end)) = self.ordered_selection() else {+ return false;+ };+ if row < start.row || row > end.row {+ return false;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row { end.col } else { usize::MAX };+ col >= lo && col <= hi+ }++ /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if+ /// any part of that row is selected.+ pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ return (row >= r0 && row <= r1).then_some((c0, c1));+ }+ let (start, end) = self.ordered_selection()?;+ if row < start.row || row > end.row {+ return None;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ end.col+ } else {+ self.abs_row(row).len().saturating_sub(1)+ };+ Some((lo, hi))+ }++ /// The selected text, with trailing blanks trimmed per line and rows joined+ /// by newlines. `None` if there is no selection.+ pub fn selection_text(&self) -> Option<String> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ let mut out = String::new();+ for row in r0..=r1 {+ out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());+ if row != r1 {+ out.push('\n');+ }+ }+ return Some(out);+ }+ let (start, end) = self.ordered_selection()?;+ let mut out = String::new();+ for row in start.row..=end.row {+ let line = self.abs_row(row);+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ (end.col + 1).min(line.len())+ } else {+ line.len()+ };+ out.push_str(self.row_slice_text(row, lo, hi).trim_end());+ if row != end.row {+ out.push('\n');+ }+ }+ Some(out)+ }++ /// The characters of an absolute row in `[from, to)`, skipping wide+ /// continuation cells.+ pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {+ let mut out = String::new();+ for cell in self+ .abs_row(row)+ .get(from..to.min(self.abs_row(row).len()))+ .unwrap_or(&[])+ .iter()+ .filter(|c| !c.flags.contains(Flags::WIDE_CONT))+ {+ out.push(cell.c);+ if let Some(marks) = &cell.combining {+ out.push_str(marks);+ }+ }+ out+ }+}@@ -20,7 +20,7 @@ use crate::config::Config; /// A fast, software-rendered, Wayland-native terminal emulator. #[derive(Parse)]-#[pound(name = "beer", version = "0.0.0")]+#[pound(name = "beer", version = "0.2.0")] struct Cli { /// Run as a daemon hosting multiple windows. #[pound(long)]@@ -1,1253 +0,0 @@-//! VT emulation: feed bytes through `vte` and drive the [`Grid`].--use std::io::Write as _;--use vte::{Params, Perform};--use crate::grid::{- Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,-};-use crate::theme::{Rgb, Theme};--/// G0/G1 character set designation.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-enum Charset {- Ascii,- DecSpecial,-}--/// Which device-attributes query is being answered.-#[derive(Clone, Copy, Debug)]-enum DaLevel {- Primary,- Secondary,- Tertiary,-}--/// A clipboard request from the application (OSC 52), for the front-end to act-/// on since it owns the Wayland selections.-#[derive(Clone, Debug)]-pub enum ClipboardOp {- /// Set the clipboard (or primary) to `text`.- Set { primary: bool, text: String },- /// Report the current clipboard (or primary) contents back to the app.- Query { primary: bool },-}--/// A desktop notification an application requested (OSC 9 / 777 / 99), for the-/// front-end to deliver via the configured notifier.-#[derive(Clone, Debug, PartialEq, Eq)]-pub struct Notification {- pub title: Option<String>,- pub body: String,-}--/// Which dynamic colour an OSC 10/11/17/19 escape targets.-#[derive(Clone, Copy, Debug)]-enum Dynamic {- Fg,- Bg,- SelBg,- SelFg,-}--/// DECRQM mode-state code: 1 = set, 2 = reset.-fn set_reset(on: bool) -> u8 {- if on { 1 } else { 2 }-}--/// Parse an OSC colour spec into an [`Rgb`].-fn parse_spec(spec: &[u8]) -> Option<Rgb> {- std::str::from_utf8(spec)- .ok()- .and_then(crate::theme::parse_color)-}--/// Parse a decimal palette index (0-255).-fn parse_index(b: &[u8]) -> Option<u8> {- std::str::from_utf8(b).ok()?.parse().ok()-}--fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {- (rgb.0, rgb.1, rgb.2)-}--/// Select `protocol` when a mouse mode is set, else turn reporting off.-fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {- if on { protocol } else { MouseProtocol::Off }-}--/// Select `encoding` when its mode is set, else fall back to the default form.-fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {- if on { encoding } else { MouseEncoding::X10 }-}--/// Map an SGR 4 param (`4` or `4:x`) to an underline style.-fn underline_from(param: &[u16]) -> Underline {- match param.get(1).copied().unwrap_or(1) {- 0 => Underline::None,- 2 => Underline::Double,- 3 => Underline::Curly,- 4 => Underline::Dotted,- 5 => Underline::Dashed,- _ => Underline::Single,- }-}--/// The terminal model: a grid plus the escape-sequence state around it.-#[derive(Debug)]-pub struct Term {- grid: Grid,- title: Option<String>,- title_stack: Vec<Option<String>>,- response: Vec<u8>,- g0: Charset,- g1: Charset,- shift_out: bool,- /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.- xtgettcap: Option<Vec<u8>>,- /// Pending OSC 52 clipboard requests, drained by the front-end.- clipboard_ops: Vec<ClipboardOp>,- /// The active colour scheme (seeded from config, mutated by OSC escapes).- theme: Theme,- /// Set when the child rings the bell (`BEL`); cleared by the front-end.- bell: bool,- /// Working directory reported by the shell via OSC 7, for new windows.- cwd: Option<String>,- /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.- notifications: Vec<Notification>,-}--impl Term {- pub fn new(cols: usize, rows: usize) -> Self {- Self {- grid: Grid::new(cols, rows),- title: None,- title_stack: Vec::new(),- response: Vec::new(),- g0: Charset::Ascii,- g1: Charset::Ascii,- shift_out: false,- xtgettcap: None,- clipboard_ops: Vec::new(),- theme: Theme::default(),- bell: false,- cwd: None,- notifications: Vec::new(),- }- }-- /// The working directory last reported by the shell (OSC 7), if any.- pub fn cwd(&self) -> Option<&str> {- self.cwd.as_deref()- }-- /// Drain the desktop notifications requested since the last call.- pub fn take_notifications(&mut self) -> Vec<Notification> {- std::mem::take(&mut self.notifications)- }-- /// Take and clear the pending bell flag.- pub fn take_bell(&mut self) -> bool {- std::mem::take(&mut self.bell)- }-- /// Drain the OSC 52 clipboard requests accumulated since the last call.- pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {- std::mem::take(&mut self.clipboard_ops)- }-- pub fn theme(&self) -> &Theme {- &self.theme- }-- /// Replace the colour scheme (config load / reload).- pub fn set_theme(&mut self, theme: Theme) {- self.theme = theme;- }-- /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply- /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.- fn answer_xtgettcap(&mut self, payload: &[u8]) {- for name_hex in payload.split(|&b| b == b';') {- let value = decode_hex(name_hex).and_then(|name| cap_value(&name));- match value {- Some(value) => {- self.response.extend_from_slice(b"\x1bP1+r");- self.response.extend_from_slice(name_hex);- self.response.push(b'=');- for byte in value.bytes() {- let _ = write!(self.response, "{byte:02x}");- }- self.response.extend_from_slice(b"\x1b\\");- }- None => {- self.response.extend_from_slice(b"\x1bP0+r");- self.response.extend_from_slice(name_hex);- self.response.extend_from_slice(b"\x1b\\");- }- }- }- }-- pub fn grid(&self) -> &Grid {- &self.grid- }-- pub fn grid_mut(&mut self) -> &mut Grid {- &mut self.grid- }-- pub fn resize(&mut self, cols: usize, rows: usize) {- self.grid.resize(cols, rows);- }-- pub fn scroll_view(&mut self, delta: isize) {- self.grid.scroll_view(delta);- }-- pub fn scroll_to_bottom(&mut self) {- self.grid.scroll_to_bottom();- }-- /// Lines per page, for page-scroll bindings.- pub fn page(&self) -> usize {- self.grid.page()- }-- pub fn title(&self) -> Option<&str> {- self.title.as_deref()- }-- /// Bytes the terminal needs to send back to the application (DA, CPR, ...).- pub fn take_response(&mut self) -> Vec<u8> {- std::mem::take(&mut self.response)- }-- fn active_charset(&self) -> Charset {- if self.shift_out { self.g1 } else { self.g0 }- }-- fn set_mode(&mut self, params: &Params, private: bool, on: bool) {- for p in params.iter() {- let Some(&code) = p.first() else { continue };- match (private, code) {- (true, 6) => self.grid.set_origin(on),- (true, 7) => self.grid.set_autowrap(on),- (true, 1049) => {- if on {- self.grid.save_cursor();- self.grid.enter_alt_screen();- self.grid.erase_display(2);- } else {- self.grid.leave_alt_screen();- self.grid.restore_cursor();- }- }- (true, 47 | 1047) => {- if on {- self.grid.enter_alt_screen();- } else {- self.grid.leave_alt_screen();- }- }- (false, 4) => self.grid.set_insert(on),- (true, 1) => self.grid.set_app_cursor(on),- (true, 25) => self.grid.set_cursor_visible(on),- (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),- (true, 1000) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Normal)),- (true, 1002) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Button)),- (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),- (true, 1004) => self.grid.set_focus_events(on),- (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),- (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),- (true, 2004) => self.grid.set_bracketed_paste(on),- (true, 2026) => self.grid.set_sync(on),- _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),- }- }- }-- fn sgr(&mut self, params: &Params) {- let items: Vec<&[u16]> = params.iter().collect();- if items.is_empty() {- self.grid.reset_pen();- return;- }- let mut i = 0;- while i < items.len() {- let p = items[i];- let code = p.first().copied().unwrap_or(0);- let pen = self.grid.pen_mut();- let mut step = 1;- match code {- 0 => *pen = Default::default(),- 1 => pen.flags.insert(Flags::BOLD),- 2 => pen.flags.insert(Flags::DIM),- 3 => pen.flags.insert(Flags::ITALIC),- 4 => pen.underline = underline_from(p),- 5 | 6 => pen.flags.insert(Flags::BLINK),- 7 => pen.flags.insert(Flags::REVERSE),- 8 => pen.flags.insert(Flags::HIDDEN),- 9 => pen.flags.insert(Flags::STRIKE),- 21 => pen.underline = Underline::Double,- 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),- 23 => pen.flags.remove(Flags::ITALIC),- 24 => pen.underline = Underline::None,- 25 => pen.flags.remove(Flags::BLINK),- 27 => pen.flags.remove(Flags::REVERSE),- 28 => pen.flags.remove(Flags::HIDDEN),- 29 => pen.flags.remove(Flags::STRIKE),- 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),- 39 => pen.fg = Color::Default,- 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),- 49 => pen.bg = Color::Default,- 53 => pen.flags.insert(Flags::OVERLINE),- 55 => pen.flags.remove(Flags::OVERLINE),- 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),- 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),- 38 | 48 | 58 => {- let (color, consumed) = ext_color(&items, i);- if let Some(color) = color {- match code {- 38 => pen.fg = color,- 48 => pen.bg = color,- _ => pen.underline_color = color,- }- }- step = consumed;- }- 59 => pen.underline_color = Color::Default,- _ => {}- }- i += step;- }- }-- /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic- /// firmware level; DA3 a (zero) unit ID.- fn device_attrs(&mut self, level: DaLevel) {- match level {- DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),- DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),- DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),- }- }-- /// XTVERSION (`CSI > q`): report the terminal name and version.- fn report_version(&mut self) {- let _ = write!(- self.response,- "\x1bP>|beer({})\x1b\\",- env!("CARGO_PKG_VERSION")- );- }-- /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).- fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {- let Rgb(r, g, b) = rgb;- let _ = write!(- self.response,- "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",- u16::from(r) * 0x101,- u16::from(g) * 0x101,- u16::from(b) * 0x101,- );- self.response- .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });- }-- /// OSC 4: set or query palette entries, given as `index;spec` pairs.- fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {- let mut rest = params[1..].iter();- while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {- let Some(idx) = parse_index(idx_raw) else {- continue;- };- if *spec == b"?" {- let rgb = self.theme.palette[idx as usize];- self.reply_color(&format!("4;{idx}"), rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- self.theme.set_palette(idx, rgb);- }- }- }-- /// OSC 10/11/17/19: set or query a dynamic colour.- fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {- let Some(spec) = spec else { return };- if **spec == b"?"[..] {- let rgb = match kind {- Dynamic::Fg => self.theme.fg,- Dynamic::Bg => self.theme.bg,- Dynamic::SelBg => self.theme.selection_bg,- Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),- };- let code = match kind {- Dynamic::Fg => "10",- Dynamic::Bg => "11",- Dynamic::SelBg => "17",- Dynamic::SelFg => "19",- };- self.reply_color(code, rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- match kind {- Dynamic::Fg => self.theme.fg = rgb,- Dynamic::Bg => self.theme.bg = rgb,- Dynamic::SelBg => self.theme.selection_bg = rgb,- Dynamic::SelFg => self.theme.selection_fg = Some(rgb),- }- }- }-- fn device_status(&mut self, params: &Params) {- match params.iter().next().and_then(|p| p.first().copied()) {- Some(5) => self.response.extend_from_slice(b"\x1b[0n"),- Some(6) => {- let (x, y) = self.grid.cursor();- let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);- }- _ => {}- }- }-- /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),- /// or unrecognized (0). Only the modes we actually track are reported.- fn report_mode(&mut self, params: &Params, private: bool) {- let code = raw(params, 0);- let state = match (private, code) {- (true, 6) => set_reset(self.grid.origin()),- (true, 7) => set_reset(self.grid.autowrap()),- (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),- (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),- (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),- (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),- (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),- (true, 1004) => set_reset(self.grid.focus_events()),- (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),- (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),- (true, 2004) => set_reset(self.grid.bracketed_paste()),- (true, 2026) => set_reset(self.grid.sync_active()),- (false, 4) => set_reset(self.grid.insert()),- _ => 0,- };- let prefix = if private { "?" } else { "" };- let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");- }-- /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.- fn title_stack_op(&mut self, params: &Params) {- match raw(params, 0) {- 22 => self.title_stack.push(self.title.clone()),- 23 => {- if let Some(title) = self.title_stack.pop() {- self.title = title;- }- }- _ => {}- }- }-}--/// First param value, with 0/absent folded to `default` (xterm convention for-/// cursor movement and counts).-fn n(params: &Params, idx: usize, default: usize) -> usize {- match params.iter().nth(idx).and_then(|p| p.first().copied()) {- Some(0) | None => default,- Some(v) => v as usize,- }-}--/// Raw first param value (0 is meaningful), defaulting to 0 when absent.-fn raw(params: &Params, idx: usize) -> u16 {- params- .iter()- .nth(idx)- .and_then(|p| p.first().copied())- .unwrap_or(0)-}--/// Parse an SGR 38/48 extended colour, returning the colour and how many-/// top-level params it consumed (1 for colon form, more for semicolon form).-fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {- let head = items[i];- if head.len() >= 2 {- return (color_from_subparams(&head[1..]), 1);- }- match items.get(i + 1).and_then(|s| s.first().copied()) {- Some(5) => {- let idx = items- .get(i + 2)- .and_then(|s| s.first().copied())- .unwrap_or(0);- (Some(Color::Indexed(idx as u8)), 3)- }- Some(2) => {- let get = |k: usize| {- items- .get(i + k)- .and_then(|s| s.first().copied())- .unwrap_or(0)- };- (- Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),- 5,- )- }- _ => (None, 1),- }-}--fn color_from_subparams(sub: &[u16]) -> Option<Color> {- match sub.first().copied() {- Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),- Some(2) => {- // Either `2:r:g:b` or `2:colorspace:r:g:b`.- let rgb = if sub.len() >= 5 {- &sub[2..5]- } else {- &sub[1..]- };- match rgb {- [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),- _ => None,- }- }- _ => None,- }-}--fn charset(byte: u8) -> Charset {- match byte {- b'0' => Charset::DecSpecial,- _ => Charset::Ascii,- }-}--/// Translate a byte under the DEC special graphics set (line drawing).-fn dec_special(c: char) -> char {- match c {- '`' => '◆',- 'a' => '▒',- 'f' => '°',- 'g' => '±',- 'j' => '┘',- 'k' => '┐',- 'l' => '┌',- 'm' => '└',- 'n' => '┼',- 'o' => '⎺',- 'p' => '⎻',- 'q' => '─',- 'r' => '⎼',- 's' => '⎽',- 't' => '├',- 'u' => '┤',- 'v' => '┴',- 'w' => '┬',- 'x' => '│',- 'y' => '≤',- 'z' => '≥',- '~' => '·',- _ => c,- }-}--impl Perform for Term {- fn print(&mut self, c: char) {- let c = if self.active_charset() == Charset::DecSpecial {- dec_special(c)- } else {- c- };- self.grid.print(c);- }-- fn execute(&mut self, byte: u8) {- match byte {- 0x07 => self.bell = true,- 0x08 => self.grid.backspace(),- 0x09 => self.grid.tab(),- 0x0A..=0x0C => self.grid.line_feed(),- 0x0D => self.grid.carriage_return(),- 0x0E => self.shift_out = true,- 0x0F => self.shift_out = false,- _ => {}- }- }-- fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {- let private = intermediates.first() == Some(&b'?');- match action {- 'A' => self.grid.cursor_up(n(params, 0, 1)),- 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),- 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),- 'D' => self.grid.cursor_back(n(params, 0, 1)),- 'E' => {- self.grid.cursor_down(n(params, 0, 1));- self.grid.carriage_return();- }- 'F' => {- self.grid.cursor_up(n(params, 0, 1));- self.grid.carriage_return();- }- 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),- 'd' => self.grid.move_to_row(n(params, 0, 1) - 1),- 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),- 'J' => self.grid.erase_display(raw(params, 0)),- 'K' => self.grid.erase_line(raw(params, 0)),- '@' => self.grid.insert_chars(n(params, 0, 1)),- 'P' => self.grid.delete_chars(n(params, 0, 1)),- 'L' => self.grid.insert_lines(n(params, 0, 1)),- 'M' => self.grid.delete_lines(n(params, 0, 1)),- 'X' => self.grid.erase_chars(n(params, 0, 1)),- 'S' => self.grid.scroll_up(n(params, 0, 1)),- 'T' => self.grid.scroll_down(n(params, 0, 1)),- 'm' => self.sgr(params),- 'r' => {- let top = n(params, 0, 1) - 1;- let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {- Some(0) | None => self.grid.rows() - 1,- Some(v) => (v as usize).saturating_sub(1),- };- self.grid.set_scroll_region(top, bottom);- }- 'h' => self.set_mode(params, private, true),- 'l' => self.set_mode(params, private, false),- 'c' => self.device_attrs(match intermediates.first() {- Some(b'>') => DaLevel::Secondary,- Some(b'=') => DaLevel::Tertiary,- _ => DaLevel::Primary,- }),- 'q' if intermediates.first() == Some(&b'>') => self.report_version(),- 'q' if intermediates.first() == Some(&b' ') => {- let code = raw(params, 0);- self.grid.set_cursor_shape(match code {- 3 | 4 => CursorShape::Underline,- 5 | 6 => CursorShape::Beam,- _ => CursorShape::Block,- });- // Even codes are steady; 0/1 and other odd codes blink.- self.grid.set_cursor_blink(code == 0 || code % 2 == 1);- }- 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),- 'n' => self.device_status(params),- 's' => self.grid.save_cursor(),- 'u' => self.grid.restore_cursor(),- 't' => self.title_stack_op(params),- 'g' => match raw(params, 0) {- 3 => self.grid.clear_all_tabs(),- _ => self.grid.clear_tab(),- },- _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),- }- }-- fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {- match (intermediates.first().copied(), byte) {- (None, b'D') => self.grid.line_feed(),- (None, b'M') => self.grid.reverse_index(),- (None, b'E') => self.grid.next_line(),- (None, b'7') => self.grid.save_cursor(),- (None, b'8') => self.grid.restore_cursor(),- (None, b'H') => self.grid.set_tab(),- (None, b'c') => {- self.grid.reset_pen();- self.grid.set_scroll_region(0, self.grid.rows() - 1);- self.grid.set_autowrap(true);- self.grid.set_origin(false);- self.grid.erase_display(2);- self.grid.move_to(0, 0);- self.g0 = Charset::Ascii;- self.g1 = Charset::Ascii;- self.shift_out = false;- }- (Some(b'('), c) => self.g0 = charset(c),- (Some(b')'), c) => self.g1 = charset(c),- _ => {}- }- }-- fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {- match params.first() {- Some(&n) if n == b"0" || n == b"2" => {- if let Some(text) = params.get(1) {- self.title = Some(String::from_utf8_lossy(text).into_owned());- }- }- // OSC 7: the shell reports its cwd as a `file://host/path` URI.- Some(&n) if n == b"7" => {- if let Some(uri) = params.get(1) {- self.cwd = file_uri_path(uri);- }- }- // OSC 133: shell-integration prompt marks (A/B/C/D, with optional- // `;key=value` attributes we ignore).- Some(&n) if n == b"133" => {- if let Some(kind) = params- .get(1)- .and_then(|p| p.first())- .and_then(|&b| prompt_kind(b))- {- self.grid.set_prompt_mark(kind);- }- }- // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the- // link. The URI is everything after the second field, rejoined since- // a URI may itself contain ';'.- Some(&n) if n == b"8" => {- let uri_bytes = params- .get(2..)- .map(|parts| parts.join(&b';'))- .unwrap_or_default();- let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");- self.grid.set_link((!uri.is_empty()).then_some(uri));- }- // OSC 9: iTerm2-style notification (`OSC 9 ; body`).- Some(&n) if n == b"9" => {- if let Some(body) = osc_text(params.get(1)) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 777: `OSC 777 ; notify ; title ; body`.- Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {- let title = osc_text(params.get(2));- if let Some(body) = osc_text(params.get(3)) {- self.notifications.push(Notification { title, body });- }- }- // OSC 99: kitty desktop-notification protocol. We honour the common- // single-chunk form, taking the payload as the body and ignoring the- // metadata key=value field.- Some(&n) if n == b"99" => {- if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 4: set/query palette entries (pairs of index;spec).- Some(&n) if n == b"4" => self.osc_palette(params, bell),- // OSC 104: reset palette (all, or the listed indices).- Some(&n) if n == b"104" => {- if params.len() <= 1 {- self.theme.reset_palette();- } else {- for p in ¶ms[1..] {- if let Some(i) = parse_index(p) {- self.theme.reset_palette_index(i);- }- }- }- }- // OSC 10/11: foreground / background; OSC 110/111 reset them.- Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),- Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),- Some(&n) if n == b"110" => self.theme.reset_fg(),- Some(&n) if n == b"111" => self.theme.reset_bg(),- // OSC 17/19: selection (highlight) background / foreground.- Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),- Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),- // OSC 12: set cursor colour; OSC 112: reset to default.- Some(&n) if n == b"12" => {- self.grid- .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));- }- Some(&n) if n == b"112" => self.grid.set_cursor_color(None),- // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or- // `?` to query. We only touch `c` (clipboard) and `p` (primary).- Some(&n) if n == b"52" => {- let target = params.get(1).copied().unwrap_or(b"");- let data = params.get(2).copied().unwrap_or(b"");- let primary = target.first() == Some(&b'p');- if data == b"?" {- self.clipboard_ops.push(ClipboardOp::Query { primary });- } else if let Some(text) =- base64_decode(data).and_then(|b| String::from_utf8(b).ok())- {- self.clipboard_ops.push(ClipboardOp::Set { primary, text });- }- }- _ => {}- }- }-- fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {- // XTGETTCAP arrives as `DCS + q <names> ST`.- if action == 'q' && intermediates == [b'+'] {- self.xtgettcap = Some(Vec::new());- }- }-- fn put(&mut self, byte: u8) {- if let Some(buf) = self.xtgettcap.as_mut() {- buf.push(byte);- }- }-- fn unhook(&mut self) {- if let Some(payload) = self.xtgettcap.take() {- self.answer_xtgettcap(&payload);- }- }-}--/// Look up a terminfo capability beer reports via XTGETTCAP.-fn cap_value(name: &[u8]) -> Option<&'static str> {- match name {- b"TN" => Some("beer"),- b"Co" | b"colors" => Some("256"),- b"RGB" => Some("8/8/8"),- _ => None,- }-}--const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";--/// Standard base64 encode (used for OSC 52 query replies).-pub fn base64_encode(data: &[u8]) -> String {- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);- for chunk in data.chunks(3) {- let b = [- chunk[0],- *chunk.get(1).unwrap_or(&0),- *chunk.get(2).unwrap_or(&0),- ];- let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);- out.push(B64[(n >> 18 & 63) as usize] as char);- out.push(B64[(n >> 12 & 63) as usize] as char);- out.push(if chunk.len() > 1 {- B64[(n >> 6 & 63) as usize] as char- } else {- '='- });- out.push(if chunk.len() > 2 {- B64[(n & 63) as usize] as char- } else {- '='- });- }- out-}--/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad-/// character.-fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {- let val = |c: u8| -> Option<u32> {- match c {- b'A'..=b'Z' => Some(u32::from(c - b'A')),- b'a'..=b'z' => Some(u32::from(c - b'a') + 26),- b'0'..=b'9' => Some(u32::from(c - b'0') + 52),- b'+' => Some(62),- b'/' => Some(63),- _ => None,- }- };- let filtered: Vec<u8> = data- .iter()- .copied()- .filter(|&c| c != b'=' && !c.is_ascii_whitespace())- .collect();- let mut out = Vec::with_capacity(filtered.len() / 4 * 3);- for chunk in filtered.chunks(4) {- if chunk.len() == 1 {- return None; // a lone sextet cannot form a byte- }- let mut n = 0u32;- for &c in chunk {- n = (n << 6) | val(c)?;- }- n <<= 6 * (4 - chunk.len() as u32);- out.push((n >> 16) as u8);- if chunk.len() >= 3 {- out.push((n >> 8) as u8);- }- if chunk.len() >= 4 {- out.push(n as u8);- }- }- Some(out)-}--/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.-fn osc_text(field: Option<&&[u8]>) -> Option<String> {- field.map(|b| String::from_utf8_lossy(b).into_owned())-}--/// Map an OSC 133 mark letter to a [`PromptKind`].-fn prompt_kind(b: u8) -> Option<PromptKind> {- match b {- b'A' => Some(PromptKind::PromptStart),- b'B' => Some(PromptKind::CmdStart),- b'C' => Some(PromptKind::OutputStart),- b'D' => Some(PromptKind::CmdEnd),- _ => None,- }-}--/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding-/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns-/// `None` if it is not a usable absolute path.-fn file_uri_path(uri: &[u8]) -> Option<String> {- let rest = uri.strip_prefix(b"file://").unwrap_or(uri);- // Skip the authority (host) up to the first '/', which begins the path.- let slash = rest.iter().position(|&b| b == b'/')?;- let path_bytes = percent_decode(&rest[slash..]);- let path = String::from_utf8(path_bytes).ok()?;- path.starts_with('/').then_some(path)-}--/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.-fn percent_decode(s: &[u8]) -> Vec<u8> {- let mut out = Vec::with_capacity(s.len());- let mut i = 0;- while i < s.len() {- if s[i] == b'%' && i + 2 < s.len() {- let hi = (s[i + 1] as char).to_digit(16);- let lo = (s[i + 2] as char).to_digit(16);- if let (Some(hi), Some(lo)) = (hi, lo) {- out.push((hi * 16 + lo) as u8);- i += 3;- continue;- }- }- out.push(s[i]);- i += 1;- }- out-}--/// Decode an even-length lowercase/uppercase hex string into bytes.-fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {- if s.is_empty() || !s.len().is_multiple_of(2) {- return None;- }- let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);- s.chunks_exact(2)- .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))- .collect()-}--#[cfg(test)]-mod tests {- use super::*;- use crate::grid::{MouseEncoding, MouseProtocol};-- fn feed(term: &mut Term, bytes: &[u8]) {- let mut parser = vte::Parser::new();- parser.advance(term, bytes);- }-- #[test]- fn plain_text_lands_in_the_grid() {- let mut t = Term::new(20, 4);- feed(&mut t, b"hello");- assert_eq!(t.grid().row_text(0), "hello");- }-- #[test]- fn cursor_position_and_erase() {- let mut t = Term::new(20, 4);- feed(&mut t, b"abcde\x1b[Hxyz");- assert_eq!(t.grid().row_text(0), "xyzde");- }-- #[test]- fn newline_sequence() {- let mut t = Term::new(20, 4);- feed(&mut t, b"one\r\ntwo");- assert_eq!(t.grid().row_text(0), "one");- assert_eq!(t.grid().row_text(1), "two");- }-- #[test]- fn device_attributes_levels() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[c");- assert_eq!(t.take_response(), b"\x1b[?62;22c");- feed(&mut t, b"\x1b[>c");- assert_eq!(t.take_response(), b"\x1b[>0;276;0c");- feed(&mut t, b"\x1b[=c");- assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");- }-- #[test]- fn xtversion_reports_name() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[>q");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1bP>|beer("));- assert!(resp.ends_with(b")\x1b\\"));- }-- #[test]- fn decrqm_reports_known_modes() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default- assert_eq!(t.take_response(), b"\x1b[?7;1$y");- feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query- assert_eq!(t.take_response(), b"\x1b[?7;2$y");- feed(&mut t, b"\x1b[?9999$p"); // unknown mode- assert_eq!(t.take_response(), b"\x1b[?9999;0$y");- }-- #[test]- fn sgr_underline_styles_and_lines() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4:3;58;5;1;53mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.underline, Underline::Curly);- assert_eq!(cell.underline_color, Color::Indexed(1));- assert!(cell.flags.contains(Flags::OVERLINE));- // 4:0 turns the underline back off.- feed(&mut t, b"\x1b[4:0mY");- assert_eq!(t.grid().cell(1, 0).underline, Underline::None);- }-- #[test]- fn decscusr_and_cursor_visibility() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b[6 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[0 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Block);-- feed(&mut t, b"\x1b[?25l");- assert!(!t.grid().cursor_visible());- feed(&mut t, b"\x1b[?25h");- assert!(t.grid().cursor_visible());- }-- #[test]- fn osc12_sets_and_resets_cursor_color() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]12;#ff0000\x07");- assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));- feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");- assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- }-- #[test]- fn decscusr_and_cursor_color() {- use crate::grid::CursorShape;- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[5 q"); // blinking bar- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[4 q"); // steady underline- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b]12;#ff3030\x07");- assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- feed(&mut t, b"\x1b[?25l"); // hide cursor- assert!(!t.grid().cursor_visible());- }-- #[test]- fn osc_palette_and_dynamic_colors() {- use crate::theme::Rgb;- let mut t = Term::new(20, 2);- // Set palette index 1 and foreground via OSC, then query them back.- feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");- assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));- feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");- assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));- feed(&mut t, b"\x1b]11;?\x07");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1b]11;rgb:"));- // Reset returns the palette entry to its default.- feed(&mut t, b"\x1b]104;1\x1b\\");- assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));- }-- #[test]- fn xtgettcap_known_and_unknown() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"- assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"- feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown- assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");- }-- #[test]- fn bracketed_paste_and_sync_modes() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b[?2004h");- assert!(t.grid().bracketed_paste());- feed(&mut t, b"\x1b[?2004$p");- assert_eq!(t.take_response(), b"\x1b[?2004;1$y");- feed(&mut t, b"\x1b[?2026h");- assert!(t.grid().sync_active());- feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");- assert!(!t.grid().sync_active());- assert_eq!(t.take_response(), b"\x1b[?2026;2$y");- }-- #[test]- fn base64_round_trips() {- for s in [- "",- "f",- "fo",- "foo",- "foob",- "fooba",- "foobar",- "hi there\n",- ] {- let enc = base64_encode(s.as_bytes());- assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));- }- assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");- assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));- }-- #[test]- fn osc52_set_and_query() {- let mut t = Term::new(20, 2);- // Set clipboard to "hi" (base64 "aGk=").- feed(&mut t, b"\x1b]52;c;aGk=\x07");- let ops = t.take_clipboard_ops();- match ops.as_slice() {- [- ClipboardOp::Set {- primary: false,- text,- },- ] => assert_eq!(text, "hi"),- other => panic!("unexpected ops: {other:?}"),- }- // Query the primary selection.- feed(&mut t, b"\x1b]52;p;?\x07");- let ops = t.take_clipboard_ops();- assert!(matches!(- ops.as_slice(),- [ClipboardOp::Query { primary: true }]- ));- }-- #[test]- fn mouse_modes_track_protocol_and_encoding() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?1002h\x1b[?1006h");- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);- assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);- feed(&mut t, b"\x1b[?1002$p");- assert_eq!(t.take_response(), b"\x1b[?1002;1$y");- feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);- feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);- feed(&mut t, b"\x1b[?1004h");- assert!(t.grid().focus_events());- }-- #[test]- fn title_stack_push_pop() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b]0;first\x07");- feed(&mut t, b"\x1b[22t"); // push "first"- feed(&mut t, b"\x1b]0;second\x07");- assert_eq!(t.title(), Some("second"));- feed(&mut t, b"\x1b[23t"); // pop -> "first"- assert_eq!(t.title(), Some("first"));- }-- #[test]- fn sgr_sets_pen_colours() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[31;1mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.fg, Color::Indexed(1));- assert!(cell.flags.contains(Flags::BOLD));- }-- #[test]- fn truecolor_semicolon_and_colon() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[38;2;10;20;30mA");- assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));- feed(&mut t, b"\x1b[38:2:40:50:60mB");- assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));- }-- #[test]- fn device_status_reports_cursor() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[3;5H\x1b[6n");- assert_eq!(t.take_response(), b"\x1b[3;5R");- }-- #[test]- fn line_drawing_charset() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b(0qx\x1b(B");- assert_eq!(t.grid().row_text(0), "─│");- }-- #[test]- fn osc133_marks_capture_last_command_output() {- let mut t = Term::new(12, 6);- feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command- feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output- feed(&mut t, b"\x1b]133;D\x07"); // command finished- assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));- }-- #[test]- fn osc_notifications_collected() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b]9;hello\x07");- feed(&mut t, b"\x1b]777;notify;Title;Body\x07");- let n = t.take_notifications();- assert_eq!(- n,- vec![- Notification {- title: None,- body: "hello".into()- },- Notification {- title: Some("Title".into()),- body: "Body".into()- },- ]- );- assert!(t.take_notifications().is_empty());- }-- #[test]- fn osc7_tracks_cwd_and_decodes_percent() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");- assert_eq!(t.cwd(), Some("/home/user/my dir"));- // A non-file or relative URI leaves the previous value untouched? It- // simply does not match a path, so cwd stays None here.- let mut t2 = Term::new(20, 1);- feed(&mut t2, b"\x1b]7;file://host\x07");- assert_eq!(t2.cwd(), None);- }-- #[test]- fn title_via_osc() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]0;hello\x07");- assert_eq!(t.title(), Some("hello"));- }-- #[test]- fn alt_screen_preserves_primary() {- let mut t = Term::new(20, 2);- feed(&mut t, b"main");- feed(&mut t, b"\x1b[?1049h");- assert_eq!(t.grid().row_text(0), "");- feed(&mut t, b"\x1b[?1049l");- assert_eq!(t.grid().row_text(0), "main");- }-}@@ -0,0 +1,1018 @@+//! VT emulation: feed bytes through `vte` and drive the [`Grid`].++mod perform;++use std::io::Write as _;++use vte::Params;++use crate::grid::{+ Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,+};+use crate::theme::{Rgb, Theme};++/// G0/G1 character set designation.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+enum Charset {+ Ascii,+ DecSpecial,+}++/// Which device-attributes query is being answered.+#[derive(Clone, Copy, Debug)]+enum DaLevel {+ Primary,+ Secondary,+ Tertiary,+}++/// A clipboard request from the application (OSC 52), for the front-end to act+/// on since it owns the Wayland selections.+#[derive(Clone, Debug)]+pub enum ClipboardOp {+ /// Set the clipboard (or primary) to `text`.+ Set { primary: bool, text: String },+ /// Report the current clipboard (or primary) contents back to the app.+ Query { primary: bool },+}++/// A desktop notification an application requested (OSC 9 / 777 / 99), for the+/// front-end to deliver via the configured notifier.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Notification {+ pub title: Option<String>,+ pub body: String,+}++/// Which dynamic colour an OSC 10/11/17/19 escape targets.+#[derive(Clone, Copy, Debug)]+enum Dynamic {+ Fg,+ Bg,+ SelBg,+ SelFg,+}++/// DECRQM mode-state code: 1 = set, 2 = reset.+fn set_reset(on: bool) -> u8 {+ if on { 1 } else { 2 }+}++/// Parse an OSC colour spec into an [`Rgb`].+fn parse_spec(spec: &[u8]) -> Option<Rgb> {+ std::str::from_utf8(spec)+ .ok()+ .and_then(crate::theme::parse_color)+}++/// Parse a decimal palette index (0-255).+fn parse_index(b: &[u8]) -> Option<u8> {+ std::str::from_utf8(b).ok()?.parse().ok()+}++fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {+ (rgb.0, rgb.1, rgb.2)+}++/// Select `protocol` when a mouse mode is set, else turn reporting off.+fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {+ if on { protocol } else { MouseProtocol::Off }+}++/// Select `encoding` when its mode is set, else fall back to the default form.+fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {+ if on { encoding } else { MouseEncoding::X10 }+}++/// Map an SGR 4 param (`4` or `4:x`) to an underline style.+fn underline_from(param: &[u16]) -> Underline {+ match param.get(1).copied().unwrap_or(1) {+ 0 => Underline::None,+ 2 => Underline::Double,+ 3 => Underline::Curly,+ 4 => Underline::Dotted,+ 5 => Underline::Dashed,+ _ => Underline::Single,+ }+}++/// The terminal model: a grid plus the escape-sequence state around it.+#[derive(Debug)]+pub struct Term {+ grid: Grid,+ title: Option<String>,+ title_stack: Vec<Option<String>>,+ response: Vec<u8>,+ g0: Charset,+ g1: Charset,+ shift_out: bool,+ /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.+ xtgettcap: Option<Vec<u8>>,+ /// Pending OSC 52 clipboard requests, drained by the front-end.+ clipboard_ops: Vec<ClipboardOp>,+ /// The active colour scheme (seeded from config, mutated by OSC escapes).+ theme: Theme,+ /// Set when the child rings the bell (`BEL`); cleared by the front-end.+ bell: bool,+ /// Working directory reported by the shell via OSC 7, for new windows.+ cwd: Option<String>,+ /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.+ notifications: Vec<Notification>,+}++impl Term {+ pub fn new(cols: usize, rows: usize) -> Self {+ Self {+ grid: Grid::new(cols, rows),+ title: None,+ title_stack: Vec::new(),+ response: Vec::new(),+ g0: Charset::Ascii,+ g1: Charset::Ascii,+ shift_out: false,+ xtgettcap: None,+ clipboard_ops: Vec::new(),+ theme: Theme::default(),+ bell: false,+ cwd: None,+ notifications: Vec::new(),+ }+ }++ /// The working directory last reported by the shell (OSC 7), if any.+ pub fn cwd(&self) -> Option<&str> {+ self.cwd.as_deref()+ }++ /// Drain the desktop notifications requested since the last call.+ pub fn take_notifications(&mut self) -> Vec<Notification> {+ std::mem::take(&mut self.notifications)+ }++ /// Take and clear the pending bell flag.+ pub fn take_bell(&mut self) -> bool {+ std::mem::take(&mut self.bell)+ }++ /// Drain the OSC 52 clipboard requests accumulated since the last call.+ pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {+ std::mem::take(&mut self.clipboard_ops)+ }++ pub fn theme(&self) -> &Theme {+ &self.theme+ }++ /// Replace the colour scheme (config load / reload).+ pub fn set_theme(&mut self, theme: Theme) {+ self.theme = theme;+ }++ /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply+ /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.+ fn answer_xtgettcap(&mut self, payload: &[u8]) {+ for name_hex in payload.split(|&b| b == b';') {+ let value = decode_hex(name_hex).and_then(|name| cap_value(&name));+ match value {+ Some(value) => {+ self.response.extend_from_slice(b"\x1bP1+r");+ self.response.extend_from_slice(name_hex);+ self.response.push(b'=');+ for byte in value.bytes() {+ let _ = write!(self.response, "{byte:02x}");+ }+ self.response.extend_from_slice(b"\x1b\\");+ }+ None => {+ self.response.extend_from_slice(b"\x1bP0+r");+ self.response.extend_from_slice(name_hex);+ self.response.extend_from_slice(b"\x1b\\");+ }+ }+ }+ }++ pub fn grid(&self) -> &Grid {+ &self.grid+ }++ pub fn grid_mut(&mut self) -> &mut Grid {+ &mut self.grid+ }++ pub fn resize(&mut self, cols: usize, rows: usize) {+ self.grid.resize(cols, rows);+ }++ pub fn scroll_view(&mut self, delta: isize) {+ self.grid.scroll_view(delta);+ }++ pub fn scroll_to_bottom(&mut self) {+ self.grid.scroll_to_bottom();+ }++ /// Lines per page, for page-scroll bindings.+ pub fn page(&self) -> usize {+ self.grid.page()+ }++ pub fn title(&self) -> Option<&str> {+ self.title.as_deref()+ }++ /// Bytes the terminal needs to send back to the application (DA, CPR, ...).+ pub fn take_response(&mut self) -> Vec<u8> {+ std::mem::take(&mut self.response)+ }++ fn active_charset(&self) -> Charset {+ if self.shift_out { self.g1 } else { self.g0 }+ }++ fn set_mode(&mut self, params: &Params, private: bool, on: bool) {+ for p in params.iter() {+ let Some(&code) = p.first() else { continue };+ match (private, code) {+ (true, 6) => self.grid.set_origin(on),+ (true, 7) => self.grid.set_autowrap(on),+ (true, 1049) => {+ if on {+ self.grid.save_cursor();+ self.grid.enter_alt_screen();+ self.grid.erase_display(2);+ } else {+ self.grid.leave_alt_screen();+ self.grid.restore_cursor();+ }+ }+ (true, 47 | 1047) => {+ if on {+ self.grid.enter_alt_screen();+ } else {+ self.grid.leave_alt_screen();+ }+ }+ (false, 4) => self.grid.set_insert(on),+ (true, 1) => self.grid.set_app_cursor(on),+ (true, 25) => self.grid.set_cursor_visible(on),+ (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),+ (true, 1000) => self+ .grid+ .set_mouse_protocol(proto(on, MouseProtocol::Normal)),+ (true, 1002) => self+ .grid+ .set_mouse_protocol(proto(on, MouseProtocol::Button)),+ (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),+ (true, 1004) => self.grid.set_focus_events(on),+ (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),+ (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),+ (true, 2004) => self.grid.set_bracketed_paste(on),+ (true, 2026) => self.grid.set_sync(on),+ _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),+ }+ }+ }++ fn sgr(&mut self, params: &Params) {+ let items: Vec<&[u16]> = params.iter().collect();+ if items.is_empty() {+ self.grid.reset_pen();+ return;+ }+ let mut i = 0;+ while i < items.len() {+ let p = items[i];+ let code = p.first().copied().unwrap_or(0);+ let pen = self.grid.pen_mut();+ let mut step = 1;+ match code {+ 0 => *pen = Default::default(),+ 1 => pen.flags.insert(Flags::BOLD),+ 2 => pen.flags.insert(Flags::DIM),+ 3 => pen.flags.insert(Flags::ITALIC),+ 4 => pen.underline = underline_from(p),+ 5 | 6 => pen.flags.insert(Flags::BLINK),+ 7 => pen.flags.insert(Flags::REVERSE),+ 8 => pen.flags.insert(Flags::HIDDEN),+ 9 => pen.flags.insert(Flags::STRIKE),+ 21 => pen.underline = Underline::Double,+ 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),+ 23 => pen.flags.remove(Flags::ITALIC),+ 24 => pen.underline = Underline::None,+ 25 => pen.flags.remove(Flags::BLINK),+ 27 => pen.flags.remove(Flags::REVERSE),+ 28 => pen.flags.remove(Flags::HIDDEN),+ 29 => pen.flags.remove(Flags::STRIKE),+ 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),+ 39 => pen.fg = Color::Default,+ 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),+ 49 => pen.bg = Color::Default,+ 53 => pen.flags.insert(Flags::OVERLINE),+ 55 => pen.flags.remove(Flags::OVERLINE),+ 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),+ 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),+ 38 | 48 | 58 => {+ let (color, consumed) = ext_color(&items, i);+ if let Some(color) = color {+ match code {+ 38 => pen.fg = color,+ 48 => pen.bg = color,+ _ => pen.underline_color = color,+ }+ }+ step = consumed;+ }+ 59 => pen.underline_color = Color::Default,+ _ => {}+ }+ i += step;+ }+ }++ /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic+ /// firmware level; DA3 a (zero) unit ID.+ fn device_attrs(&mut self, level: DaLevel) {+ match level {+ DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),+ DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),+ DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),+ }+ }++ /// XTVERSION (`CSI > q`): report the terminal name and version.+ fn report_version(&mut self) {+ let _ = write!(+ self.response,+ "\x1bP>|beer({})\x1b\\",+ env!("CARGO_PKG_VERSION")+ );+ }++ /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).+ fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {+ let Rgb(r, g, b) = rgb;+ let _ = write!(+ self.response,+ "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",+ u16::from(r) * 0x101,+ u16::from(g) * 0x101,+ u16::from(b) * 0x101,+ );+ self.response+ .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });+ }++ /// OSC 4: set or query palette entries, given as `index;spec` pairs.+ fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {+ let mut rest = params[1..].iter();+ while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {+ let Some(idx) = parse_index(idx_raw) else {+ continue;+ };+ if *spec == b"?" {+ let rgb = self.theme.palette[idx as usize];+ self.reply_color(&format!("4;{idx}"), rgb, bell);+ } else if let Some(rgb) = parse_spec(spec) {+ self.theme.set_palette(idx, rgb);+ }+ }+ }++ /// OSC 10/11/17/19: set or query a dynamic colour.+ fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {+ let Some(spec) = spec else { return };+ if **spec == b"?"[..] {+ let rgb = match kind {+ Dynamic::Fg => self.theme.fg,+ Dynamic::Bg => self.theme.bg,+ Dynamic::SelBg => self.theme.selection_bg,+ Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),+ };+ let code = match kind {+ Dynamic::Fg => "10",+ Dynamic::Bg => "11",+ Dynamic::SelBg => "17",+ Dynamic::SelFg => "19",+ };+ self.reply_color(code, rgb, bell);+ } else if let Some(rgb) = parse_spec(spec) {+ match kind {+ Dynamic::Fg => self.theme.fg = rgb,+ Dynamic::Bg => self.theme.bg = rgb,+ Dynamic::SelBg => self.theme.selection_bg = rgb,+ Dynamic::SelFg => self.theme.selection_fg = Some(rgb),+ }+ }+ }++ fn device_status(&mut self, params: &Params) {+ match params.iter().next().and_then(|p| p.first().copied()) {+ Some(5) => self.response.extend_from_slice(b"\x1b[0n"),+ Some(6) => {+ let (x, y) = self.grid.cursor();+ let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);+ }+ _ => {}+ }+ }++ /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),+ /// or unrecognized (0). Only the modes we actually track are reported.+ fn report_mode(&mut self, params: &Params, private: bool) {+ let code = raw(params, 0);+ let state = match (private, code) {+ (true, 6) => set_reset(self.grid.origin()),+ (true, 7) => set_reset(self.grid.autowrap()),+ (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),+ (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),+ (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),+ (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),+ (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),+ (true, 1004) => set_reset(self.grid.focus_events()),+ (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),+ (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),+ (true, 2004) => set_reset(self.grid.bracketed_paste()),+ (true, 2026) => set_reset(self.grid.sync_active()),+ (false, 4) => set_reset(self.grid.insert()),+ _ => 0,+ };+ let prefix = if private { "?" } else { "" };+ let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");+ }++ /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.+ fn title_stack_op(&mut self, params: &Params) {+ match raw(params, 0) {+ 22 => self.title_stack.push(self.title.clone()),+ 23 => {+ if let Some(title) = self.title_stack.pop() {+ self.title = title;+ }+ }+ _ => {}+ }+ }+}++/// First param value, with 0/absent folded to `default` (xterm convention for+/// cursor movement and counts).+fn n(params: &Params, idx: usize, default: usize) -> usize {+ match params.iter().nth(idx).and_then(|p| p.first().copied()) {+ Some(0) | None => default,+ Some(v) => v as usize,+ }+}++/// Raw first param value (0 is meaningful), defaulting to 0 when absent.+fn raw(params: &Params, idx: usize) -> u16 {+ params+ .iter()+ .nth(idx)+ .and_then(|p| p.first().copied())+ .unwrap_or(0)+}++/// Parse an SGR 38/48 extended colour, returning the colour and how many+/// top-level params it consumed (1 for colon form, more for semicolon form).+fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {+ let head = items[i];+ if head.len() >= 2 {+ return (color_from_subparams(&head[1..]), 1);+ }+ match items.get(i + 1).and_then(|s| s.first().copied()) {+ Some(5) => {+ let idx = items+ .get(i + 2)+ .and_then(|s| s.first().copied())+ .unwrap_or(0);+ (Some(Color::Indexed(idx as u8)), 3)+ }+ Some(2) => {+ let get = |k: usize| {+ items+ .get(i + k)+ .and_then(|s| s.first().copied())+ .unwrap_or(0)+ };+ (+ Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),+ 5,+ )+ }+ _ => (None, 1),+ }+}++fn color_from_subparams(sub: &[u16]) -> Option<Color> {+ match sub.first().copied() {+ Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),+ Some(2) => {+ // Either `2:r:g:b` or `2:colorspace:r:g:b`.+ let rgb = if sub.len() >= 5 {+ &sub[2..5]+ } else {+ &sub[1..]+ };+ match rgb {+ [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),+ _ => None,+ }+ }+ _ => None,+ }+}++fn charset(byte: u8) -> Charset {+ match byte {+ b'0' => Charset::DecSpecial,+ _ => Charset::Ascii,+ }+}++/// Translate a byte under the DEC special graphics set (line drawing).+fn dec_special(c: char) -> char {+ match c {+ '`' => '◆',+ 'a' => '▒',+ 'f' => '°',+ 'g' => '±',+ 'j' => '┘',+ 'k' => '┐',+ 'l' => '┌',+ 'm' => '└',+ 'n' => '┼',+ 'o' => '⎺',+ 'p' => '⎻',+ 'q' => '─',+ 'r' => '⎼',+ 's' => '⎽',+ 't' => '├',+ 'u' => '┤',+ 'v' => '┴',+ 'w' => '┬',+ 'x' => '│',+ 'y' => '≤',+ 'z' => '≥',+ '~' => '·',+ _ => c,+ }+}++/// Look up a terminfo capability beer reports via XTGETTCAP.+fn cap_value(name: &[u8]) -> Option<&'static str> {+ match name {+ b"TN" => Some("beer"),+ b"Co" | b"colors" => Some("256"),+ b"RGB" => Some("8/8/8"),+ _ => None,+ }+}++const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";++/// Standard base64 encode (used for OSC 52 query replies).+pub fn base64_encode(data: &[u8]) -> String {+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for chunk in data.chunks(3) {+ let b = [+ chunk[0],+ *chunk.get(1).unwrap_or(&0),+ *chunk.get(2).unwrap_or(&0),+ ];+ let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);+ out.push(B64[(n >> 18 & 63) as usize] as char);+ out.push(B64[(n >> 12 & 63) as usize] as char);+ out.push(if chunk.len() > 1 {+ B64[(n >> 6 & 63) as usize] as char+ } else {+ '='+ });+ out.push(if chunk.len() > 2 {+ B64[(n & 63) as usize] as char+ } else {+ '='+ });+ }+ out+}++/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad+/// character.+fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {+ let val = |c: u8| -> Option<u32> {+ match c {+ b'A'..=b'Z' => Some(u32::from(c - b'A')),+ b'a'..=b'z' => Some(u32::from(c - b'a') + 26),+ b'0'..=b'9' => Some(u32::from(c - b'0') + 52),+ b'+' => Some(62),+ b'/' => Some(63),+ _ => None,+ }+ };+ let filtered: Vec<u8> = data+ .iter()+ .copied()+ .filter(|&c| c != b'=' && !c.is_ascii_whitespace())+ .collect();+ let mut out = Vec::with_capacity(filtered.len() / 4 * 3);+ for chunk in filtered.chunks(4) {+ if chunk.len() == 1 {+ return None; // a lone sextet cannot form a byte+ }+ let mut n = 0u32;+ for &c in chunk {+ n = (n << 6) | val(c)?;+ }+ n <<= 6 * (4 - chunk.len() as u32);+ out.push((n >> 16) as u8);+ if chunk.len() >= 3 {+ out.push((n >> 8) as u8);+ }+ if chunk.len() >= 4 {+ out.push(n as u8);+ }+ }+ Some(out)+}++/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.+fn osc_text(field: Option<&&[u8]>) -> Option<String> {+ field.map(|b| String::from_utf8_lossy(b).into_owned())+}++/// Map an OSC 133 mark letter to a [`PromptKind`].+fn prompt_kind(b: u8) -> Option<PromptKind> {+ match b {+ b'A' => Some(PromptKind::PromptStart),+ b'B' => Some(PromptKind::CmdStart),+ b'C' => Some(PromptKind::OutputStart),+ b'D' => Some(PromptKind::CmdEnd),+ _ => None,+ }+}++/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding+/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns+/// `None` if it is not a usable absolute path.+fn file_uri_path(uri: &[u8]) -> Option<String> {+ let rest = uri.strip_prefix(b"file://").unwrap_or(uri);+ // Skip the authority (host) up to the first '/', which begins the path.+ let slash = rest.iter().position(|&b| b == b'/')?;+ let path_bytes = percent_decode(&rest[slash..]);+ let path = String::from_utf8(path_bytes).ok()?;+ path.starts_with('/').then_some(path)+}++/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.+fn percent_decode(s: &[u8]) -> Vec<u8> {+ let mut out = Vec::with_capacity(s.len());+ let mut i = 0;+ while i < s.len() {+ if s[i] == b'%' && i + 2 < s.len() {+ let hi = (s[i + 1] as char).to_digit(16);+ let lo = (s[i + 2] as char).to_digit(16);+ if let (Some(hi), Some(lo)) = (hi, lo) {+ out.push((hi * 16 + lo) as u8);+ i += 3;+ continue;+ }+ }+ out.push(s[i]);+ i += 1;+ }+ out+}++/// Decode an even-length lowercase/uppercase hex string into bytes.+fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {+ if s.is_empty() || !s.len().is_multiple_of(2) {+ return None;+ }+ let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);+ s.chunks_exact(2)+ .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))+ .collect()+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::grid::{MouseEncoding, MouseProtocol};++ fn feed(term: &mut Term, bytes: &[u8]) {+ let mut parser = vte::Parser::new();+ parser.advance(term, bytes);+ }++ #[test]+ fn plain_text_lands_in_the_grid() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"hello");+ assert_eq!(t.grid().row_text(0), "hello");+ }++ #[test]+ fn cursor_position_and_erase() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"abcde\x1b[Hxyz");+ assert_eq!(t.grid().row_text(0), "xyzde");+ }++ #[test]+ fn newline_sequence() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"one\r\ntwo");+ assert_eq!(t.grid().row_text(0), "one");+ assert_eq!(t.grid().row_text(1), "two");+ }++ #[test]+ fn device_attributes_levels() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[c");+ assert_eq!(t.take_response(), b"\x1b[?62;22c");+ feed(&mut t, b"\x1b[>c");+ assert_eq!(t.take_response(), b"\x1b[>0;276;0c");+ feed(&mut t, b"\x1b[=c");+ assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");+ }++ #[test]+ fn xtversion_reports_name() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[>q");+ let resp = t.take_response();+ assert!(resp.starts_with(b"\x1bP>|beer("));+ assert!(resp.ends_with(b")\x1b\\"));+ }++ #[test]+ fn decrqm_reports_known_modes() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default+ assert_eq!(t.take_response(), b"\x1b[?7;1$y");+ feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query+ assert_eq!(t.take_response(), b"\x1b[?7;2$y");+ feed(&mut t, b"\x1b[?9999$p"); // unknown mode+ assert_eq!(t.take_response(), b"\x1b[?9999;0$y");+ }++ #[test]+ fn sgr_underline_styles_and_lines() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[4:3;58;5;1;53mX");+ let cell = t.grid().cell(0, 0);+ assert_eq!(cell.underline, Underline::Curly);+ assert_eq!(cell.underline_color, Color::Indexed(1));+ assert!(cell.flags.contains(Flags::OVERLINE));+ // 4:0 turns the underline back off.+ feed(&mut t, b"\x1b[4:0mY");+ assert_eq!(t.grid().cell(1, 0).underline, Underline::None);+ }++ #[test]+ fn decscusr_and_cursor_visibility() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[4 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+ feed(&mut t, b"\x1b[6 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+ feed(&mut t, b"\x1b[0 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Block);++ feed(&mut t, b"\x1b[?25l");+ assert!(!t.grid().cursor_visible());+ feed(&mut t, b"\x1b[?25h");+ assert!(t.grid().cursor_visible());+ }++ #[test]+ fn osc12_sets_and_resets_cursor_color() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]12;#ff0000\x07");+ assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));+ feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");+ assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));+ feed(&mut t, b"\x1b]112\x07");+ assert_eq!(t.grid().cursor_color(), None);+ }++ #[test]+ fn decscusr_and_cursor_color() {+ use crate::grid::CursorShape;+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[5 q"); // blinking bar+ assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+ feed(&mut t, b"\x1b[4 q"); // steady underline+ assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+ feed(&mut t, b"\x1b]12;#ff3030\x07");+ assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));+ feed(&mut t, b"\x1b]112\x07");+ assert_eq!(t.grid().cursor_color(), None);+ feed(&mut t, b"\x1b[?25l"); // hide cursor+ assert!(!t.grid().cursor_visible());+ }++ #[test]+ fn osc_palette_and_dynamic_colors() {+ use crate::theme::Rgb;+ let mut t = Term::new(20, 2);+ // Set palette index 1 and foreground via OSC, then query them back.+ feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");+ assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));+ feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");+ assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));+ feed(&mut t, b"\x1b]11;?\x07");+ let resp = t.take_response();+ assert!(resp.starts_with(b"\x1b]11;rgb:"));+ // Reset returns the palette entry to its default.+ feed(&mut t, b"\x1b]104;1\x1b\\");+ assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));+ }++ #[test]+ fn xtgettcap_known_and_unknown() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"+ assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"+ feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown+ assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");+ }++ #[test]+ fn bracketed_paste_and_sync_modes() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"\x1b[?2004h");+ assert!(t.grid().bracketed_paste());+ feed(&mut t, b"\x1b[?2004$p");+ assert_eq!(t.take_response(), b"\x1b[?2004;1$y");+ feed(&mut t, b"\x1b[?2026h");+ assert!(t.grid().sync_active());+ feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");+ assert!(!t.grid().sync_active());+ assert_eq!(t.take_response(), b"\x1b[?2026;2$y");+ }++ #[test]+ fn base64_round_trips() {+ for s in [+ "",+ "f",+ "fo",+ "foo",+ "foob",+ "fooba",+ "foobar",+ "hi there\n",+ ] {+ let enc = base64_encode(s.as_bytes());+ assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));+ }+ assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");+ assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));+ }++ #[test]+ fn osc52_set_and_query() {+ let mut t = Term::new(20, 2);+ // Set clipboard to "hi" (base64 "aGk=").+ feed(&mut t, b"\x1b]52;c;aGk=\x07");+ let ops = t.take_clipboard_ops();+ match ops.as_slice() {+ [+ ClipboardOp::Set {+ primary: false,+ text,+ },+ ] => assert_eq!(text, "hi"),+ other => panic!("unexpected ops: {other:?}"),+ }+ // Query the primary selection.+ feed(&mut t, b"\x1b]52;p;?\x07");+ let ops = t.take_clipboard_ops();+ assert!(matches!(+ ops.as_slice(),+ [ClipboardOp::Query { primary: true }]+ ));+ }++ #[test]+ fn mouse_modes_track_protocol_and_encoding() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[?1002h\x1b[?1006h");+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);+ assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);+ feed(&mut t, b"\x1b[?1002$p");+ assert_eq!(t.take_response(), b"\x1b[?1002;1$y");+ feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);+ feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);+ feed(&mut t, b"\x1b[?1004h");+ assert!(t.grid().focus_events());+ }++ #[test]+ fn title_stack_push_pop() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b]0;first\x07");+ feed(&mut t, b"\x1b[22t"); // push "first"+ feed(&mut t, b"\x1b]0;second\x07");+ assert_eq!(t.title(), Some("second"));+ feed(&mut t, b"\x1b[23t"); // pop -> "first"+ assert_eq!(t.title(), Some("first"));+ }++ #[test]+ fn sgr_sets_pen_colours() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[31;1mX");+ let cell = t.grid().cell(0, 0);+ assert_eq!(cell.fg, Color::Indexed(1));+ assert!(cell.flags.contains(Flags::BOLD));+ }++ #[test]+ fn truecolor_semicolon_and_colon() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[38;2;10;20;30mA");+ assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));+ feed(&mut t, b"\x1b[38:2:40:50:60mB");+ assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));+ }++ #[test]+ fn device_status_reports_cursor() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[3;5H\x1b[6n");+ assert_eq!(t.take_response(), b"\x1b[3;5R");+ }++ #[test]+ fn line_drawing_charset() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b(0qx\x1b(B");+ assert_eq!(t.grid().row_text(0), "─│");+ }++ #[test]+ fn osc133_marks_capture_last_command_output() {+ let mut t = Term::new(12, 6);+ feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command+ feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output+ feed(&mut t, b"\x1b]133;D\x07"); // command finished+ assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));+ }++ #[test]+ fn osc_notifications_collected() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"\x1b]9;hello\x07");+ feed(&mut t, b"\x1b]777;notify;Title;Body\x07");+ let n = t.take_notifications();+ assert_eq!(+ n,+ vec![+ Notification {+ title: None,+ body: "hello".into()+ },+ Notification {+ title: Some("Title".into()),+ body: "Body".into()+ },+ ]+ );+ assert!(t.take_notifications().is_empty());+ }++ #[test]+ fn osc7_tracks_cwd_and_decodes_percent() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");+ assert_eq!(t.cwd(), Some("/home/user/my dir"));+ // A non-file or relative URI leaves the previous value untouched? It+ // simply does not match a path, so cwd stays None here.+ let mut t2 = Term::new(20, 1);+ feed(&mut t2, b"\x1b]7;file://host\x07");+ assert_eq!(t2.cwd(), None);+ }++ #[test]+ fn title_via_osc() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]0;hello\x07");+ assert_eq!(t.title(), Some("hello"));+ }++ #[test]+ fn alt_screen_preserves_primary() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"main");+ feed(&mut t, b"\x1b[?1049h");+ assert_eq!(t.grid().row_text(0), "");+ feed(&mut t, b"\x1b[?1049l");+ assert_eq!(t.grid().row_text(0), "main");+ }+}@@ -0,0 +1,240 @@+use vte::Perform;++use super::*;++impl Perform for Term {+ fn print(&mut self, c: char) {+ let c = if self.active_charset() == Charset::DecSpecial {+ dec_special(c)+ } else {+ c+ };+ self.grid.print(c);+ }++ fn execute(&mut self, byte: u8) {+ match byte {+ 0x07 => self.bell = true,+ 0x08 => self.grid.backspace(),+ 0x09 => self.grid.tab(),+ 0x0A..=0x0C => self.grid.line_feed(),+ 0x0D => self.grid.carriage_return(),+ 0x0E => self.shift_out = true,+ 0x0F => self.shift_out = false,+ _ => {}+ }+ }++ fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {+ let private = intermediates.first() == Some(&b'?');+ match action {+ 'A' => self.grid.cursor_up(n(params, 0, 1)),+ 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),+ 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),+ 'D' => self.grid.cursor_back(n(params, 0, 1)),+ 'E' => {+ self.grid.cursor_down(n(params, 0, 1));+ self.grid.carriage_return();+ }+ 'F' => {+ self.grid.cursor_up(n(params, 0, 1));+ self.grid.carriage_return();+ }+ 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),+ 'd' => self.grid.move_to_row(n(params, 0, 1) - 1),+ 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),+ 'J' => self.grid.erase_display(raw(params, 0)),+ 'K' => self.grid.erase_line(raw(params, 0)),+ '@' => self.grid.insert_chars(n(params, 0, 1)),+ 'P' => self.grid.delete_chars(n(params, 0, 1)),+ 'L' => self.grid.insert_lines(n(params, 0, 1)),+ 'M' => self.grid.delete_lines(n(params, 0, 1)),+ 'X' => self.grid.erase_chars(n(params, 0, 1)),+ 'S' => self.grid.scroll_up(n(params, 0, 1)),+ 'T' => self.grid.scroll_down(n(params, 0, 1)),+ 'm' => self.sgr(params),+ 'r' => {+ let top = n(params, 0, 1) - 1;+ let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {+ Some(0) | None => self.grid.rows() - 1,+ Some(v) => (v as usize).saturating_sub(1),+ };+ self.grid.set_scroll_region(top, bottom);+ }+ 'h' => self.set_mode(params, private, true),+ 'l' => self.set_mode(params, private, false),+ 'c' => self.device_attrs(match intermediates.first() {+ Some(b'>') => DaLevel::Secondary,+ Some(b'=') => DaLevel::Tertiary,+ _ => DaLevel::Primary,+ }),+ 'q' if intermediates.first() == Some(&b'>') => self.report_version(),+ 'q' if intermediates.first() == Some(&b' ') => {+ let code = raw(params, 0);+ self.grid.set_cursor_shape(match code {+ 3 | 4 => CursorShape::Underline,+ 5 | 6 => CursorShape::Beam,+ _ => CursorShape::Block,+ });+ // Even codes are steady; 0/1 and other odd codes blink.+ self.grid.set_cursor_blink(code == 0 || code % 2 == 1);+ }+ 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),+ 'n' => self.device_status(params),+ 's' => self.grid.save_cursor(),+ 'u' => self.grid.restore_cursor(),+ 't' => self.title_stack_op(params),+ 'g' => match raw(params, 0) {+ 3 => self.grid.clear_all_tabs(),+ _ => self.grid.clear_tab(),+ },+ _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),+ }+ }++ fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {+ match (intermediates.first().copied(), byte) {+ (None, b'D') => self.grid.line_feed(),+ (None, b'M') => self.grid.reverse_index(),+ (None, b'E') => self.grid.next_line(),+ (None, b'7') => self.grid.save_cursor(),+ (None, b'8') => self.grid.restore_cursor(),+ (None, b'H') => self.grid.set_tab(),+ (None, b'c') => {+ self.grid.reset_pen();+ self.grid.set_scroll_region(0, self.grid.rows() - 1);+ self.grid.set_autowrap(true);+ self.grid.set_origin(false);+ self.grid.erase_display(2);+ self.grid.move_to(0, 0);+ self.g0 = Charset::Ascii;+ self.g1 = Charset::Ascii;+ self.shift_out = false;+ }+ (Some(b'('), c) => self.g0 = charset(c),+ (Some(b')'), c) => self.g1 = charset(c),+ _ => {}+ }+ }++ fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {+ match params.first() {+ Some(&n) if n == b"0" || n == b"2" => {+ if let Some(text) = params.get(1) {+ self.title = Some(String::from_utf8_lossy(text).into_owned());+ }+ }+ // OSC 7: the shell reports its cwd as a `file://host/path` URI.+ Some(&n) if n == b"7" => {+ if let Some(uri) = params.get(1) {+ self.cwd = file_uri_path(uri);+ }+ }+ // OSC 133: shell-integration prompt marks (A/B/C/D, with optional+ // `;key=value` attributes we ignore).+ Some(&n) if n == b"133" => {+ if let Some(kind) = params+ .get(1)+ .and_then(|p| p.first())+ .and_then(|&b| prompt_kind(b))+ {+ self.grid.set_prompt_mark(kind);+ }+ }+ // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the+ // link. The URI is everything after the second field, rejoined since+ // a URI may itself contain ';'.+ Some(&n) if n == b"8" => {+ let uri_bytes = params+ .get(2..)+ .map(|parts| parts.join(&b';'))+ .unwrap_or_default();+ let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");+ self.grid.set_link((!uri.is_empty()).then_some(uri));+ }+ // OSC 9: iTerm2-style notification (`OSC 9 ; body`).+ Some(&n) if n == b"9" => {+ if let Some(body) = osc_text(params.get(1)) {+ self.notifications.push(Notification { title: None, body });+ }+ }+ // OSC 777: `OSC 777 ; notify ; title ; body`.+ Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {+ let title = osc_text(params.get(2));+ if let Some(body) = osc_text(params.get(3)) {+ self.notifications.push(Notification { title, body });+ }+ }+ // OSC 99: kitty desktop-notification protocol. We honour the common+ // single-chunk form, taking the payload as the body and ignoring the+ // metadata key=value field.+ Some(&n) if n == b"99" => {+ if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {+ self.notifications.push(Notification { title: None, body });+ }+ }+ // OSC 4: set/query palette entries (pairs of index;spec).+ Some(&n) if n == b"4" => self.osc_palette(params, bell),+ // OSC 104: reset palette (all, or the listed indices).+ Some(&n) if n == b"104" => {+ if params.len() <= 1 {+ self.theme.reset_palette();+ } else {+ for p in ¶ms[1..] {+ if let Some(i) = parse_index(p) {+ self.theme.reset_palette_index(i);+ }+ }+ }+ }+ // OSC 10/11: foreground / background; OSC 110/111 reset them.+ Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),+ Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),+ Some(&n) if n == b"110" => self.theme.reset_fg(),+ Some(&n) if n == b"111" => self.theme.reset_bg(),+ // OSC 17/19: selection (highlight) background / foreground.+ Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),+ Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),+ // OSC 12: set cursor colour; OSC 112: reset to default.+ Some(&n) if n == b"12" => {+ self.grid+ .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));+ }+ Some(&n) if n == b"112" => self.grid.set_cursor_color(None),+ // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or+ // `?` to query. We only touch `c` (clipboard) and `p` (primary).+ Some(&n) if n == b"52" => {+ let target = params.get(1).copied().unwrap_or(b"");+ let data = params.get(2).copied().unwrap_or(b"");+ let primary = target.first() == Some(&b'p');+ if data == b"?" {+ self.clipboard_ops.push(ClipboardOp::Query { primary });+ } else if let Some(text) =+ base64_decode(data).and_then(|b| String::from_utf8(b).ok())+ {+ self.clipboard_ops.push(ClipboardOp::Set { primary, text });+ }+ }+ _ => {}+ }+ }++ fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {+ // XTGETTCAP arrives as `DCS + q <names> ST`.+ if action == 'q' && intermediates == [b'+'] {+ self.xtgettcap = Some(Vec::new());+ }+ }++ fn put(&mut self, byte: u8) {+ if let Some(buf) = self.xtgettcap.as_mut() {+ buf.push(byte);+ }+ }++ fn unhook(&mut self) {+ if let Some(payload) = self.xtgettcap.take() {+ self.answer_xtgettcap(&payload);+ }+ }+}@@ -1,2625 +0,0 @@-//! Wayland front-end: connection, surface, and software-drawn window.-//!-//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the-//! event loop, so the PTY master fd and timers share one loop.--use std::fs::File;-use std::io::{Read as _, Write as _};-use std::num::NonZeroU16;-use std::os::fd::OwnedFd;-use std::os::unix::process::ExitStatusExt;-use std::process::ExitCode;--use std::time::Duration;--use anyhow::Context;-use calloop::generic::Generic;-use calloop::timer::{TimeoutAction, Timer};-use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken};-use calloop_wayland_source::WaylandSource;--use crate::config::Config;-use crate::font::Fonts;-use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit};-use crate::pty::Pty;-use crate::render::Renderer;-use crate::vt::Term;-use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{- Shape, WpCursorShapeDeviceV1,-};-use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{- zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,- zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,-};-use smithay_client_toolkit::{- activation::{ActivationHandler, ActivationState, RequestData},- compositor::{CompositorHandler, CompositorState},- data_device_manager::{- DataDeviceManagerState, WritePipe,- data_device::{DataDevice, DataDeviceHandler},- data_offer::{DataOfferHandler, DragOffer},- data_source::{CopyPasteSource, DataSourceHandler},- },- delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard,- delegate_output,- delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,- delegate_xdg_shell, delegate_xdg_window,- output::{OutputHandler, OutputState},- primary_selection::{- PrimarySelectionManagerState,- device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},- selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},- },- registry::{ProvidesRegistryState, RegistryState},- registry_handlers,- seat::{- Capability, SeatHandler, SeatState,- keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},- pointer::{- BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,- cursor_shape::CursorShapeManager,- },- },- shell::{- WaylandSurface,- xdg::{- XdgShell,- window::{Window, WindowConfigure, WindowDecorations, WindowHandler},- },- },- shm::{- Shm, ShmHandler,- slot::{Buffer, SlotPool},- },-};-use wayland_client::{- Connection, Dispatch, Proxy, QueueHandle,- globals::{GlobalList, registry_queue_init},- protocol::{- wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,- wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,- wl_surface,- },-};-use wayland_protocols::wp::fractional_scale::v1::client::{- wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,- wp_fractional_scale_v1::{self, WpFractionalScaleV1},-};-use wayland_protocols::wp::text_input::zv3::client::{- zwp_text_input_manager_v3::ZwpTextInputManagerV3,- zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3},-};-use wayland_protocols::wp::viewporter::client::{- wp_viewport::WpViewport, wp_viewporter::WpViewporter,-};--/// MIME types beer offers and accepts for clipboard text.-const TEXT_MIMES: &[&str] = &[- "text/plain;charset=utf-8",- "text/plain;charset=UTF-8",- "UTF8_STRING",- "STRING",- "text/plain",- "TEXT",-];--/// Pick the first MIME type we understand from an offer's advertised set.-fn pick_mime(mimes: &[String]) -> Option<String> {- mimes- .iter()- .find(|m| TEXT_MIMES.contains(&m.as_str()))- .cloned()-}--/// Max gap between clicks counted as a multi-click (ms).-const MULTI_CLICK_MS: u32 = 400;--/// Blink half-period: cells/cursor toggle visibility this often.-const BLINK_MS: u64 = 500;--/// Buffers kept for double/triple buffering before we wait for a release.-const MAX_BUFFERS: usize = 3;--/// How long synchronized output (DECSET 2026) may hold the screen before we-/// present anyway, so a misbehaving app cannot freeze the window.-const SYNC_TIMEOUT_MS: u64 = 150;--/// Interval between autoscroll steps while a drag selection runs off an edge.-const AUTOSCROLL_MS: u64 = 40;--/// How long the visual bell inverts the screen.-const FLASH_MS: u64 = 80;--/// What determines one rendered row's pixels: its cells, the cursor on it, the-/// selection span over it, and the blink phase. Two equal `RowSnap`s render-/// identically, so a buffer holding an equal snapshot needs no repaint.-#[derive(Clone, PartialEq, Debug)]-struct RowSnap {- cells: Vec<Cell>,- /// `(col, shape, focused)` when the cursor is drawn on this row.- cursor: Option<(usize, CursorShape, bool)>,- /// Inclusive selected column span on this row.- sel: Option<(usize, usize)>,- /// Search-match spans `(lo, hi, is_current)` highlighted on this row.- search: Vec<(usize, usize, bool)>,- /// Search-prompt text drawn over this row (only the bottom row, when active).- overlay: Option<String>,- /// IME preedit `(start_col, text)` drawn inline over this row (cursor row).- preedit: Option<(usize, String)>,- /// Blink phase, but only varied when the row actually has blinking ink, so- /// non-blinking rows stay equal across phase toggles.- blink: bool,-}--/// One shm buffer plus the per-row snapshot of what it currently displays.-#[derive(Debug)]-struct FrameBuf {- buffer: Buffer,- rows: Vec<RowSnap>,-}--/// Snapshot the determinants of viewport row `y`'s pixels.-fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {- let abs = grid.view_to_abs(y);- let cells = grid.view_row(y).to_vec();- let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {- let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);- visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))- } else {- None- };- let has_blink = cells- .iter()- .any(|c| c.flags.contains(crate::grid::Flags::BLINK));- RowSnap {- cells,- cursor,- sel: grid.selection_span_on(abs),- search: grid.search_spans_on(abs),- overlay: None,- preedit: None,- blink: if has_blink { blink_on } else { true },- }-}--/// Fallback window size in pixels if the configured geometry yields nothing.-const DEFAULT_W: u32 = 800;-const DEFAULT_H: u32 = 600;--/// Run a single window until it is closed, returning the shell's exit code.-pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {- let conn = Connection::connect_to_env().context("connect to Wayland compositor")?;- let (globals, event_queue) =- registry_queue_init(&conn).context("initialize Wayland registry")?;- let qh = event_queue.handle();-- let mut event_loop: EventLoop<App> =- EventLoop::try_new().context("create calloop event loop")?;- WaylandSource::new(conn, event_queue)- .insert(event_loop.handle())- .map_err(|e| anyhow::anyhow!("insert Wayland source into event loop: {e}"))?;-- let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;- let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;- let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;- let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)- .context("wl_data_device_manager not available")?;- let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();- let cursor_shape_manager = CursorShapeManager::bind(&globals, &qh).ok();-- let surface = compositor.create_surface(&qh);- let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);- window.set_title("beer");- window.set_app_id("dev.notashelf.beer");- window.set_min_size(Some((1, 1)));-- // Decorrelate buffer pixels from surface size so we can render at the- // compositor's preferred fractional scale (crisp glyphs on a 150% output)- // and present at the logical size. Both are optional; without them the- // window falls back to integer buffer scaling via `scale_factor_changed`.- let viewport = bind_global::<WpViewporter>(&globals, &qh)- .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ()));- // Fractional scaling needs a viewport to present the scaled buffer back at- // the logical size; without one we can only do integer buffer scaling.- let fractional_scale = viewport.as_ref().and_then(|_| {- bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)- .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))- });- let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &qh);- let activation = ActivationState::bind(&globals, &qh).ok();-- // First commit with no buffer kicks off the initial configure.- window.commit();-- let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?;- let mut renderer = Renderer::new(fonts);- renderer.set_padding(config.main.pad_x, config.main.pad_y);-- // Start at the configured cell geometry plus padding; the compositor may- // override it on the first configure.- let m = renderer.metrics();- let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1);- let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);- let pool = SlotPool::new(- (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,- &shm,- )- .context("create shm slot pool")?;-- let bindings =- crate::bindings::Bindings::from_config(&config.key_bindings, &config.text_bindings);- let font_size = config.main.font_size;-- let mut app = App {- registry_state: RegistryState::new(&globals),- output_state: OutputState::new(&globals, &qh),- seat_state: SeatState::new(&globals, &qh),- shm,- pool,- window,- renderer,- loop_handle: event_loop.handle(),- qh: qh.clone(),- data_device_manager,- primary_manager,- cursor_shape_manager,- text_input_manager,- activation,- preedit: String::new(),- ime_preedit_pending: String::new(),- ime_commit_pending: String::new(),- viewport,- fractional_scale,- scale120: 120,- seats: Vec::new(),- active_seat: 0,- copy_source: None,- primary_source: None,- clipboard: String::new(),- primary_clip: String::new(),- selecting: false,- hovered_link: None,- pointer_enter_serial: 0,- press_cell: None,- pressed_button: None,- last_report_cell: None,- autoscroll: 0,- autoscroll_timer: None,- pointer_pos: (0.0, 0.0),- last_click: None,- serial: 0,- modifiers: Modifiers::default(),- // The PTY is spawned on the first configure, once the real window size- // is known, so the shell starts at the final size and is not hit by a- // startup SIGWINCH storm that makes it reprint its prompt.- session: None,- title: None,- config,- config_path,- bindings,- font_size,- fullscreen: false,- width,- height,- needs_draw: false,- frame_pending: false,- frames: Vec::new(),- buf_dims: (0, 0),- blink_on: true,- sync_timeout: None,- flashing: false,- flash_timer: None,- searching: false,- url_mode: false,- url_hits: Vec::new(),- url_labels: Vec::new(),- url_input: String::new(),- focused: true,- exit: false,- exit_code: ExitCode::SUCCESS,- };-- // Toggle the blink phase on a timer so blinking text and cursors animate.- let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));- let blink_registered = event_loop- .handle()- .insert_source(blink, |_, _, app: &mut App| {- app.blink_on = !app.blink_on;- app.needs_draw = true;- TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))- });- if let Err(err) = blink_registered {- tracing::warn!("register blink timer: {err}");- }-- // SIGUSR1 reloads the config in place.- match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {- Ok(signals) => {- let registered = event_loop- .handle()- .insert_source(signals, |_, _, app: &mut App| app.reload_config());- if let Err(err) = registered {- tracing::warn!("register signal source: {err}");- }- }- Err(err) => tracing::warn!("install SIGUSR1 handler: {err}"),- }-- // Each iteration blocks until an event (PTY output, input, configure, frame- // callback, blink) arrives, then presents at most one frame; bursts of PTY- // output between frame callbacks coalesce into a single repaint.- while !app.exit {- event_loop- .dispatch(None, &mut app)- .context("dispatch event loop")?;- app.flush();- }- Ok(app.exit_code)-}--/// Bind a singleton global at version 1 with `()` user-data, or `None` if the-/// compositor does not advertise it.-fn bind_global<I>(globals: &GlobalList, qh: &QueueHandle<App>) -> Option<I>-where- I: Proxy + 'static,- App: Dispatch<I, ()>,-{- match globals.bind(qh, 1..=1, ()) {- Ok(global) => Some(global),- Err(err) => {- tracing::debug!("bind {}: {err}", I::interface().name);- None- }- }-}--/// Columns and rows that fit a `width`×`height` px window at `metrics`, after-/// reserving `2 * pad` pixels of inner padding on each axis.-fn grid_size(- metrics: crate::font::CellMetrics,- width: u32,- height: u32,- pad: (u32, u32),-) -> (u16, u16) {- let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1);- let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1);- (cols as u16, rows as u16)-}--/// Write every byte to `fd`, retrying short writes and interrupts.-fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {- while !buf.is_empty() {- match rustix::io::write(fd, buf) {- Ok(0) => return Err(rustix::io::Errno::IO),- Ok(n) => buf = &buf[n..],- Err(rustix::io::Errno::INTR) => {}- Err(e) => return Err(e),- }- }- Ok(())-}--/// The per-window terminal: the PTY and the parsed screen behind it. Created on-/// the first configure, once the real size is known.-#[derive(Debug)]-struct Session {- pty: Pty,- term: Term,-}--/// Input devices for one seat. Several seats can drive the single window; the-/// most recently used one owns clipboard/primary claims.-#[derive(Debug)]-struct SeatData {- seat: wl_seat::WlSeat,- keyboard: Option<wl_keyboard::WlKeyboard>,- pointer: Option<wl_pointer::WlPointer>,- cursor_shape_device: Option<WpCursorShapeDeviceV1>,- data_device: Option<DataDevice>,- primary_device: Option<PrimarySelectionDevice>,- /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.- text_input: Option<ZwpTextInputV3>,-}--/// Window + Wayland client state shared across all protocol handlers.-#[derive(Debug)]-struct App {- registry_state: RegistryState,- output_state: OutputState,- seat_state: SeatState,- shm: Shm,- pool: SlotPool,- window: Window,- renderer: Renderer,- loop_handle: LoopHandle<'static, App>,- qh: QueueHandle<App>,- data_device_manager: DataDeviceManagerState,- primary_manager: Option<PrimarySelectionManagerState>,- /// Sets the pointer to an I-beam over the window (cursor-shape-v1).- cursor_shape_manager: Option<CursorShapeManager>,- /// IME manager (text-input-v3); per-seat handles live in `seats`.- text_input_manager: Option<ZwpTextInputManagerV3>,- /// xdg-activation, used to request attention on an urgent bell.- activation: Option<ActivationState>,- /// Committed IME preedit string shown inline at the cursor while composing.- preedit: String,- /// Preedit/commit accumulated since the last text-input `done`.- ime_preedit_pending: String,- ime_commit_pending: String,- /// Presents a scaled buffer at the logical surface size (viewporter).- viewport: Option<WpViewport>,- /// Per-surface fractional-scale object; kept alive to receive scale events.- fractional_scale: Option<WpFractionalScaleV1>,- /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5).- scale120: u32,- /// One entry per seat; `active_seat` indexes the most recently used.- seats: Vec<SeatData>,- active_seat: usize,- /// Held while we own the clipboard / primary selection, serving paste reads.- copy_source: Option<CopyPasteSource>,- primary_source: Option<PrimarySelectionSource>,- clipboard: String,- primary_clip: String,- /// A left-button drag is in progress.- selecting: bool,- /// OSC 8 hyperlink under the pointer, underlined and opened on click.- hovered_link: Option<NonZeroU16>,- /// Serial of the last pointer enter, reused to update the cursor shape.- pointer_enter_serial: u32,- /// Cell `(abs_row, col)` of the last left-press, for click-to-open links.- press_cell: Option<(usize, usize)>,- /// Button base code held down while mouse reporting, for drag reports.- pressed_button: Option<u8>,- /// Last cell a motion report was emitted for, to suppress duplicates.- last_report_cell: Option<(usize, usize)>,- /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live.- autoscroll: isize,- /// Calloop token for the repeating autoscroll timer, when armed.- autoscroll_timer: Option<RegistrationToken>,- pointer_pos: (f64, f64),- /// Last click (time ms, abs row, col, count) for double/triple detection.- last_click: Option<(u32, usize, usize, u32)>,- /// Most recent input serial, used to claim selections.- serial: u32,- modifiers: Modifiers,- /// `None` until the first configure spawns the shell.- session: Option<Session>,- /// Last title applied to the toplevel, to avoid redundant requests.- title: Option<String>,- /// The active user configuration.- config: Config,- /// Path the config was loaded from, for SIGUSR1 live reload.- config_path: Option<std::path::PathBuf>,- /// Resolved key/text bindings.- bindings: crate::bindings::Bindings,- /// Current font size in pixels (changed by font-resize bindings).- font_size: u32,- /// Whether the toplevel is fullscreen.- fullscreen: bool,- width: u32,- height: u32,- /// The grid changed and the window wants repainting on the next frame.- needs_draw: bool,- /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.- frame_pending: bool,- /// Double/triple-buffer ring, each tagged with the rows it currently shows.- frames: Vec<FrameBuf>,- /// Pixel size the `frames` buffers were allocated for.- buf_dims: (u32, u32),- /// Current blink phase, toggled by a timer; off hides blinking ink.- blink_on: bool,- /// Armed while synchronized output holds the screen, to force it open.- sync_timeout: Option<RegistrationToken>,- /// The visual bell is inverting the screen.- flashing: bool,- /// Timer that ends the visual-bell flash.- flash_timer: Option<RegistrationToken>,- /// Whether incremental search mode is active (the query lives in the grid).- searching: bool,- /// URL hint mode: detected URLs get keyboard labels to open them.- url_mode: bool,- /// Detected URLs and their hint labels (parallel), while `url_mode` is on.- url_hits: Vec<UrlHit>,- url_labels: Vec<String>,- /// Label characters typed so far in URL mode.- url_input: String,- /// Whether the toplevel currently has keyboard focus (drives the cursor).- focused: bool,- exit: bool,- /// Exit code to return, taken from the shell when it exits.- exit_code: ExitCode,-}--impl App {- /// Spawn the shell at the current window size and start reading its output.- fn spawn_session(&mut self) {- let (cols, rows) = self.grid_dims();- let pty = match Pty::spawn(cols, rows, &self.config.main.term) {- Ok(pty) => pty,- Err(err) => {- tracing::error!("spawn shell: {err:#}");- self.exit = true;- return;- }- };- let read_fd = match pty.master().try_clone() {- Ok(fd) => fd,- Err(err) => {- tracing::error!("clone pty master: {err}");- self.exit = true;- return;- }- };-- let mut parser = vte::Parser::new();- let source = Generic::new(read_fd, Interest::READ, Mode::Level);- let registered = self- .loop_handle- .insert_source(source, move |_, fd, app: &mut App| {- let mut buf = [0u8; 4096];- let n = match rustix::io::read(&*fd, &mut buf) {- Ok(0) => {- app.child_exited();- return Ok(PostAction::Remove);- }- Ok(n) => n,- Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => {- return Ok(PostAction::Continue);- }- Err(_) => {- app.child_exited();- return Ok(PostAction::Remove);- }- };- if let Some(session) = app.session.as_mut() {- parser.advance(&mut session.term, &buf[..n]);- }- app.after_feed();- Ok(PostAction::Continue)- });- if let Err(err) = registered {- tracing::error!("register pty in event loop: {err}");- self.exit = true;- return;- }-- let mut term = Term::new(cols as usize, rows as usize);- term.set_theme(crate::theme::Theme::from_config(&self.config.colors));- let grid = term.grid_mut();- grid.set_word_delimiters(self.config.main.word_delimiters.clone());- grid.set_scrollback_cap(self.config.scrollback.lines);- if let Some(shape) = cursor_shape_from(self.config.cursor.style.as_deref()) {- grid.set_cursor_shape(shape);- }- grid.set_cursor_blink(self.config.cursor.blink);- self.session = Some(Session { pty, term });- }-- /// Handle a key (initial press or repeat): configured bindings first, then- /// text bindings, else the byte encoding sent to the shell (which snaps the- /// viewport back to the live screen).- fn handle_key(&mut self, event: &KeyEvent) {- // URL hint mode and search both capture the keyboard while active.- if self.url_mode {- self.url_key(event);- return;- }- if self.searching {- self.search_key(event);- return;- }- if let Some(action) = self.bindings.action(event, self.modifiers) {- self.dispatch_action(action);- return;- }- if let Some(text) = self.bindings.text(event, self.modifiers) {- let bytes = text.to_vec();- self.send_to_shell(&bytes);- return;- }-- let app_cursor = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().app_cursor());- if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) {- self.send_to_shell(&bytes);- }- }-- /// Write key/text bytes to the shell, snapping the viewport to the live- /// screen and clearing any selection first.- fn send_to_shell(&mut self, bytes: &[u8]) {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- session.term.grid_mut().clear_selection();- self.needs_draw = true;- if let Err(err) = write_all(session.pty.master(), bytes) {- tracing::warn!("write key to pty: {err}");- }- }- }-- /// Run a bound editor action.- fn dispatch_action(&mut self, action: crate::bindings::Action) {- use crate::bindings::Action;- match action {- Action::Copy => {- let qh = self.qh.clone();- self.set_clipboard(&qh);- }- Action::Paste => self.paste_clipboard(),- Action::PastePrimary => self.paste_primary(),- Action::ScrollPageUp => self.scroll_page(true),- Action::ScrollPageDown => self.scroll_page(false),- Action::ScrollTop => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(isize::MAX);- self.needs_draw = true;- }- }- Action::ScrollBottom => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- self.needs_draw = true;- }- }- Action::SearchStart => self.toggle_search(),- Action::FontIncrease => self.change_font_size(self.font_size + 1),- Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),- Action::FontReset => self.change_font_size(self.config.main.font_size),- Action::Fullscreen => self.toggle_fullscreen(),- Action::NewWindow => self.spawn_new_window(),- Action::JumpPromptUp => self.jump_prompt(true),- Action::JumpPromptDown => self.jump_prompt(false),- Action::PipeCommandOutput => self.pipe_command_output(),- Action::UrlMode => self.enter_url_mode(),- }- }-- /// Enter URL hint mode: detect the visible URLs and label them. No-op (with- /// a brief log) when there are none.- fn enter_url_mode(&mut self) {- let Some(session) = self.session.as_ref() else {- return;- };- let hits = session.term.grid().visible_urls();- if hits.is_empty() {- return;- }- self.url_labels = hint_labels(hits.len());- self.url_hits = hits;- self.url_input = String::new();- self.url_mode = true;- self.needs_draw = true;- }-- /// Leave URL hint mode, discarding any partial label input.- fn exit_url_mode(&mut self) {- self.url_mode = false;- self.url_hits.clear();- self.url_labels.clear();- self.url_input.clear();- // Drop the labelled buffers so the next present repaints without labels.- self.frames.clear();- self.needs_draw = true;- }-- /// Handle a key while URL hint mode is active: build up a label, open the- /// matching URL, or cancel.- fn url_key(&mut self, event: &KeyEvent) {- match event.keysym {- Keysym::Escape => self.exit_url_mode(),- Keysym::BackSpace => {- self.url_input.pop();- self.needs_draw = true;- }- _ => {- let Some(text) = event.utf8.as_ref() else {- return;- };- for c in text.chars().filter(|c| c.is_ascii_alphabetic()) {- self.url_input.push(c.to_ascii_lowercase());- }- // Exact match opens; if no label even has this prefix, cancel.- if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) {- let url = self.url_hits[i].url.clone();- self.exit_url_mode();- self.open_url(&url);- } else if !self- .url_labels- .iter()- .any(|l| l.starts_with(&self.url_input))- {- self.exit_url_mode();- } else {- self.needs_draw = true;- }- }- }- }-- /// Scroll the viewport to the previous/next shell prompt (OSC 133).- fn jump_prompt(&mut self, up: bool) {- if let Some(session) = self.session.as_mut() {- session.term.grid_mut().jump_prompt(up);- self.needs_draw = true;- }- }-- /// Feed the last command's output (between OSC 133 C and D) to the configured- /// command on stdin.- fn pipe_command_output(&mut self) {- let argv = &self.config.shell_integration.pipe_command;- let Some((program, args)) = argv.split_first() else {- tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured");- return;- };- let Some(text) = self- .session- .as_ref()- .and_then(|s| s.term.grid().last_command_output())- else {- return;- };- let mut cmd = std::process::Command::new(program);- cmd.args(args)- .stdin(std::process::Stdio::piped())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {- cmd.current_dir(cwd);- }-
show full diff (262143 bytes truncated)