brewery
notashelf /
5cba919c783e11bc77983157fe8b9003fd6d6b67

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 5cba919c783e

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified16 files changed+6031-5855
treewide: split terminal core modules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9cace0b7c6995c0fca21ff2cf465ae1f6a6a6964
diff --git a/README.md b/README.mdindex 334c400..d364679 100644--- a/README.md+++ b/README.md@@ -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.diff --git a/doc/beer.toml.5.scd b/doc/beer.toml.5.scdindex 7888765..5ee8ea4 100644--- a/doc/beer.toml.5.scd+++ b/doc/beer.toml.5.scd@@ -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]diff --git a/nix/package.nix b/nix/package.nixindex fe609f6..fe56ad0 100644--- a/nix/package.nix+++ b/nix/package.nix@@ -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;+    };+  })diff --git a/src/grid.rs b/src/grid.rsdeleted file mode 100644index 66124aa..0000000--- a/src/grid.rs+++ /dev/null@@ -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), "");-    }-}diff --git a/src/grid/links.rs b/src/grid/links.rsnew file mode 100644index 0000000..9215316--- /dev/null+++ b/src/grid/links.rs@@ -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()+    }+}diff --git a/src/grid/mod.rs b/src/grid/mod.rsnew file mode 100644index 0000000..08defc2--- /dev/null+++ b/src/grid/mod.rs@@ -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), "");+    }+}diff --git a/src/grid/search.rs b/src/grid/search.rsnew file mode 100644index 0000000..804d594--- /dev/null+++ b/src/grid/search.rs@@ -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));+        }+    }+}diff --git a/src/grid/selection.rs b/src/grid/selection.rsnew file mode 100644index 0000000..74d1fe3--- /dev/null+++ b/src/grid/selection.rs@@ -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+    }+}diff --git a/src/main.rs b/src/main.rsindex 742b5ca..c31d897 100644--- a/src/main.rs+++ b/src/main.rs@@ -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)]diff --git a/src/vt.rs b/src/vt.rsdeleted file mode 100644index dbb16c1..0000000--- a/src/vt.rs+++ /dev/null@@ -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 &params[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");-    }-}diff --git a/src/vt/mod.rs b/src/vt/mod.rsnew file mode 100644index 0000000..e4d4f24--- /dev/null+++ b/src/vt/mod.rs@@ -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");+    }+}diff --git a/src/vt/perform.rs b/src/vt/perform.rsnew file mode 100644index 0000000..b9d57b8--- /dev/null+++ b/src/vt/perform.rs@@ -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 &params[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);+        }+    }+}diff --git a/src/wayland.rs b/src/wayland.rsdeleted file mode 100644index 1b1a5c4..0000000--- a/src/wayland.rs+++ /dev/null@@ -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)