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);-        }-        match cmd.spawn() {-            Ok(mut child) => {-                if let Some(mut stdin) = child.stdin.take() {-                    let _ = stdin.write_all(text.as_bytes());-                }-            }-            Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),-        }-    }--    /// Launch another beer process in the shell's reported working directory-    /// (OSC 7), inheriting the same config. The child is fully detached.-    fn spawn_new_window(&mut self) {-        let exe = match std::env::current_exe() {-            Ok(exe) => exe,-            Err(err) => {-                tracing::warn!("locate beer executable: {err}");-                return;-            }-        };-        let mut cmd = std::process::Command::new(exe);-        if let Some(path) = self.config_path.as_ref() {-            cmd.arg("--config").arg(path);-        }-        if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {-            cmd.current_dir(cwd);-        }-        cmd.stdin(std::process::Stdio::null())-            .stdout(std::process::Stdio::null())-            .stderr(std::process::Stdio::null());-        if let Err(err) = cmd.spawn() {-            tracing::warn!("spawn new window: {err}");-        }-    }--    /// Send `count` cursor-up/down keys to the shell for alternate-scroll,-    /// honouring the application cursor-key mode (DECCKM).-    fn alternate_scroll(&mut self, up: bool, count: isize) {-        let app_cursor = self-            .session-            .as_ref()-            .is_some_and(|s| s.term.grid().app_cursor());-        let seq: &[u8] = match (up, app_cursor) {-            (true, false) => b"\x1b[A",-            (true, true) => b"\x1bOA",-            (false, false) => b"\x1b[B",-            (false, true) => b"\x1bOB",-        };-        for _ in 0..count {-            self.write_to_pty(seq);-        }-    }--    /// Scroll the viewport one page back (`up`) or toward the live screen.-    fn scroll_page(&mut self, up: bool) {-        if let Some(session) = self.session.as_mut() {-            let page = session.term.page() as isize;-            session.term.scroll_view(if up { page } else { -page });-            self.needs_draw = true;-        }-    }--    /// Toggle the toplevel between fullscreen and windowed.-    fn toggle_fullscreen(&mut self) {-        self.fullscreen = !self.fullscreen;-        if self.fullscreen {-            self.window.set_fullscreen(None);-        } else {-            self.window.unset_fullscreen();-        }-    }--    /// Re-read the config file and apply it in place (SIGUSR1).-    fn reload_config(&mut self) {-        let new = Config::load(self.config_path.as_deref());-        self.bindings =-            crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings);-        let font_changed = new.main.font != self.config.main.font-            || new.main.font_size != self.config.main.font_size;-        if font_changed {-            self.font_size = new.main.font_size;-        }-        self.config.main.font = new.main.font.clone();-        self.config.main.font_size = new.main.font_size;-        self.config.main.pad_x = new.main.pad_x;-        self.config.main.pad_y = new.main.pad_y;-        // Re-rasterize at the active scale and update padding in one place.-        self.rescale_render();-        if let Some(session) = self.session.as_mut() {-            session-                .term-                .set_theme(crate::theme::Theme::from_config(&new.colors));-            let grid = session.term.grid_mut();-            grid.set_word_delimiters(new.main.word_delimiters.clone());-            grid.set_scrollback_cap(new.scrollback.lines);-            if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {-                grid.set_cursor_shape(shape);-            }-            grid.set_cursor_blink(new.cursor.blink);-        }-        self.config = new;-        self.frames.clear();-        self.resize_grid();-        self.needs_draw = true;-        tracing::info!("config reloaded");-    }--    /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.-    fn change_font_size(&mut self, new_size: u32) {-        let new_size = new_size.clamp(6, 200);-        if new_size == self.font_size {-            return;-        }-        self.font_size = new_size;-        self.rescale_render();-        self.frames.clear();-        self.resize_grid();-        self.needs_draw = true;-    }--    /// Enter or leave incremental search mode.-    fn toggle_search(&mut self) {-        self.searching = !self.searching;-        if let Some(session) = self.session.as_mut() {-            if self.searching {-                session.term.grid_mut().set_search("");-            } else {-                session.term.grid_mut().clear_search();-            }-        }-        self.needs_draw = true;-    }--    /// Handle a key while search mode is active: edit the query incrementally,-    /// step between matches, or exit.-    fn search_key(&mut self, event: &KeyEvent) {-        let Some(session) = self.session.as_mut() else {-            return;-        };-        let grid = session.term.grid_mut();-        match event.keysym {-            Keysym::Escape => {-                grid.clear_search();-                self.searching = false;-            }-            Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {-                grid.search_step(false);-            }-            Keysym::Down | Keysym::Page_Down => grid.search_step(true),-            Keysym::BackSpace => {-                let mut query = grid.search_query().unwrap_or("").to_string();-                query.pop();-                grid.set_search(&query);-            }-            _ => {-                // Append typed text, ignoring control characters.-                if let Some(text) = event.utf8.as_ref() {-                    let printable: String = text.chars().filter(|c| !c.is_control()).collect();-                    if !printable.is_empty() {-                        let mut query = grid.search_query().unwrap_or("").to_string();-                        query.push_str(&printable);-                        grid.set_search(&query);-                    }-                }-            }-        }-        self.needs_draw = true;-    }--    /// The OSC 8 hyperlink id under the pointer, if any.-    fn link_under_pointer(&self) -> Option<NonZeroU16> {-        let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;-        self.session.as_ref()?.term.grid().link_at(row, col)-    }--    /// Recompute the hyperlink under the pointer; when it changes, repaint to-    /// move the hover underline and update the pointer to a hand over a link.-    fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {-        let link = self.link_under_pointer();-        if link == self.hovered_link {-            return;-        }-        self.hovered_link = link;-        // The hover underline lives in every buffer's snapshot; drop the ring so-        // the affected rows repaint with (or without) it.-        self.frames.clear();-        self.needs_draw = true;-        let shape = if link.is_some() {-            Shape::Pointer-        } else {-            Shape::Text-        };-        if let Some(device) = self-            .seats-            .iter()-            .find(|s| s.pointer.as_ref() == Some(pointer))-            .and_then(|s| s.cursor_shape_device.as_ref())-        {-            device.set_shape(self.pointer_enter_serial, shape);-        }-    }--    /// If the left button was pressed and released on the same hyperlinked cell-    /// (a click, not a drag), open the link.-    fn maybe_open_clicked_link(&mut self) {-        let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);-        let Some((row, col)) = release else { return };-        if self.press_cell != Some((row, col)) {-            return;-        }-        let uri = self-            .session-            .as_ref()-            .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))-            .and_then(|(s, id)| s.term.grid().link_uri(id))-            .map(str::to_owned);-        if let Some(uri) = uri {-            self.open_url(&uri);-        }-    }--    /// Launch the configured opener (default `xdg-open`) on a URL.-    fn open_url(&self, url: &str) {-        let Some((program, args)) = self.config.url.launch.split_first() else {-            tracing::warn!("open url: no [url] launch command configured");-            return;-        };-        let mut cmd = std::process::Command::new(program);-        cmd.args(args)-            .arg(url)-            .stdin(std::process::Stdio::null())-            .stdout(std::process::Stdio::null())-            .stderr(std::process::Stdio::null());-        if let Err(err) = cmd.spawn() {-            tracing::warn!("open url {url:?}: {err}");-        }-    }--    /// Scale a logical pixel length to physical (buffer) pixels at the current-    /// fractional scale, rounding to nearest.-    fn to_phys(&self, v: u32) -> u32 {-        ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32-    }--    /// Physical (buffer) pixel size at the current scale.-    fn phys_dims(&self) -> (u32, u32) {-        (-            self.to_phys(self.width).max(1),-            self.to_phys(self.height).max(1),-        )-    }--    /// Columns and rows for the current physical size, metrics, and padding.-    /// Scale-invariant: every term scales together so the cell count is stable.-    fn grid_dims(&self) -> (u16, u16) {-        let (pw, ph) = self.phys_dims();-        grid_size(-            self.renderer.metrics(),-            pw,-            ph,-            (-                self.to_phys(self.config.main.pad_x),-                self.to_phys(self.config.main.pad_y),-            ),-        )-    }--    /// Re-rasterize the font and padding at the current scale × the logical-    /// font size, so glyphs are crisp at fractional scales.-    fn rescale_render(&mut self) {-        self.renderer.set_padding(-            self.to_phys(self.config.main.pad_x),-            self.to_phys(self.config.main.pad_y),-        );-        let px = self.to_phys(self.font_size).max(1);-        if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {-            tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);-        }-    }--    /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid-    /// geometry, and update the viewport so the logical size stays put.-    fn set_scale(&mut self, scale120: u32) {-        let scale120 = scale120.max(1);-        if scale120 == self.scale120 {-            return;-        }-        self.scale120 = scale120;-        self.rescale_render();-        self.frames.clear();-        self.buf_dims = (0, 0);-        if let Some(vp) = &self.viewport {-            vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);-        }-        self.resize_grid();-        self.needs_draw = true;-    }--    /// Inner padding `(x, y)` in physical pixels (pointer coords are converted-    /// to physical before use).-    fn padding(&self) -> (f64, f64) {-        (-            f64::from(self.to_phys(self.config.main.pad_x)),-            f64::from(self.to_phys(self.config.main.pad_y)),-        )-    }--    /// Convert a logical surface coordinate to a physical buffer coordinate.-    fn to_phys_f(&self, v: f64) -> f64 {-        v * f64::from(self.scale120) / 120.0-    }--    /// The active seat (the one that most recently produced input).-    fn active(&self) -> Option<&SeatData> {-        self.seats.get(self.active_seat)-    }--    /// The active seat's clipboard device, if any.-    fn data_device(&self) -> Option<&DataDevice> {-        self.active().and_then(|s| s.data_device.as_ref())-    }--    /// The active seat's primary-selection device, if any.-    fn primary_device(&self) -> Option<&PrimarySelectionDevice> {-        self.active().and_then(|s| s.primary_device.as_ref())-    }--    /// Find (or create) the per-seat entry for `seat`, returning its index.-    fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {-        if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {-            return i;-        }-        self.seats.push(SeatData {-            seat: seat.clone(),-            keyboard: None,-            pointer: None,-            cursor_shape_device: None,-            data_device: None,-            primary_device: None,-            text_input: None,-        });-        self.seats.len() - 1-    }--    /// Mark the seat owning `keyboard` as active for clipboard ownership.-    fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {-        if let Some(i) = self-            .seats-            .iter()-            .position(|s| s.keyboard.as_ref() == Some(keyboard))-        {-            self.active_seat = i;-        }-    }--    /// Mark the seat owning `pointer` as active for clipboard ownership.-    fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {-        if let Some(i) = self-            .seats-            .iter()-            .position(|s| s.pointer.as_ref() == Some(pointer))-        {-            self.active_seat = i;-        }-    }--    /// Map window pixel coordinates to an absolute `(row, col)` grid point.-    fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {-        let session = self.session.as_ref()?;-        let m = self.renderer.metrics();-        let (pad_x, pad_y) = self.padding();-        let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));-        let grid = session.term.grid();-        let col =-            ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));-        let vrow =-            ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));-        Some((grid.view_to_abs(vrow), col))-    }--    /// Left-button press: start (or word/line-extend) a selection.-    fn pointer_press(&mut self, time: u32) {-        let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {-            return;-        };-        let count = match self.last_click {-            Some((t, r, c, n))-                if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>-            {-                n % 3 + 1-            }-            _ => 1,-        };-        self.last_click = Some((time, row, col, count));-        let Some(session) = self.session.as_mut() else {-            return;-        };-        let ctrl = self.modifiers.ctrl;-        let grid = session.term.grid_mut();-        match count {-            2 => grid.select_word(row, col),-            3 => grid.select_line(row),-            // Holding Ctrl starts a rectangular (block) selection.-            _ if ctrl => grid.start_block_selection(row, col),-            _ => grid.start_selection(row, col),-        }-        self.selecting = true;-        self.needs_draw = true;-    }--    /// Pointer motion during a drag: extend the selection head and, if the-    /// pointer has left the top/bottom edge, start autoscrolling.-    fn pointer_drag(&mut self) {-        if !self.selecting {-            return;-        }-        let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {-            return;-        };-        if let Some(session) = self.session.as_mut() {-            session.term.grid_mut().extend_selection(row, col);-            self.needs_draw = true;-        }-        self.update_autoscroll();-    }--    /// Arm or disarm edge autoscroll based on the pointer's vertical position.-    fn update_autoscroll(&mut self) {-        let dir = if self.pointer_pos.1 < 0.0 {-            1 // above the top: reveal older lines-        } else if self.pointer_pos.1 >= f64::from(self.height) {-            -1 // below the bottom: advance toward the live screen-        } else {-            0-        };-        self.autoscroll = dir;-        if dir != 0 && self.autoscroll_timer.is_none() {-            let timer = Timer::immediate();-            self.autoscroll_timer = self-                .loop_handle-                .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())-                .ok();-        }-    }--    /// One autoscroll step: scroll the viewport and drag the selection head to-    /// the edge cell under the pointer. Reschedules until the drag ends or the-    /// pointer returns inside the window.-    fn autoscroll_step(&mut self) -> TimeoutAction {-        if !self.selecting || self.autoscroll == 0 {-            self.autoscroll_timer = None;-            return TimeoutAction::Drop;-        }-        if let Some(session) = self.session.as_mut() {-            session.term.scroll_view(self.autoscroll);-        }-        let edge_y = if self.autoscroll > 0 {-            0.0-        } else {-            f64::from(self.height) - 1.0-        };-        if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)-            && let Some(session) = self.session.as_mut()-        {-            session.term.grid_mut().extend_selection(row, col);-        }-        self.needs_draw = true;-        TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))-    }--    /// Left-button release: stop autoscrolling and publish the primary selection.-    fn pointer_release(&mut self, qh: &QueueHandle<App>) {-        if !self.selecting {-            return;-        }-        self.selecting = false;-        self.autoscroll = 0;-        if let Some(token) = self.autoscroll_timer.take() {-            self.loop_handle.remove(token);-        }-        self.set_primary(qh);-    }--    /// Whether the application wants mouse reports and the user is not holding-    /// Shift (which forces local selection regardless of mode).-    fn mouse_reporting(&self) -> bool {-        self.session-            .as_ref()-            .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)-            && !self.modifiers.shift-    }--    /// The viewport cell `(col, row)` under the pointer, clamped to the screen.-    fn report_screen_cell(&self) -> Option<(usize, usize)> {-        let session = self.session.as_ref()?;-        let m = self.renderer.metrics();-        let (pad_x, pad_y) = self.padding();-        let (ppx, ppy) = (-            self.to_phys_f(self.pointer_pos.0),-            self.to_phys_f(self.pointer_pos.1),-        );-        let grid = session.term.grid();-        let col =-            ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));-        let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)-            .min(grid.rows().saturating_sub(1));-        Some((col, row))-    }--    /// Report a button press/release to the application, if reporting is active.-    /// Returns whether the event was consumed (so local handling is skipped).-    fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {-        let Some(session) = self.session.as_ref() else {-            return false;-        };-        let grid = session.term.grid();-        let proto = grid.mouse_protocol();-        if proto == MouseProtocol::Off || self.modifiers.shift {-            return false;-        }-        let enc = grid.mouse_encoding();-        // X10 (mode 9) reports presses only; a release is swallowed, not sent.-        if (pressed || proto != MouseProtocol::X10)-            && let Some((col, row)) = self.report_screen_cell()-        {-            let bytes =-                crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);-            self.write_to_pty(&bytes);-            self.last_report_cell = Some((col, row));-        }-        true-    }--    /// Report pointer motion to the application when the active mode wants it.-    /// Returns whether reporting consumed the motion (suppressing local drag).-    fn try_report_motion(&mut self) -> bool {-        let Some(session) = self.session.as_ref() else {-            return false;-        };-        let grid = session.term.grid();-        let proto = grid.mouse_protocol();-        if proto == MouseProtocol::Off || self.modifiers.shift {-            return false;-        }-        let enc = grid.mouse_encoding();-        let wants = match proto {-            MouseProtocol::Any => true,-            MouseProtocol::Button => self.pressed_button.is_some(),-            _ => false,-        };-        if wants-            && let Some((col, row)) = self.report_screen_cell()-            && self.last_report_cell != Some((col, row))-        {-            // Any-event motion with no button held uses the "no button" code 3.-            let code = self.pressed_button.unwrap_or(3);-            let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers);-            self.write_to_pty(&bytes);-            self.last_report_cell = Some((col, row));-        }-        true-    }--    /// Send focus in/out (DECSET 1004) to the application when it asked for it.-    fn report_focus(&mut self, focused: bool) {-        if self-            .session-            .as_ref()-            .is_some_and(|s| s.term.grid().focus_events())-        {-            self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });-        }-    }--    /// Write bytes to the PTY master, logging on failure.-    fn write_to_pty(&mut self, bytes: &[u8]) {-        if let Some(session) = self.session.as_mut()-            && let Err(err) = write_all(session.pty.master(), bytes)-        {-            tracing::warn!("write to pty: {err}");-        }-    }--    /// The current selection text, if any and non-empty.-    fn selection_text(&self) -> Option<String> {-        let text = self.session.as_ref()?.term.grid().selection_text()?;-        (!text.is_empty()).then_some(text)-    }--    /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.-    fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {-        let Some(device) = self.data_device() else {-            return;-        };-        let source = self-            .data_device_manager-            .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());-        source.set_selection(device, self.serial);-        self.clipboard = text;-        self.copy_source = Some(source);-    }--    /// Take ownership of the primary selection, serving `text` to pasters.-    fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {-        let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())-        else {-            return;-        };-        let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());-        source.set_selection(device, self.serial);-        self.primary_clip = text;-        self.primary_source = Some(source);-    }--    /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).-    fn set_clipboard(&mut self, qh: &QueueHandle<App>) {-        if let Some(text) = self.selection_text() {-            self.claim_clipboard(text, qh);-        }-    }--    /// Claim the primary selection with the current selection (select-to-copy).-    fn set_primary(&mut self, qh: &QueueHandle<App>) {-        if let Some(text) = self.selection_text() {-            self.claim_primary(text, qh);-        }-    }--    /// Point the IME's candidate popup at the terminal cursor, in logical-    /// surface coordinates (the renderer works in physical pixels, so divide-    /// the physical cell rectangle back down by the scale).-    fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {-        let Some(session) = self.session.as_ref() else {-            return;-        };-        let (cx, cy) = session.term.grid().cursor();-        let m = self.renderer.metrics();-        let s = f64::from(self.scale120) / 120.0;-        let pad_x = f64::from(self.to_phys(self.config.main.pad_x));-        let pad_y = f64::from(self.to_phys(self.config.main.pad_y));-        let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;-        let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;-        let w = (f64::from(m.width) / s) as i32;-        let h = (f64::from(m.height) / s) as i32;-        ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));-    }--    /// Apply one IME transaction: commit any committed text to the shell, adopt-    /// the new preedit, then re-commit our state (cursor rectangle) to the IME.-    fn ime_done(&mut self, ti: &ZwpTextInputV3) {-        let commit = std::mem::take(&mut self.ime_commit_pending);-        // Preedit is replaced wholesale each cycle; an absent preedit clears it.-        self.preedit = std::mem::take(&mut self.ime_preedit_pending);-        if !commit.is_empty() {-            self.send_to_shell(commit.as_bytes());-        }-        self.ime_set_cursor_rect(ti);-        ti.commit();-        self.needs_draw = true;-    }--    /// Act on the OSC 52 clipboard requests an application made: take ownership-    /// of the selection it set, or answer a query with what we currently hold.-    fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {-        use crate::vt::ClipboardOp;-        let qh = self.qh.clone();-        for op in ops {-            match op {-                ClipboardOp::Set {-                    primary: true,-                    text,-                } => self.claim_primary(text, &qh),-                ClipboardOp::Set {-                    primary: false,-                    text,-                } => self.claim_clipboard(text, &qh),-                ClipboardOp::Query { primary } => {-                    let text = if primary {-                        &self.primary_clip-                    } else {-                        &self.clipboard-                    };-                    let kind = if primary { 'p' } else { 'c' };-                    let reply = format!(-                        "\x1b]52;{kind};{}\x07",-                        crate::vt::base64_encode(text.as_bytes())-                    );-                    self.write_to_pty(reply.as_bytes());-                }-            }-        }-    }--    /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).-    fn paste_clipboard(&mut self) {-        let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {-            return;-        };-        let Some(mime) = offer.with_mime_types(pick_mime) else {-            return;-        };-        if let Ok(pipe) = offer.receive(mime) {-            self.read_paste(pipe);-        }-    }--    /// Paste the primary selection into the shell (middle click).-    fn paste_primary(&mut self) {-        let Some(offer) = self-            .primary_device()-            .and_then(|d| d.data().selection_offer())-        else {-            return;-        };-        let Some(mime) = offer.with_mime_types(pick_mime) else {-            return;-        };-        if let Ok(pipe) = offer.receive(mime) {-            self.read_paste(pipe);-        }-    }--    /// Drain a clipboard read-pipe on the event loop, writing the bytes to the-    /// PTY once the source closes its end.-    fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {-        let mut data: Vec<u8> = Vec::new();-        let registered = self-            .loop_handle-            .insert_source(pipe, move |_, file, app: &mut App| {-                // SAFETY: the file is owned by the source and not closed while read.-                let f: &mut File = unsafe { file.get_mut() };-                let mut tmp = [0u8; 4096];-                match f.read(&mut tmp) {-                    Ok(0) => {-                        app.paste_bytes(&data);-                        PostAction::Remove-                    }-                    Ok(n) => {-                        data.extend_from_slice(&tmp[..n]);-                        PostAction::Continue-                    }-                    Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {-                        PostAction::Continue-                    }-                    Err(e) => {-                        tracing::warn!("read paste pipe: {e}");-                        PostAction::Remove-                    }-                }-            });-        if let Err(err) = registered {-            tracing::warn!("register paste pipe: {err}");-        }-    }--    /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and-    /// snapping the viewport to the live screen.-    fn paste_bytes(&mut self, data: &[u8]) {-        let Some(session) = self.session.as_mut() else {-            return;-        };-        session.term.scroll_to_bottom();-        self.needs_draw = true;-        let bracketed = session.term.grid().bracketed_paste();-        // Strip control bytes a terminal must never receive raw from a paste;-        // keep tab and newlines (CR is what the shell expects for Enter).-        let mut clean: Vec<u8> = Vec::with_capacity(data.len());-        for &b in data {-            match b {-                b'\n' => clean.push(b'\r'),-                b'\t' | b'\r' => clean.push(b),-                0x20..=0xff => clean.push(b),-                _ => {}-            }-        }-        let fd = session.pty.master();-        if bracketed {-            let _ = write_all(fd, b"\x1b[200~");-            let _ = write_all(fd, &clean);-            let _ = write_all(fd, b"\x1b[201~");-        } else if let Err(err) = write_all(fd, &clean) {-            tracing::warn!("write paste to pty: {err}");-        }-    }--    /// After parsing child output: send any replies, sync the title, repaint.-    fn after_feed(&mut self) {-        let Some(session) = self.session.as_mut() else {-            return;-        };-        let reply = session.term.take_response();-        if !reply.is_empty()-            && let Err(err) = write_all(session.pty.master(), &reply)-        {-            tracing::warn!("write to pty: {err}");-        }--        let new_title = session.term.title().map(str::to_owned);-        if new_title.as_deref() != self.title.as_deref() {-            self.title = new_title;-            self.window-                .set_title(self.title.clone().unwrap_or_default());-        }-        let rang = session.term.take_bell();-        let ops = session.term.take_clipboard_ops();-        let notifications = session.term.take_notifications();-        if !ops.is_empty() {-            self.handle_clipboard_ops(ops);-        }-        for note in notifications {-            self.send_notification(&note);-        }-        if rang {-            self.ring_bell();-        }-        self.needs_draw = true;-    }--    /// React to a `BEL`: optionally flash, run the configured bell command, and-    /// request the compositor's attention when unfocused.-    fn ring_bell(&mut self) {-        if self.config.bell.visual {-            self.start_flash();-        }-        if let Some((program, args)) = self.config.bell.command.split_first() {-            let _ = std::process::Command::new(program)-                .args(args)-                .stdin(std::process::Stdio::null())-                .stdout(std::process::Stdio::null())-                .stderr(std::process::Stdio::null())-                .spawn()-                .inspect_err(|err| tracing::warn!("bell command: {err}"));-        }-        if self.config.bell.urgent && !self.focused {-            self.request_attention();-        }-    }--    /// Deliver a desktop notification through the configured notifier (default-    /// `notify-send`), appending the title and body as the final arguments.-    fn send_notification(&self, note: &crate::vt::Notification) {-        let Some((program, args)) = self.config.notify.command.split_first() else {-            return;-        };-        let title = note-            .title-            .clone()-            .or_else(|| self.title.clone())-            .unwrap_or_else(|| "beer".to_string());-        let _ = std::process::Command::new(program)-            .args(args)-            .arg(title)-            .arg(&note.body)-            .stdin(std::process::Stdio::null())-            .stdout(std::process::Stdio::null())-            .stderr(std::process::Stdio::null())-            .spawn()-            .inspect_err(|err| tracing::warn!("notify command: {err}"));-    }--    /// Ask the compositor to draw attention to the window (xdg-activation).-    fn request_attention(&mut self) {-        let Some(activation) = self.activation.as_ref() else {-            return;-        };-        let seat_and_serial = self-            .seats-            .get(self.active_seat)-            .map(|s| (s.seat.clone(), self.serial));-        let data = smithay_client_toolkit::activation::RequestData {-            app_id: Some("dev.notashelf.beer".to_string()),-            seat_and_serial,-            surface: Some(self.window.wl_surface().clone()),-        };-        activation.request_token::<App>(&self.qh, data);-    }--    /// Begin a visual-bell flash: invert the screen for a moment. Clearing the-    /// buffer ring forces a full repaint with the inverted theme.-    fn start_flash(&mut self) {-        self.flashing = true;-        self.frames.clear();-        self.needs_draw = true;-        if self.flash_timer.is_none() {-            let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));-            self.flash_timer = self-                .loop_handle-                .insert_source(timer, |_, _, app: &mut App| {-                    app.flashing = false;-                    app.frames.clear();-                    app.needs_draw = true;-                    app.flash_timer = None;-                    TimeoutAction::Drop-                })-                .ok();-        }-    }--    /// Recompute the grid size for the current window and tell the grid and the-    /// PTY about it if it changed.-    fn resize_grid(&mut self) {-        let (cols, rows) = self.grid_dims();-        let Some(session) = self.session.as_mut() else {-            return;-        };-        if (cols as usize, rows as usize)-            == (session.term.grid().cols(), session.term.grid().rows())-        {-            return;-        }-        session.term.resize(cols as usize, rows as usize);-        if let Err(err) = session.pty.resize(cols, rows) {-            tracing::warn!("resize pty: {err}");-        }-    }--    /// The child shell has gone away; reap it, capture its code, and tear the-    /// window down.-    fn child_exited(&mut self) {-        if let Some(session) = self.session.as_mut() {-            match session.pty.wait() {-                Ok(status) => {-                    tracing::info!("shell exited: {status}");-                    // Mirror the shell's status: its code, or 128+signal if killed.-                    let code = status-                        .code()-                        .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));-                    self.exit_code = ExitCode::from(code as u8);-                }-                Err(err) => tracing::warn!("reap shell: {err}"),-            }-        }-        self.exit = true;-    }--    /// Present a frame if one is wanted and the compositor is ready for it.-    /// Called after every event-loop wake; the frame-callback gate keeps draws-    /// paced to the display instead of one per PTY read. While the app holds-    /// synchronized output (DECSET 2026) we withhold the frame, but arm a-    /// timeout so a stuck `2026h` cannot freeze the window.-    fn flush(&mut self) {-        let sync = self-            .session-            .as_ref()-            .is_some_and(|s| s.term.grid().sync_active());-        if sync {-            if self.sync_timeout.is_none() {-                let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));-                self.sync_timeout = self-                    .loop_handle-                    .insert_source(timer, |_, _, app: &mut App| {-                        if let Some(session) = app.session.as_mut() {-                            session.term.grid_mut().set_sync(false);-                        }-                        app.sync_timeout = None;-                        app.needs_draw = true;-                        TimeoutAction::Drop-                    })-                    .ok();-            }-            return;-        }-        if let Some(token) = self.sync_timeout.take() {-            self.loop_handle.remove(token);-        }-        if self.needs_draw && !self.frame_pending && self.session.is_some() {-            self.present();-        }-    }--    /// Render only the rows that changed since the chosen buffer last displayed-    /// them, damage just those rows, and commit with a frame-callback request.-    fn present(&mut self) {-        self.needs_draw = false;-        // URL hint labels overlay the grid but are not part of the row snapshot,-        // so force a full redraw while the labels are showing.-        if self.url_mode {-            self.frames.clear();-        }-        // Render into a buffer sized in physical pixels (logical × scale); the-        // viewport presents it back at the logical surface size.-        let (w, h) = self.phys_dims();-        let m = self.renderer.metrics();-        let (focused, blink_on) = (self.focused, self.blink_on);--        // A resize invalidates every buffer's contents and size.-        if self.buf_dims != (w, h) {-            self.frames.clear();-            self.buf_dims = (w, h);-        }--        let Some(session) = self.session.as_ref() else {-            return;-        };-        let grid = session.term.grid();-        // The visual bell inverts fg/bg for the duration of the flash.-        let flashed = self.flashing.then(|| session.term.theme().inverted());-        let theme = flashed.as_ref().unwrap_or(session.term.theme());-        let rows = grid.rows();-        let mut cur: Vec<RowSnap> = (0..rows)-            .map(|y| row_snap(grid, y, focused, blink_on))-            .collect();--        // The search prompt occupies the bottom row while search mode is active.-        // Recording it in the snapshot keeps the row's damage/diff correct.-        let bar_text = self.searching.then(|| {-            let (n, total) = grid.search_count();-            format!(-                "search: {}  [{n}/{total}]",-                grid.search_query().unwrap_or("")-            )-        });-        if let Some(text) = &bar_text-            && rows > 0-        {-            cur[rows - 1].overlay = Some(text.clone());-        }--        // The IME preedit is drawn inline at the cursor while composing.-        if !self.preedit.is_empty() && grid.view_at_bottom() {-            let (cx, cy) = grid.cursor();-            if cy < rows {-                cur[cy].preedit = Some((cx, self.preedit.clone()));-            }-        }--        // Reuse a buffer the compositor has released, else grow the ring.-        let stride = w as i32 * 4;-        let mut idx = None;-        for i in 0..self.frames.len() {-            if self.pool.canvas(&self.frames[i].buffer).is_some() {-                idx = Some(i);-                break;-            }-        }-        let idx = match idx {-            Some(i) => i,-            None if self.frames.len() < MAX_BUFFERS => {-                match self-                    .pool-                    .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)-                {-                    Ok((buffer, _)) => {-                        self.frames.push(FrameBuf {-                            buffer,-                            rows: Vec::new(),-                        });-                        self.frames.len() - 1-                    }-                    Err(err) => {-                        tracing::error!("allocate shm buffer: {err}");-                        return;-                    }-                }-            }-            // All buffers are still held by the compositor; a release event will-            // wake us and `needs_draw` (re-set below) retries then.-            None => {-                self.needs_draw = true;-                return;-            }-        };--        // Rows that differ from what this buffer last showed (all, if fresh).-        let prev = &self.frames[idx].rows;-        let dirty: Vec<usize> = (0..rows)-            .filter(|&y| prev.get(y) != Some(&cur[y]))-            .collect();-        if dirty.is_empty() {-            return;-        }--        // A buffer used for the first time has uninitialized margins; paint the-        // whole thing (background + padding) once, then damage it in full below.-        let fresh = self.frames[idx].rows.is_empty();-        let pad_y = self.to_phys(self.config.main.pad_y) as i32;-        let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {-            return;-        };-        let dims = (w as usize, h as usize);-        let frame = crate::render::Frame {-            theme,-            focused,-            blink_on,-            hovered_link: self.hovered_link,-        };-        if fresh {-            self.renderer.clear(canvas, dims, theme);-        }-        for &y in &dirty {-            self.renderer.render_row(canvas, dims, grid, &frame, y);-        }-        // Draw the search prompt over the (now repainted) bottom row.-        if let Some(text) = &bar_text-            && dirty.contains(&(rows - 1))-        {-            self.renderer-                .render_search_bar(canvas, dims, theme, rows - 1, text);-        }-        // Draw the IME preedit inline over its (repainted) cursor row.-        for &y in &dirty {-            if let Some((col, text)) = &cur[y].preedit {-                self.renderer-                    .render_preedit(canvas, dims, theme, y, *col, text);-            }-        }-        // Draw URL hint labels on top, narrowing to those matching the input.-        if self.url_mode {-            for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {-                if label.starts_with(&self.url_input) {-                    self.renderer-                        .render_label(canvas, dims, theme, hit.row, hit.col, label);-                }-            }-        }-        self.frames[idx].rows = cur;--        let surface = self.window.wl_surface();-        if let Err(err) = self.frames[idx].buffer.attach_to(surface) {-            tracing::error!("attach buffer: {err}");-            return;-        }-        // With a viewport the buffer is presented at the logical destination, so-        // its own scale stays 1; without one, fall back to integer buffer scale.-        if let Some(vp) = &self.viewport {-            surface.set_buffer_scale(1);-            vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);-        } else {-            surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);-        }-        if fresh {-            surface.damage_buffer(0, 0, w as i32, h as i32);-        } else {-            for &y in &dirty {-                let top = pad_y + y as i32 * m.height as i32;-                surface.damage_buffer(0, top, w as i32, m.height as i32);-            }-        }-        surface.frame(&self.qh, surface.clone());-        self.window.commit();-        self.frame_pending = true;-    }-}--impl CompositorHandler for App {-    fn scale_factor_changed(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_surface::WlSurface,-        factor: i32,-    ) {-        // Integer fallback for compositors without fractional-scale-v1; ignored-        // when the fractional-scale object drives the scale instead.-        if self.fractional_scale.is_none() {-            self.set_scale((factor.max(1) as u32) * 120);-        }-    }--    fn transform_changed(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_surface::WlSurface,-        _: wl_output::Transform,-    ) {-    }--    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {-        // The compositor is ready for another frame; `flush` will repaint if the-        // grid has changed since the last present.-        self.frame_pending = false;-    }--    fn surface_enter(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_surface::WlSurface,-        _: &wl_output::WlOutput,-    ) {-    }--    fn surface_leave(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_surface::WlSurface,-        _: &wl_output::WlOutput,-    ) {-    }-}--impl WindowHandler for App {-    fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {-        self.exit = true;-    }--    fn configure(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &Window,-        configure: WindowConfigure,-        _serial: u32,-    ) {-        if let (Some(w), Some(h)) = configure.new_size {-            self.width = w.get();-            self.height = h.get();-            if let Some(vp) = &self.viewport {-                vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);-            }-        }-        self.focused = configure.is_activated();-        if self.session.is_none() {-            self.spawn_session();-        } else {-            self.resize_grid();-        }-        self.needs_draw = true;-    }-}--impl ShmHandler for App {-    fn shm_state(&mut self) -> &mut Shm {-        &mut self.shm-    }-}--impl SeatHandler for App {-    fn seat_state(&mut self) -> &mut SeatState {-        &mut self.seat_state-    }--    fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {-        // Clipboard/primary devices are seat-scoped, not capability-scoped.-        let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));-        let primary_device = self-            .primary_manager-            .as_ref()-            .map(|m| m.get_selection_device(qh, &seat));-        let i = self.seat_index(&seat);-        self.seats[i].data_device = data_device;-        self.seats[i].primary_device = primary_device;-    }--    fn new_capability(-        &mut self,-        _: &Connection,-        qh: &QueueHandle<Self>,-        seat: wl_seat::WlSeat,-        capability: Capability,-    ) {-        let i = self.seat_index(&seat);-        if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {-            // get_keyboard_with_repeat drives key repeat off a calloop timer and-            // delivers each repeat through the callback.-            let loop_handle = self.loop_handle.clone();-            let keyboard = self.seat_state.get_keyboard_with_repeat(-                qh,-                &seat,-                None,-                loop_handle,-                Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),-            );-            match keyboard {-                Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),-                Err(err) => tracing::warn!("get keyboard: {err}"),-            }-            if self.seats[i].text_input.is_none()-                && let Some(mgr) = self.text_input_manager.as_ref()-            {-                self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));-            }-        }-        if capability == Capability::Pointer && self.seats[i].pointer.is_none() {-            match self.seat_state.get_pointer(qh, &seat) {-                Ok(pointer) => {-                    self.seats[i].cursor_shape_device = self-                        .cursor_shape_manager-                        .as_ref()-                        .map(|m| m.get_shape_device(&pointer, qh));-                    self.seats[i].pointer = Some(pointer);-                }-                Err(err) => tracing::warn!("get pointer: {err}"),-            }-        }-    }--    fn remove_capability(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        seat: wl_seat::WlSeat,-        capability: Capability,-    ) {-        let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {-            return;-        };-        match capability {-            Capability::Keyboard => {-                if let Some(keyboard) = s.keyboard.take() {-                    keyboard.release();-                }-            }-            Capability::Pointer => {-                s.cursor_shape_device = None;-                if let Some(pointer) = s.pointer.take() {-                    pointer.release();-                }-            }-            _ => {}-        }-    }--    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {-        self.seats.retain(|s| s.seat != seat);-        self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));-    }-}--impl KeyboardHandler for App {-    fn enter(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        keyboard: &wl_keyboard::WlKeyboard,-        _: &wl_surface::WlSurface,-        serial: u32,-        _: &[u32],-        _: &[Keysym],-    ) {-        self.activate_keyboard(keyboard);-        self.serial = serial;-        self.focused = true;-        self.report_focus(true);-        self.needs_draw = true;-    }--    fn leave(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_keyboard::WlKeyboard,-        _: &wl_surface::WlSurface,-        _: u32,-    ) {-        self.focused = false;-        self.report_focus(false);-        self.needs_draw = true;-    }--    fn press_key(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        keyboard: &wl_keyboard::WlKeyboard,-        serial: u32,-        event: KeyEvent,-    ) {-        self.activate_keyboard(keyboard);-        self.serial = serial;-        self.handle_key(&event);-    }--    fn repeat_key(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_keyboard::WlKeyboard,-        _: u32,-        _: KeyEvent,-    ) {-        // Repeats are delivered through the get_keyboard_with_repeat callback;-        // this non-calloop hook is unused.-    }--    fn release_key(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_keyboard::WlKeyboard,-        _: u32,-        _: KeyEvent,-    ) {-    }--    fn update_modifiers(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_keyboard::WlKeyboard,-        _: u32,-        modifiers: Modifiers,-        _: RawModifiers,-        _: u32,-    ) {-        self.modifiers = modifiers;-    }--    fn update_repeat_info(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_keyboard::WlKeyboard,-        _: RepeatInfo,-    ) {-    }-}--/// Parse a configured cursor-style name into a [`CursorShape`].-fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {-    match style? {-        "block" => Some(CursorShape::Block),-        "beam" | "bar" => Some(CursorShape::Beam),-        "underline" => Some(CursorShape::Underline),-        other => {-            tracing::warn!("unknown cursor style {other:?}");-            None-        }-    }-}--/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the-/// same length so prefix matching is unambiguous.-fn hint_labels(n: usize) -> Vec<String> {-    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";-    if n == 0 {-        return Vec::new();-    }-    let (mut width, mut capacity) = (1usize, 26usize);-    while capacity < n {-        width += 1;-        capacity *= 26;-    }-    (0..n)-        .map(|i| {-            let mut idx = i;-            let mut chars = vec![b'a'; width];-            for slot in chars.iter_mut().rev() {-                *slot = ALPHABET[idx % 26];-                idx /= 26;-            }-            String::from_utf8(chars).expect("ascii labels are valid utf-8")-        })-        .collect()-}--/// Map a Wayland button code to the terminal mouse base code, if reportable.-fn button_code(button: u32) -> Option<u8> {-    match button {-        BTN_LEFT => Some(0),-        BTN_MIDDLE => Some(1),-        BTN_RIGHT => Some(2),-        _ => None,-    }-}--impl PointerHandler for App {-    fn pointer_frame(-        &mut self,-        _: &Connection,-        qh: &QueueHandle<Self>,-        pointer: &wl_pointer::WlPointer,-        events: &[PointerEvent],-    ) {-        self.activate_pointer(pointer);-        let cell_h = f64::from(self.renderer.metrics().height);-        for event in events {-            match &event.kind {-                PointerEventKind::Enter { serial } => {-                    self.pointer_pos = event.position;-                    self.pointer_enter_serial = *serial;-                    self.update_hover(pointer);-                    self.pointer_drag();-                }-                PointerEventKind::Motion { .. } => {-                    self.pointer_pos = event.position;-                    if self.try_report_motion() {-                        continue;-                    }-                    if !self.selecting {-                        self.update_hover(pointer);-                    }-                    self.pointer_drag();-                }-                PointerEventKind::Press {-                    button,-                    serial,-                    time,-                    ..-                } => {-                    self.serial = *serial;-                    self.pointer_pos = event.position;-                    if let Some(code) = button_code(*button)-                        && self.try_report_button(code, true)-                    {-                        self.pressed_button = Some(code);-                        continue;-                    }-                    match *button {-                        BTN_LEFT => {-                            self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);-                            self.pointer_press(*time);-                        }-                        BTN_MIDDLE => self.paste_primary(),-                        _ => {}-                    }-                }-                PointerEventKind::Release { button, .. } => {-                    self.pointer_pos = event.position;-                    let code = button_code(*button);-                    if let Some(code) = code-                        && self.try_report_button(code, false)-                    {-                        if self.pressed_button == Some(code) {-                            self.pressed_button = None;-                        }-                        continue;-                    }-                    if *button == BTN_LEFT {-                        self.maybe_open_clicked_link();-                        self.pointer_release(qh);-                    }-                }-                PointerEventKind::Axis { vertical, .. } => {-                    // Wheel notches arrive as value120 (÷120) or legacy discrete-                    // steps, ~3 lines each; touchpads send pixels per cell height.-                    let (raw, scale) = if vertical.value120 != 0 {-                        (f64::from(vertical.value120) / 120.0, 3.0)-                    } else if vertical.discrete != 0 {-                        (f64::from(vertical.discrete), 3.0)-                    } else if cell_h > 0.0 {-                        (vertical.absolute / cell_h, 1.0)-                    } else {-                        continue;-                    };-                    if raw == 0.0 {-                        continue;-                    }-                    let mult = self.config.mouse.scroll_multiplier.max(0.0);-                    let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;-                    let up = raw < 0.0;-                    // Reporting apps get wheel buttons (64 up / 65 down) as-                    // presses, one per line, capped so a flick cannot flood.-                    if self.mouse_reporting() {-                        let code = if up { 64 } else { 65 };-                        for _ in 0..lines.clamp(1, 8) {-                            self.try_report_button(code, true);-                        }-                        continue;-                    }-                    // On the alternate screen there is no scrollback to move, so-                    // (when enabled) translate the wheel into cursor-key presses-                    // for apps that did not request mouse reporting.-                    let alt = self-                        .session-                        .as_ref()-                        .is_some_and(|s| s.term.grid().alt_active());-                    if alt && self.config.mouse.alternate_scroll {-                        self.alternate_scroll(up, lines.clamp(1, 8));-                        continue;-                    }-                    // Positive axis = scroll down (toward live); the viewport-                    // scrolls the opposite way (negative offset delta).-                    let delta = if up { lines } else { -lines };-                    if let Some(session) = self.session.as_mut() {-                        session.term.scroll_view(delta);-                        self.needs_draw = true;-                    }-                }-                _ => {}-            }-        }-    }-}--impl OutputHandler for App {-    fn output_state(&mut self) -> &mut OutputState {-        &mut self.output_state-    }--    fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}--    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}--    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-}--impl ProvidesRegistryState for App {-    fn registry(&mut self) -> &mut RegistryState {-        &mut self.registry_state-    }-    registry_handlers![OutputState, SeatState];-}--/// Serve the held clipboard text when a paste target requests it.-fn serve(text: &str, fd: WritePipe) {-    let mut file = File::from(OwnedFd::from(fd));-    let _ = file.write_all(text.as_bytes());-}--impl DataDeviceHandler for App {-    fn enter(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &WlDataDevice,-        _: f64,-        _: f64,-        _: &wl_surface::WlSurface,-    ) {-    }-    fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}-    fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}-    fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}-    fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}-}--impl DataOfferHandler for App {-    fn source_actions(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &mut DragOffer,-        _: DndAction,-    ) {-    }-    fn selected_action(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &mut DragOffer,-        _: DndAction,-    ) {-    }-}--impl DataSourceHandler for App {-    fn accept_mime(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &WlDataSource,-        _: Option<String>,-    ) {-    }--    fn send_request(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        source: &WlDataSource,-        _mime: String,-        fd: WritePipe,-    ) {-        if self-            .copy_source-            .as_ref()-            .is_some_and(|s| s.inner() == source)-        {-            serve(&self.clipboard, fd);-        }-    }--    fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {-        if self-            .copy_source-            .as_ref()-            .is_some_and(|s| s.inner() == source)-        {-            self.copy_source = None;-        }-    }--    fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}-    fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}-    fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}-}--impl PrimarySelectionDeviceHandler for App {-    fn selection(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &ZwpPrimarySelectionDeviceV1,-    ) {-    }-}--impl PrimarySelectionSourceHandler for App {-    fn send_request(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        source: &ZwpPrimarySelectionSourceV1,-        _mime: String,-        fd: WritePipe,-    ) {-        if self-            .primary_source-            .as_ref()-            .is_some_and(|s| s.inner() == source)-        {-            serve(&self.primary_clip, fd);-        }-    }--    fn cancelled(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        source: &ZwpPrimarySelectionSourceV1,-    ) {-        if self-            .primary_source-            .as_ref()-            .is_some_and(|s| s.inner() == source)-        {-            self.primary_source = None;-        }-    }-}--// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by-// hand. Only the fractional-scale object carries an event we act on.-impl Dispatch<WpFractionalScaleV1, ()> for App {-    fn event(-        state: &mut Self,-        _: &WpFractionalScaleV1,-        event: wp_fractional_scale_v1::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-        if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {-            state.set_scale(scale);-        }-    }-}--impl Dispatch<WpFractionalScaleManagerV1, ()> for App {-    fn event(-        _: &mut Self,-        _: &WpFractionalScaleManagerV1,-        _: <WpFractionalScaleManagerV1 as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl Dispatch<WpViewporter, ()> for App {-    fn event(-        _: &mut Self,-        _: &WpViewporter,-        _: <WpViewporter as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl Dispatch<WpViewport, ()> for App {-    fn event(-        _: &mut Self,-        _: &WpViewport,-        _: <WpViewport as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl ActivationHandler for App {-    type RequestData = RequestData;--    fn new_token(&mut self, token: String, _: &RequestData) {-        // The compositor granted an activation token; use it to draw attention.-        if let Some(activation) = self.activation.as_ref() {-            activation.activate::<App>(self.window.wl_surface(), token);-        }-    }-}--impl Dispatch<ZwpTextInputManagerV3, ()> for App {-    fn event(-        _: &mut Self,-        _: &ZwpTextInputManagerV3,-        _: <ZwpTextInputManagerV3 as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the-// accumulated transaction on `done` and re-enable on focus enter.-impl Dispatch<ZwpTextInputV3, ()> for App {-    fn event(-        state: &mut Self,-        ti: &ZwpTextInputV3,-        event: zwp_text_input_v3::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-        use zwp_text_input_v3::Event;-        match event {-            Event::Enter { .. } => {-                ti.enable();-                ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);-                state.ime_set_cursor_rect(ti);-                ti.commit();-            }-            Event::Leave { .. } => {-                ti.disable();-                ti.commit();-                state.preedit.clear();-                state.ime_preedit_pending.clear();-                state.ime_commit_pending.clear();-                state.needs_draw = true;-            }-            Event::PreeditString { text, .. } => {-                state.ime_preedit_pending = text.unwrap_or_default();-            }-            Event::CommitString { text } => {-                state.ime_commit_pending.push_str(&text.unwrap_or_default());-            }-            Event::Done { .. } => state.ime_done(ti),-            // We do not expose surrounding text, so nothing to delete.-            Event::DeleteSurroundingText { .. } => {}-            _ => {}-        }-    }-}--delegate_compositor!(App);-delegate_output!(App);-delegate_shm!(App);-delegate_seat!(App);-delegate_keyboard!(App);-delegate_pointer!(App);-delegate_xdg_shell!(App);-delegate_xdg_window!(App);-delegate_data_device!(App);-delegate_primary_selection!(App);-delegate_registry!(App);-delegate_activation!(App);diff --git a/src/wayland/handlers.rs b/src/wayland/handlers.rsnew file mode 100644index 0000000..4f6a935--- /dev/null+++ b/src/wayland/handlers.rs@@ -0,0 +1,711 @@+use super::*;++impl CompositorHandler for App {+    fn scale_factor_changed(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_surface::WlSurface,+        factor: i32,+    ) {+        // Integer fallback for compositors without fractional-scale-v1; ignored+        // when the fractional-scale object drives the scale instead.+        if self.fractional_scale.is_none() {+            self.set_scale((factor.max(1) as u32) * 120);+        }+    }++    fn transform_changed(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_surface::WlSurface,+        _: wl_output::Transform,+    ) {+    }++    fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {+        // The compositor is ready for another frame; `flush` will repaint if the+        // grid has changed since the last present.+        self.frame_pending = false;+    }++    fn surface_enter(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_surface::WlSurface,+        _: &wl_output::WlOutput,+    ) {+    }++    fn surface_leave(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_surface::WlSurface,+        _: &wl_output::WlOutput,+    ) {+    }+}++impl WindowHandler for App {+    fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {+        self.exit = true;+    }++    fn configure(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &Window,+        configure: WindowConfigure,+        _serial: u32,+    ) {+        if let (Some(w), Some(h)) = configure.new_size {+            self.width = w.get();+            self.height = h.get();+            if let Some(vp) = &self.viewport {+                vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+            }+        }+        self.focused = configure.is_activated();+        if self.session.is_none() {+            self.spawn_session();+        } else {+            self.resize_grid();+        }+        self.needs_draw = true;+    }+}++impl ShmHandler for App {+    fn shm_state(&mut self) -> &mut Shm {+        &mut self.shm+    }+}++impl SeatHandler for App {+    fn seat_state(&mut self) -> &mut SeatState {+        &mut self.seat_state+    }++    fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {+        // Clipboard/primary devices are seat-scoped, not capability-scoped.+        let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));+        let primary_device = self+            .primary_manager+            .as_ref()+            .map(|m| m.get_selection_device(qh, &seat));+        let i = self.seat_index(&seat);+        self.seats[i].data_device = data_device;+        self.seats[i].primary_device = primary_device;+    }++    fn new_capability(+        &mut self,+        _: &Connection,+        qh: &QueueHandle<Self>,+        seat: wl_seat::WlSeat,+        capability: Capability,+    ) {+        let i = self.seat_index(&seat);+        if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {+            // get_keyboard_with_repeat drives key repeat off a calloop timer and+            // delivers each repeat through the callback.+            let loop_handle = self.loop_handle.clone();+            let keyboard = self.seat_state.get_keyboard_with_repeat(+                qh,+                &seat,+                None,+                loop_handle,+                Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),+            );+            match keyboard {+                Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),+                Err(err) => tracing::warn!("get keyboard: {err}"),+            }+            if self.seats[i].text_input.is_none()+                && let Some(mgr) = self.text_input_manager.as_ref()+            {+                self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));+            }+        }+        if capability == Capability::Pointer && self.seats[i].pointer.is_none() {+            match self.seat_state.get_pointer(qh, &seat) {+                Ok(pointer) => {+                    self.seats[i].cursor_shape_device = self+                        .cursor_shape_manager+                        .as_ref()+                        .map(|m| m.get_shape_device(&pointer, qh));+                    self.seats[i].pointer = Some(pointer);+                }+                Err(err) => tracing::warn!("get pointer: {err}"),+            }+        }+    }++    fn remove_capability(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        seat: wl_seat::WlSeat,+        capability: Capability,+    ) {+        let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {+            return;+        };+        match capability {+            Capability::Keyboard => {+                if let Some(keyboard) = s.keyboard.take() {+                    keyboard.release();+                }+            }+            Capability::Pointer => {+                s.cursor_shape_device = None;+                if let Some(pointer) = s.pointer.take() {+                    pointer.release();+                }+            }+            _ => {}+        }+    }++    fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {+        self.seats.retain(|s| s.seat != seat);+        self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));+    }+}++impl KeyboardHandler for App {+    fn enter(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        keyboard: &wl_keyboard::WlKeyboard,+        _: &wl_surface::WlSurface,+        serial: u32,+        _: &[u32],+        _: &[Keysym],+    ) {+        self.activate_keyboard(keyboard);+        self.serial = serial;+        self.focused = true;+        self.report_focus(true);+        self.needs_draw = true;+    }++    fn leave(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: &wl_surface::WlSurface,+        _: u32,+    ) {+        self.focused = false;+        self.report_focus(false);+        self.needs_draw = true;+    }++    fn press_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        keyboard: &wl_keyboard::WlKeyboard,+        serial: u32,+        event: KeyEvent,+    ) {+        self.activate_keyboard(keyboard);+        self.serial = serial;+        self.handle_key(&event);+    }++    fn repeat_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        _: KeyEvent,+    ) {+        // Repeats are delivered through the get_keyboard_with_repeat callback;+        // this non-calloop hook is unused.+    }++    fn release_key(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        _: KeyEvent,+    ) {+    }++    fn update_modifiers(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: u32,+        modifiers: Modifiers,+        _: RawModifiers,+        _: u32,+    ) {+        self.modifiers = modifiers;+    }++    fn update_repeat_info(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_keyboard::WlKeyboard,+        _: RepeatInfo,+    ) {+    }+}++/// Parse a configured cursor-style name into a [`CursorShape`].+pub(super) fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {+    match style? {+        "block" => Some(CursorShape::Block),+        "beam" | "bar" => Some(CursorShape::Beam),+        "underline" => Some(CursorShape::Underline),+        other => {+            tracing::warn!("unknown cursor style {other:?}");+            None+        }+    }+}++/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the+/// same length so prefix matching is unambiguous.+pub(super) fn hint_labels(n: usize) -> Vec<String> {+    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";+    if n == 0 {+        return Vec::new();+    }+    let (mut width, mut capacity) = (1usize, 26usize);+    while capacity < n {+        width += 1;+        capacity *= 26;+    }+    (0..n)+        .map(|i| {+            let mut idx = i;+            let mut chars = vec![b'a'; width];+            for slot in chars.iter_mut().rev() {+                *slot = ALPHABET[idx % 26];+                idx /= 26;+            }+            String::from_utf8(chars).expect("ascii labels are valid utf-8")+        })+        .collect()+}++/// Map a Wayland button code to the terminal mouse base code, if reportable.+fn button_code(button: u32) -> Option<u8> {+    match button {+        BTN_LEFT => Some(0),+        BTN_MIDDLE => Some(1),+        BTN_RIGHT => Some(2),+        _ => None,+    }+}++impl PointerHandler for App {+    fn pointer_frame(+        &mut self,+        _: &Connection,+        qh: &QueueHandle<Self>,+        pointer: &wl_pointer::WlPointer,+        events: &[PointerEvent],+    ) {+        self.activate_pointer(pointer);+        let cell_h = f64::from(self.renderer.metrics().height);+        for event in events {+            match &event.kind {+                PointerEventKind::Enter { serial } => {+                    self.pointer_pos = event.position;+                    self.pointer_enter_serial = *serial;+                    self.update_hover(pointer);+                    self.pointer_drag();+                }+                PointerEventKind::Motion { .. } => {+                    self.pointer_pos = event.position;+                    if self.try_report_motion() {+                        continue;+                    }+                    if !self.selecting {+                        self.update_hover(pointer);+                    }+                    self.pointer_drag();+                }+                PointerEventKind::Press {+                    button,+                    serial,+                    time,+                    ..+                } => {+                    self.serial = *serial;+                    self.pointer_pos = event.position;+                    if let Some(code) = button_code(*button)+                        && self.try_report_button(code, true)+                    {+                        self.pressed_button = Some(code);+                        continue;+                    }+                    match *button {+                        BTN_LEFT => {+                            self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+                            self.pointer_press(*time);+                        }+                        BTN_MIDDLE => self.paste_primary(),+                        _ => {}+                    }+                }+                PointerEventKind::Release { button, .. } => {+                    self.pointer_pos = event.position;+                    let code = button_code(*button);+                    if let Some(code) = code+                        && self.try_report_button(code, false)+                    {+                        if self.pressed_button == Some(code) {+                            self.pressed_button = None;+                        }+                        continue;+                    }+                    if *button == BTN_LEFT {+                        self.maybe_open_clicked_link();+                        self.pointer_release(qh);+                    }+                }+                PointerEventKind::Axis { vertical, .. } => {+                    // Wheel notches arrive as value120 (÷120) or legacy discrete+                    // steps, ~3 lines each; touchpads send pixels per cell height.+                    let (raw, scale) = if vertical.value120 != 0 {+                        (f64::from(vertical.value120) / 120.0, 3.0)+                    } else if vertical.discrete != 0 {+                        (f64::from(vertical.discrete), 3.0)+                    } else if cell_h > 0.0 {+                        (vertical.absolute / cell_h, 1.0)+                    } else {+                        continue;+                    };+                    if raw == 0.0 {+                        continue;+                    }+                    let mult = self.config.mouse.scroll_multiplier.max(0.0);+                    let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;+                    let up = raw < 0.0;+                    // Reporting apps get wheel buttons (64 up / 65 down) as+                    // presses, one per line, capped so a flick cannot flood.+                    if self.mouse_reporting() {+                        let code = if up { 64 } else { 65 };+                        for _ in 0..lines.clamp(1, 8) {+                            self.try_report_button(code, true);+                        }+                        continue;+                    }+                    // On the alternate screen there is no scrollback to move, so+                    // (when enabled) translate the wheel into cursor-key presses+                    // for apps that did not request mouse reporting.+                    let alt = self+                        .session+                        .as_ref()+                        .is_some_and(|s| s.term.grid().alt_active());+                    if alt && self.config.mouse.alternate_scroll {+                        self.alternate_scroll(up, lines.clamp(1, 8));+                        continue;+                    }+                    // Positive axis = scroll down (toward live); the viewport+                    // scrolls the opposite way (negative offset delta).+                    let delta = if up { lines } else { -lines };+                    if let Some(session) = self.session.as_mut() {+                        session.term.scroll_view(delta);+                        self.needs_draw = true;+                    }+                }+                _ => {}+            }+        }+    }+}++impl OutputHandler for App {+    fn output_state(&mut self) -> &mut OutputState {+        &mut self.output_state+    }++    fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++    fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++    fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}+}++impl ProvidesRegistryState for App {+    fn registry(&mut self) -> &mut RegistryState {+        &mut self.registry_state+    }+    registry_handlers![OutputState, SeatState];+}++/// Serve the held clipboard text when a paste target requests it.+fn serve(text: &str, fd: WritePipe) {+    let mut file = File::from(OwnedFd::from(fd));+    let _ = file.write_all(text.as_bytes());+}++impl DataDeviceHandler for App {+    fn enter(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &WlDataDevice,+        _: f64,+        _: f64,+        _: &wl_surface::WlSurface,+    ) {+    }+    fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+    fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}+    fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+    fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+}++impl DataOfferHandler for App {+    fn source_actions(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &mut DragOffer,+        _: DndAction,+    ) {+    }+    fn selected_action(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &mut DragOffer,+        _: DndAction,+    ) {+    }+}++impl DataSourceHandler for App {+    fn accept_mime(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &WlDataSource,+        _: Option<String>,+    ) {+    }++    fn send_request(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        source: &WlDataSource,+        _mime: String,+        fd: WritePipe,+    ) {+        if self+            .copy_source+            .as_ref()+            .is_some_and(|s| s.inner() == source)+        {+            serve(&self.clipboard, fd);+        }+    }++    fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {+        if self+            .copy_source+            .as_ref()+            .is_some_and(|s| s.inner() == source)+        {+            self.copy_source = None;+        }+    }++    fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+    fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+    fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}+}++impl PrimarySelectionDeviceHandler for App {+    fn selection(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &ZwpPrimarySelectionDeviceV1,+    ) {+    }+}++impl PrimarySelectionSourceHandler for App {+    fn send_request(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        source: &ZwpPrimarySelectionSourceV1,+        _mime: String,+        fd: WritePipe,+    ) {+        if self+            .primary_source+            .as_ref()+            .is_some_and(|s| s.inner() == source)+        {+            serve(&self.primary_clip, fd);+        }+    }++    fn cancelled(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        source: &ZwpPrimarySelectionSourceV1,+    ) {+        if self+            .primary_source+            .as_ref()+            .is_some_and(|s| s.inner() == source)+        {+            self.primary_source = None;+        }+    }+}++// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by+// hand. Only the fractional-scale object carries an event we act on.+impl Dispatch<WpFractionalScaleV1, ()> for App {+    fn event(+        state: &mut Self,+        _: &WpFractionalScaleV1,+        event: wp_fractional_scale_v1::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+        if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {+            state.set_scale(scale);+        }+    }+}++impl Dispatch<WpFractionalScaleManagerV1, ()> for App {+    fn event(+        _: &mut Self,+        _: &WpFractionalScaleManagerV1,+        _: <WpFractionalScaleManagerV1 as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl Dispatch<WpViewporter, ()> for App {+    fn event(+        _: &mut Self,+        _: &WpViewporter,+        _: <WpViewporter as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl Dispatch<WpViewport, ()> for App {+    fn event(+        _: &mut Self,+        _: &WpViewport,+        _: <WpViewport as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl ActivationHandler for App {+    type RequestData = RequestData;++    fn new_token(&mut self, token: String, _: &RequestData) {+        // The compositor granted an activation token; use it to draw attention.+        if let Some(activation) = self.activation.as_ref() {+            activation.activate::<App>(self.window.wl_surface(), token);+        }+    }+}++impl Dispatch<ZwpTextInputManagerV3, ()> for App {+    fn event(+        _: &mut Self,+        _: &ZwpTextInputManagerV3,+        _: <ZwpTextInputManagerV3 as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the+// accumulated transaction on `done` and re-enable on focus enter.+impl Dispatch<ZwpTextInputV3, ()> for App {+    fn event(+        state: &mut Self,+        ti: &ZwpTextInputV3,+        event: zwp_text_input_v3::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+        use zwp_text_input_v3::Event;+        match event {+            Event::Enter { .. } => {+                ti.enable();+                ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);+                state.ime_set_cursor_rect(ti);+                ti.commit();+            }+            Event::Leave { .. } => {+                ti.disable();+                ti.commit();+                state.preedit.clear();+                state.ime_preedit_pending.clear();+                state.ime_commit_pending.clear();+                state.needs_draw = true;+            }+            Event::PreeditString { text, .. } => {+                state.ime_preedit_pending = text.unwrap_or_default();+            }+            Event::CommitString { text } => {+                state.ime_commit_pending.push_str(&text.unwrap_or_default());+            }+            Event::Done { .. } => state.ime_done(ti),+            // We do not expose surrounding text, so nothing to delete.+            Event::DeleteSurroundingText { .. } => {}+            _ => {}+        }+    }+}++delegate_compositor!(App);+delegate_output!(App);+delegate_shm!(App);+delegate_seat!(App);+delegate_keyboard!(App);+delegate_pointer!(App);+delegate_xdg_shell!(App);+delegate_xdg_window!(App);+delegate_data_device!(App);+delegate_primary_selection!(App);+delegate_registry!(App);+delegate_activation!(App);diff --git a/src/wayland/mod.rs b/src/wayland/mod.rsnew file mode 100644index 0000000..fd7b9dc--- /dev/null+++ b/src/wayland/mod.rs@@ -0,0 +1,1696 @@+//! 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.++mod handlers;+mod rendering;++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,+};++use handlers::{cursor_shape_from, hint_labels};+use rendering::FrameBuf;++/// 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;++/// 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);+        }+        match cmd.spawn() {+            Ok(mut child) => {+                if let Some(mut stdin) = child.stdin.take() {+                    let _ = stdin.write_all(text.as_bytes());+                }+            }+            Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),+        }+    }++    /// Launch another beer process in the shell's reported working directory+    /// (OSC 7), inheriting the same config. The child is fully detached.+    fn spawn_new_window(&mut self) {+        let exe = match std::env::current_exe() {+            Ok(exe) => exe,+            Err(err) => {+                tracing::warn!("locate beer executable: {err}");+                return;+            }+        };+        let mut cmd = std::process::Command::new(exe);+        if let Some(path) = self.config_path.as_ref() {+            cmd.arg("--config").arg(path);+        }+        if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {+            cmd.current_dir(cwd);+        }+        cmd.stdin(std::process::Stdio::null())+            .stdout(std::process::Stdio::null())+            .stderr(std::process::Stdio::null());+        if let Err(err) = cmd.spawn() {+            tracing::warn!("spawn new window: {err}");+        }+    }++    /// Send `count` cursor-up/down keys to the shell for alternate-scroll,+    /// honouring the application cursor-key mode (DECCKM).+    fn alternate_scroll(&mut self, up: bool, count: isize) {+        let app_cursor = self+            .session+            .as_ref()+            .is_some_and(|s| s.term.grid().app_cursor());+        let seq: &[u8] = match (up, app_cursor) {+            (true, false) => b"\x1b[A",+            (true, true) => b"\x1bOA",+            (false, false) => b"\x1b[B",+            (false, true) => b"\x1bOB",+        };+        for _ in 0..count {+            self.write_to_pty(seq);+        }+    }++    /// Scroll the viewport one page back (`up`) or toward the live screen.+    fn scroll_page(&mut self, up: bool) {+        if let Some(session) = self.session.as_mut() {+            let page = session.term.page() as isize;+            session.term.scroll_view(if up { page } else { -page });+            self.needs_draw = true;+        }+    }++    /// Toggle the toplevel between fullscreen and windowed.+    fn toggle_fullscreen(&mut self) {+        self.fullscreen = !self.fullscreen;+        if self.fullscreen {+            self.window.set_fullscreen(None);+        } else {+            self.window.unset_fullscreen();+        }+    }++    /// Re-read the config file and apply it in place (SIGUSR1).+    fn reload_config(&mut self) {+        let new = Config::load(self.config_path.as_deref());+        self.bindings =+            crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings);+        let font_changed = new.main.font != self.config.main.font+            || new.main.font_size != self.config.main.font_size;+        if font_changed {+            self.font_size = new.main.font_size;+        }+        self.config.main.font = new.main.font.clone();+        self.config.main.font_size = new.main.font_size;+        self.config.main.pad_x = new.main.pad_x;+        self.config.main.pad_y = new.main.pad_y;+        // Re-rasterize at the active scale and update padding in one place.+        self.rescale_render();+        if let Some(session) = self.session.as_mut() {+            session+                .term+                .set_theme(crate::theme::Theme::from_config(&new.colors));+            let grid = session.term.grid_mut();+            grid.set_word_delimiters(new.main.word_delimiters.clone());+            grid.set_scrollback_cap(new.scrollback.lines);+            if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {+                grid.set_cursor_shape(shape);+            }+            grid.set_cursor_blink(new.cursor.blink);+        }+        self.config = new;+        self.frames.clear();+        self.resize_grid();+        self.needs_draw = true;+        tracing::info!("config reloaded");+    }++    /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.+    fn change_font_size(&mut self, new_size: u32) {+        let new_size = new_size.clamp(6, 200);+        if new_size == self.font_size {+            return;+        }+        self.font_size = new_size;+        self.rescale_render();+        self.frames.clear();+        self.resize_grid();+        self.needs_draw = true;+    }++    /// Enter or leave incremental search mode.+    fn toggle_search(&mut self) {+        self.searching = !self.searching;+        if let Some(session) = self.session.as_mut() {+            if self.searching {+                session.term.grid_mut().set_search("");+            } else {+                session.term.grid_mut().clear_search();+            }+        }+        self.needs_draw = true;+    }++    /// Handle a key while search mode is active: edit the query incrementally,+    /// step between matches, or exit.+    fn search_key(&mut self, event: &KeyEvent) {+        let Some(session) = self.session.as_mut() else {+            return;+        };+        let grid = session.term.grid_mut();+        match event.keysym {+            Keysym::Escape => {+                grid.clear_search();+                self.searching = false;+            }+            Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {+                grid.search_step(false);+            }+            Keysym::Down | Keysym::Page_Down => grid.search_step(true),+            Keysym::BackSpace => {+                let mut query = grid.search_query().unwrap_or("").to_string();+                query.pop();+                grid.set_search(&query);+            }+            _ => {+                // Append typed text, ignoring control characters.+                if let Some(text) = event.utf8.as_ref() {+                    let printable: String = text.chars().filter(|c| !c.is_control()).collect();+                    if !printable.is_empty() {+                        let mut query = grid.search_query().unwrap_or("").to_string();+                        query.push_str(&printable);+                        grid.set_search(&query);+                    }+                }+            }+        }+        self.needs_draw = true;+    }++    /// The OSC 8 hyperlink id under the pointer, if any.+    fn link_under_pointer(&self) -> Option<NonZeroU16> {+        let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;+        self.session.as_ref()?.term.grid().link_at(row, col)+    }++    /// Recompute the hyperlink under the pointer; when it changes, repaint to+    /// move the hover underline and update the pointer to a hand over a link.+    fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {+        let link = self.link_under_pointer();+        if link == self.hovered_link {+            return;+        }+        self.hovered_link = link;+        // The hover underline lives in every buffer's snapshot; drop the ring so+        // the affected rows repaint with (or without) it.+        self.frames.clear();+        self.needs_draw = true;+        let shape = if link.is_some() {+            Shape::Pointer+        } else {+            Shape::Text+        };+        if let Some(device) = self+            .seats+            .iter()+            .find(|s| s.pointer.as_ref() == Some(pointer))+            .and_then(|s| s.cursor_shape_device.as_ref())+        {+            device.set_shape(self.pointer_enter_serial, shape);+        }+    }++    /// If the left button was pressed and released on the same hyperlinked cell+    /// (a click, not a drag), open the link.+    fn maybe_open_clicked_link(&mut self) {+        let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+        let Some((row, col)) = release else { return };+        if self.press_cell != Some((row, col)) {+            return;+        }+        let uri = self+            .session+            .as_ref()+            .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))+            .and_then(|(s, id)| s.term.grid().link_uri(id))+            .map(str::to_owned);+        if let Some(uri) = uri {+            self.open_url(&uri);+        }+    }++    /// Launch the configured opener (default `xdg-open`) on a URL.+    fn open_url(&self, url: &str) {+        let Some((program, args)) = self.config.url.launch.split_first() else {+            tracing::warn!("open url: no [url] launch command configured");+            return;+        };+        let mut cmd = std::process::Command::new(program);+        cmd.args(args)+            .arg(url)+            .stdin(std::process::Stdio::null())+            .stdout(std::process::Stdio::null())+            .stderr(std::process::Stdio::null());+        if let Err(err) = cmd.spawn() {+            tracing::warn!("open url {url:?}: {err}");+        }+    }++    /// Scale a logical pixel length to physical (buffer) pixels at the current+    /// fractional scale, rounding to nearest.+    fn to_phys(&self, v: u32) -> u32 {+        ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32+    }++    /// Physical (buffer) pixel size at the current scale.+    fn phys_dims(&self) -> (u32, u32) {+        (+            self.to_phys(self.width).max(1),+            self.to_phys(self.height).max(1),+        )+    }++    /// Columns and rows for the current physical size, metrics, and padding.+    /// Scale-invariant: every term scales together so the cell count is stable.+    fn grid_dims(&self) -> (u16, u16) {+        let (pw, ph) = self.phys_dims();+        grid_size(+            self.renderer.metrics(),+            pw,+            ph,+            (+                self.to_phys(self.config.main.pad_x),+                self.to_phys(self.config.main.pad_y),+            ),+        )+    }++    /// Re-rasterize the font and padding at the current scale × the logical+    /// font size, so glyphs are crisp at fractional scales.+    fn rescale_render(&mut self) {+        self.renderer.set_padding(+            self.to_phys(self.config.main.pad_x),+            self.to_phys(self.config.main.pad_y),+        );+        let px = self.to_phys(self.font_size).max(1);+        if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {+            tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);+        }+    }++    /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid+    /// geometry, and update the viewport so the logical size stays put.+    fn set_scale(&mut self, scale120: u32) {+        let scale120 = scale120.max(1);+        if scale120 == self.scale120 {+            return;+        }+        self.scale120 = scale120;+        self.rescale_render();+        self.frames.clear();+        self.buf_dims = (0, 0);+        if let Some(vp) = &self.viewport {+            vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+        }+        self.resize_grid();+        self.needs_draw = true;+    }++    /// Inner padding `(x, y)` in physical pixels (pointer coords are converted+    /// to physical before use).+    fn padding(&self) -> (f64, f64) {+        (+            f64::from(self.to_phys(self.config.main.pad_x)),+            f64::from(self.to_phys(self.config.main.pad_y)),+        )+    }++    /// Convert a logical surface coordinate to a physical buffer coordinate.+    fn to_phys_f(&self, v: f64) -> f64 {+        v * f64::from(self.scale120) / 120.0+    }++    /// The active seat (the one that most recently produced input).+    fn active(&self) -> Option<&SeatData> {+        self.seats.get(self.active_seat)+    }++    /// The active seat's clipboard device, if any.+    fn data_device(&self) -> Option<&DataDevice> {+        self.active().and_then(|s| s.data_device.as_ref())+    }++    /// The active seat's primary-selection device, if any.+    fn primary_device(&self) -> Option<&PrimarySelectionDevice> {+        self.active().and_then(|s| s.primary_device.as_ref())+    }++    /// Find (or create) the per-seat entry for `seat`, returning its index.+    fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {+        if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {+            return i;+        }+        self.seats.push(SeatData {+            seat: seat.clone(),+            keyboard: None,+            pointer: None,+            cursor_shape_device: None,+            data_device: None,+            primary_device: None,+            text_input: None,+        });+        self.seats.len() - 1+    }++    /// Mark the seat owning `keyboard` as active for clipboard ownership.+    fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {+        if let Some(i) = self+            .seats+            .iter()+            .position(|s| s.keyboard.as_ref() == Some(keyboard))+        {+            self.active_seat = i;+        }+    }++    /// Mark the seat owning `pointer` as active for clipboard ownership.+    fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {+        if let Some(i) = self+            .seats+            .iter()+            .position(|s| s.pointer.as_ref() == Some(pointer))+        {+            self.active_seat = i;+        }+    }++    /// Map window pixel coordinates to an absolute `(row, col)` grid point.+    fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {+        let session = self.session.as_ref()?;+        let m = self.renderer.metrics();+        let (pad_x, pad_y) = self.padding();+        let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));+        let grid = session.term.grid();+        let col =+            ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+        let vrow =+            ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));+        Some((grid.view_to_abs(vrow), col))+    }++    /// Left-button press: start (or word/line-extend) a selection.+    fn pointer_press(&mut self, time: u32) {+        let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+            return;+        };+        let count = match self.last_click {+            Some((t, r, c, n))+                if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>+            {+                n % 3 + 1+            }+            _ => 1,+        };+        self.last_click = Some((time, row, col, count));+        let Some(session) = self.session.as_mut() else {+            return;+        };+        let ctrl = self.modifiers.ctrl;+        let grid = session.term.grid_mut();+        match count {+            2 => grid.select_word(row, col),+            3 => grid.select_line(row),+            // Holding Ctrl starts a rectangular (block) selection.+            _ if ctrl => grid.start_block_selection(row, col),+            _ => grid.start_selection(row, col),+        }+        self.selecting = true;+        self.needs_draw = true;+    }++    /// Pointer motion during a drag: extend the selection head and, if the+    /// pointer has left the top/bottom edge, start autoscrolling.+    fn pointer_drag(&mut self) {+        if !self.selecting {+            return;+        }+        let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+            return;+        };+        if let Some(session) = self.session.as_mut() {+            session.term.grid_mut().extend_selection(row, col);+            self.needs_draw = true;+        }+        self.update_autoscroll();+    }++    /// Arm or disarm edge autoscroll based on the pointer's vertical position.+    fn update_autoscroll(&mut self) {+        let dir = if self.pointer_pos.1 < 0.0 {+            1 // above the top: reveal older lines+        } else if self.pointer_pos.1 >= f64::from(self.height) {+            -1 // below the bottom: advance toward the live screen+        } else {+            0+        };+        self.autoscroll = dir;+        if dir != 0 && self.autoscroll_timer.is_none() {+            let timer = Timer::immediate();+            self.autoscroll_timer = self+                .loop_handle+                .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())+                .ok();+        }+    }++    /// One autoscroll step: scroll the viewport and drag the selection head to+    /// the edge cell under the pointer. Reschedules until the drag ends or the+    /// pointer returns inside the window.+    fn autoscroll_step(&mut self) -> TimeoutAction {+        if !self.selecting || self.autoscroll == 0 {+            self.autoscroll_timer = None;+            return TimeoutAction::Drop;+        }+        if let Some(session) = self.session.as_mut() {+            session.term.scroll_view(self.autoscroll);+        }+        let edge_y = if self.autoscroll > 0 {+            0.0+        } else {+            f64::from(self.height) - 1.0+        };+        if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)+            && let Some(session) = self.session.as_mut()+        {+            session.term.grid_mut().extend_selection(row, col);+        }+        self.needs_draw = true;+        TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))+    }++    /// Left-button release: stop autoscrolling and publish the primary selection.+    fn pointer_release(&mut self, qh: &QueueHandle<App>) {+        if !self.selecting {+            return;+        }+        self.selecting = false;+        self.autoscroll = 0;+        if let Some(token) = self.autoscroll_timer.take() {+            self.loop_handle.remove(token);+        }+        self.set_primary(qh);+    }++    /// Whether the application wants mouse reports and the user is not holding+    /// Shift (which forces local selection regardless of mode).+    fn mouse_reporting(&self) -> bool {+        self.session+            .as_ref()+            .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)+            && !self.modifiers.shift+    }++    /// The viewport cell `(col, row)` under the pointer, clamped to the screen.+    fn report_screen_cell(&self) -> Option<(usize, usize)> {+        let session = self.session.as_ref()?;+        let m = self.renderer.metrics();+        let (pad_x, pad_y) = self.padding();+        let (ppx, ppy) = (+            self.to_phys_f(self.pointer_pos.0),+            self.to_phys_f(self.pointer_pos.1),+        );+        let grid = session.term.grid();+        let col =+            ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+        let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)+            .min(grid.rows().saturating_sub(1));+        Some((col, row))+    }++    /// Report a button press/release to the application, if reporting is active.+    /// Returns whether the event was consumed (so local handling is skipped).+    fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {+        let Some(session) = self.session.as_ref() else {+            return false;+        };+        let grid = session.term.grid();+        let proto = grid.mouse_protocol();+        if proto == MouseProtocol::Off || self.modifiers.shift {+            return false;+        }+        let enc = grid.mouse_encoding();+        // X10 (mode 9) reports presses only; a release is swallowed, not sent.+        if (pressed || proto != MouseProtocol::X10)+            && let Some((col, row)) = self.report_screen_cell()+        {+            let bytes =+                crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);+            self.write_to_pty(&bytes);+            self.last_report_cell = Some((col, row));+        }+        true+    }++    /// Report pointer motion to the application when the active mode wants it.+    /// Returns whether reporting consumed the motion (suppressing local drag).+    fn try_report_motion(&mut self) -> bool {+        let Some(session) = self.session.as_ref() else {+            return false;+        };+        let grid = session.term.grid();+        let proto = grid.mouse_protocol();+        if proto == MouseProtocol::Off || self.modifiers.shift {+            return false;+        }+        let enc = grid.mouse_encoding();+        let wants = match proto {+            MouseProtocol::Any => true,+            MouseProtocol::Button => self.pressed_button.is_some(),+            _ => false,+        };+        if wants+            && let Some((col, row)) = self.report_screen_cell()+            && self.last_report_cell != Some((col, row))+        {+            // Any-event motion with no button held uses the "no button" code 3.+            let code = self.pressed_button.unwrap_or(3);+            let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers);+            self.write_to_pty(&bytes);+            self.last_report_cell = Some((col, row));+        }+        true+    }++    /// Send focus in/out (DECSET 1004) to the application when it asked for it.+    fn report_focus(&mut self, focused: bool) {+        if self+            .session+            .as_ref()+            .is_some_and(|s| s.term.grid().focus_events())+        {+            self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });+        }+    }++    /// Write bytes to the PTY master, logging on failure.+    fn write_to_pty(&mut self, bytes: &[u8]) {+        if let Some(session) = self.session.as_mut()+            && let Err(err) = write_all(session.pty.master(), bytes)+        {+            tracing::warn!("write to pty: {err}");+        }+    }++    /// The current selection text, if any and non-empty.+    fn selection_text(&self) -> Option<String> {+        let text = self.session.as_ref()?.term.grid().selection_text()?;+        (!text.is_empty()).then_some(text)+    }++    /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.+    fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {+        let Some(device) = self.data_device() else {+            return;+        };+        let source = self+            .data_device_manager+            .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());+        source.set_selection(device, self.serial);+        self.clipboard = text;+        self.copy_source = Some(source);+    }++    /// Take ownership of the primary selection, serving `text` to pasters.+    fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {+        let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())+        else {+            return;+        };+        let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());+        source.set_selection(device, self.serial);+        self.primary_clip = text;+        self.primary_source = Some(source);+    }++    /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).+    fn set_clipboard(&mut self, qh: &QueueHandle<App>) {+        if let Some(text) = self.selection_text() {+            self.claim_clipboard(text, qh);+        }+    }++    /// Claim the primary selection with the current selection (select-to-copy).+    fn set_primary(&mut self, qh: &QueueHandle<App>) {+        if let Some(text) = self.selection_text() {+            self.claim_primary(text, qh);+        }+    }++    /// Point the IME's candidate popup at the terminal cursor, in logical+    /// surface coordinates (the renderer works in physical pixels, so divide+    /// the physical cell rectangle back down by the scale).+    fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {+        let Some(session) = self.session.as_ref() else {+            return;+        };+        let (cx, cy) = session.term.grid().cursor();+        let m = self.renderer.metrics();+        let s = f64::from(self.scale120) / 120.0;+        let pad_x = f64::from(self.to_phys(self.config.main.pad_x));+        let pad_y = f64::from(self.to_phys(self.config.main.pad_y));+        let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;+        let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;+        let w = (f64::from(m.width) / s) as i32;+        let h = (f64::from(m.height) / s) as i32;+        ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));+    }++    /// Apply one IME transaction: commit any committed text to the shell, adopt+    /// the new preedit, then re-commit our state (cursor rectangle) to the IME.+    fn ime_done(&mut self, ti: &ZwpTextInputV3) {+        let commit = std::mem::take(&mut self.ime_commit_pending);+        // Preedit is replaced wholesale each cycle; an absent preedit clears it.+        self.preedit = std::mem::take(&mut self.ime_preedit_pending);+        if !commit.is_empty() {+            self.send_to_shell(commit.as_bytes());+        }+        self.ime_set_cursor_rect(ti);+        ti.commit();+        self.needs_draw = true;+    }++    /// Act on the OSC 52 clipboard requests an application made: take ownership+    /// of the selection it set, or answer a query with what we currently hold.+    fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {+        use crate::vt::ClipboardOp;+        let qh = self.qh.clone();+        for op in ops {+            match op {+                ClipboardOp::Set {+                    primary: true,+                    text,+                } => self.claim_primary(text, &qh),+                ClipboardOp::Set {+                    primary: false,+                    text,+                } => self.claim_clipboard(text, &qh),+                ClipboardOp::Query { primary } => {+                    let text = if primary {+                        &self.primary_clip+                    } else {+                        &self.clipboard+                    };+                    let kind = if primary { 'p' } else { 'c' };+                    let reply = format!(+                        "\x1b]52;{kind};{}\x07",+                        crate::vt::base64_encode(text.as_bytes())+                    );+                    self.write_to_pty(reply.as_bytes());+                }+            }+        }+    }++    /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).+    fn paste_clipboard(&mut self) {+        let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {+            return;+        };+        let Some(mime) = offer.with_mime_types(pick_mime) else {+            return;+        };+        if let Ok(pipe) = offer.receive(mime) {+            self.read_paste(pipe);+        }+    }++    /// Paste the primary selection into the shell (middle click).+    fn paste_primary(&mut self) {+        let Some(offer) = self+            .primary_device()+            .and_then(|d| d.data().selection_offer())+        else {+            return;+        };+        let Some(mime) = offer.with_mime_types(pick_mime) else {+            return;+        };+        if let Ok(pipe) = offer.receive(mime) {+            self.read_paste(pipe);+        }+    }++    /// Drain a clipboard read-pipe on the event loop, writing the bytes to the+    /// PTY once the source closes its end.+    fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {+        let mut data: Vec<u8> = Vec::new();+        let registered = self+            .loop_handle+            .insert_source(pipe, move |_, file, app: &mut App| {+                // SAFETY: the file is owned by the source and not closed while read.+                let f: &mut File = unsafe { file.get_mut() };+                let mut tmp = [0u8; 4096];+                match f.read(&mut tmp) {+                    Ok(0) => {+                        app.paste_bytes(&data);+                        PostAction::Remove+                    }+                    Ok(n) => {+                        data.extend_from_slice(&tmp[..n]);+                        PostAction::Continue+                    }+                    Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {+                        PostAction::Continue+                    }+                    Err(e) => {+                        tracing::warn!("read paste pipe: {e}");+                        PostAction::Remove+                    }+                }+            });+        if let Err(err) = registered {+            tracing::warn!("register paste pipe: {err}");+        }+    }++    /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and+    /// snapping the viewport to the live screen.+    fn paste_bytes(&mut self, data: &[u8]) {+        let Some(session) = self.session.as_mut() else {+            return;+        };+        session.term.scroll_to_bottom();+        self.needs_draw = true;+        let bracketed = session.term.grid().bracketed_paste();+        // Strip control bytes a terminal must never receive raw from a paste;+        // keep tab and newlines (CR is what the shell expects for Enter).+        let mut clean: Vec<u8> = Vec::with_capacity(data.len());+        for &b in data {+            match b {+                b'\n' => clean.push(b'\r'),+                b'\t' | b'\r' => clean.push(b),+                0x20..=0xff => clean.push(b),+                _ => {}+            }+        }+        let fd = session.pty.master();+        if bracketed {+            let _ = write_all(fd, b"\x1b[200~");+            let _ = write_all(fd, &clean);+            let _ = write_all(fd, b"\x1b[201~");+        } else if let Err(err) = write_all(fd, &clean) {+            tracing::warn!("write paste to pty: {err}");+        }+    }++    /// After parsing child output: send any replies, sync the title, repaint.+    fn after_feed(&mut self) {+        let Some(session) = self.session.as_mut() else {+            return;+        };+        let reply = session.term.take_response();+        if !reply.is_empty()+            && let Err(err) = write_all(session.pty.master(), &reply)+        {+            tracing::warn!("write to pty: {err}");+        }++        let new_title = session.term.title().map(str::to_owned);+        if new_title.as_deref() != self.title.as_deref() {+            self.title = new_title;+            self.window+                .set_title(self.title.clone().unwrap_or_default());+        }+        let rang = session.term.take_bell();+        let ops = session.term.take_clipboard_ops();+        let notifications = session.term.take_notifications();+        if !ops.is_empty() {+            self.handle_clipboard_ops(ops);+        }+        for note in notifications {+            self.send_notification(&note);+        }+        if rang {+            self.ring_bell();+        }+        self.needs_draw = true;+    }++    /// React to a `BEL`: optionally flash, run the configured bell command, and+    /// request the compositor's attention when unfocused.+    fn ring_bell(&mut self) {+        if self.config.bell.visual {+            self.start_flash();+        }+        if let Some((program, args)) = self.config.bell.command.split_first() {+            let _ = std::process::Command::new(program)+                .args(args)+                .stdin(std::process::Stdio::null())+                .stdout(std::process::Stdio::null())+                .stderr(std::process::Stdio::null())+                .spawn()+                .inspect_err(|err| tracing::warn!("bell command: {err}"));+        }+        if self.config.bell.urgent && !self.focused {+            self.request_attention();+        }+    }++    /// Deliver a desktop notification through the configured notifier (default+    /// `notify-send`), appending the title and body as the final arguments.+    fn send_notification(&self, note: &crate::vt::Notification) {+        let Some((program, args)) = self.config.notify.command.split_first() else {+            return;+        };+        let title = note+            .title+            .clone()+            .or_else(|| self.title.clone())+            .unwrap_or_else(|| "beer".to_string());+        let _ = std::process::Command::new(program)+            .args(args)+            .arg(title)+            .arg(&note.body)+            .stdin(std::process::Stdio::null())+            .stdout(std::process::Stdio::null())+            .stderr(std::process::Stdio::null())+            .spawn()+            .inspect_err(|err| tracing::warn!("notify command: {err}"));+    }++    /// Ask the compositor to draw attention to the window (xdg-activation).+    fn request_attention(&mut self) {+        let Some(activation) = self.activation.as_ref() else {+            return;+        };+        let seat_and_serial = self+            .seats+            .get(self.active_seat)+            .map(|s| (s.seat.clone(), self.serial));+        let data = smithay_client_toolkit::activation::RequestData {+            app_id: Some("dev.notashelf.beer".to_string()),+            seat_and_serial,+            surface: Some(self.window.wl_surface().clone()),+        };+        activation.request_token::<App>(&self.qh, data);+    }++    /// Begin a visual-bell flash: invert the screen for a moment. Clearing the+    /// buffer ring forces a full repaint with the inverted theme.+    fn start_flash(&mut self) {+        self.flashing = true;+        self.frames.clear();+        self.needs_draw = true;+        if self.flash_timer.is_none() {+            let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));+            self.flash_timer = self+                .loop_handle+                .insert_source(timer, |_, _, app: &mut App| {+                    app.flashing = false;+                    app.frames.clear();+                    app.needs_draw = true;+                    app.flash_timer = None;+                    TimeoutAction::Drop+                })+                .ok();+        }+    }++    /// Recompute the grid size for the current window and tell the grid and the+    /// PTY about it if it changed.+    fn resize_grid(&mut self) {+        let (cols, rows) = self.grid_dims();+        let Some(session) = self.session.as_mut() else {+            return;+        };+        if (cols as usize, rows as usize)+            == (session.term.grid().cols(), session.term.grid().rows())+        {+            return;+        }+        session.term.resize(cols as usize, rows as usize);+        if let Err(err) = session.pty.resize(cols, rows) {+            tracing::warn!("resize pty: {err}");+        }+    }++    /// The child shell has gone away; reap it, capture its code, and tear the+    /// window down.+    fn child_exited(&mut self) {+        if let Some(session) = self.session.as_mut() {+            match session.pty.wait() {+                Ok(status) => {+                    tracing::info!("shell exited: {status}");+                    // Mirror the shell's status: its code, or 128+signal if killed.+                    let code = status+                        .code()+                        .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));+                    self.exit_code = ExitCode::from(code as u8);+                }+                Err(err) => tracing::warn!("reap shell: {err}"),+            }+        }+        self.exit = true;+    }++    /// Present a frame if one is wanted and the compositor is ready for it.+    /// Called after every event-loop wake; the frame-callback gate keeps draws+    /// paced to the display instead of one per PTY read. While the app holds+    /// synchronized output (DECSET 2026) we withhold the frame, but arm a+    /// timeout so a stuck `2026h` cannot freeze the window.+    fn flush(&mut self) {+        let sync = self+            .session+            .as_ref()+            .is_some_and(|s| s.term.grid().sync_active());+        if sync {+            if self.sync_timeout.is_none() {+                let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));+                self.sync_timeout = self+                    .loop_handle+                    .insert_source(timer, |_, _, app: &mut App| {+                        if let Some(session) = app.session.as_mut() {+                            session.term.grid_mut().set_sync(false);+                        }+                        app.sync_timeout = None;+                        app.needs_draw = true;+                        TimeoutAction::Drop+                    })+                    .ok();+            }+            return;+        }+        if let Some(token) = self.sync_timeout.take() {+            self.loop_handle.remove(token);+        }+        if self.needs_draw && !self.frame_pending && self.session.is_some() {+            self.present();+        }+    }+}diff --git a/src/wayland/rendering.rs b/src/wayland/rendering.rsnew file mode 100644index 0000000..04c3ac4--- /dev/null+++ b/src/wayland/rendering.rs@@ -0,0 +1,227 @@+use super::*;++/// 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)]+pub(super) 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 },+    }+}+impl App {+    /// Render only the rows that changed since the chosen buffer last displayed+    /// them, damage just those rows, and commit with a frame-callback request.+    pub(super) fn present(&mut self) {+        self.needs_draw = false;+        // URL hint labels overlay the grid but are not part of the row snapshot,+        // so force a full redraw while the labels are showing.+        if self.url_mode {+            self.frames.clear();+        }+        // Render into a buffer sized in physical pixels (logical × scale); the+        // viewport presents it back at the logical surface size.+        let (w, h) = self.phys_dims();+        let m = self.renderer.metrics();+        let (focused, blink_on) = (self.focused, self.blink_on);++        // A resize invalidates every buffer's contents and size.+        if self.buf_dims != (w, h) {+            self.frames.clear();+            self.buf_dims = (w, h);+        }++        let Some(session) = self.session.as_ref() else {+            return;+        };+        let grid = session.term.grid();+        // The visual bell inverts fg/bg for the duration of the flash.+        let flashed = self.flashing.then(|| session.term.theme().inverted());+        let theme = flashed.as_ref().unwrap_or(session.term.theme());+        let rows = grid.rows();+        let mut cur: Vec<RowSnap> = (0..rows)+            .map(|y| row_snap(grid, y, focused, blink_on))+            .collect();++        // The search prompt occupies the bottom row while search mode is active.+        // Recording it in the snapshot keeps the row's damage/diff correct.+        let bar_text = self.searching.then(|| {+            let (n, total) = grid.search_count();+            format!(+                "search: {}  [{n}/{total}]",+                grid.search_query().unwrap_or("")+            )+        });+        if let Some(text) = &bar_text+            && rows > 0+        {+            cur[rows - 1].overlay = Some(text.clone());+        }++        // The IME preedit is drawn inline at the cursor while composing.+        if !self.preedit.is_empty() && grid.view_at_bottom() {+            let (cx, cy) = grid.cursor();+            if cy < rows {+                cur[cy].preedit = Some((cx, self.preedit.clone()));+            }+        }++        // Reuse a buffer the compositor has released, else grow the ring.+        let stride = w as i32 * 4;+        let mut idx = None;+        for i in 0..self.frames.len() {+            if self.pool.canvas(&self.frames[i].buffer).is_some() {+                idx = Some(i);+                break;+            }+        }+        let idx = match idx {+            Some(i) => i,+            None if self.frames.len() < MAX_BUFFERS => {+                match self+                    .pool+                    .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)+                {+                    Ok((buffer, _)) => {+                        self.frames.push(FrameBuf {+                            buffer,+                            rows: Vec::new(),+                        });+                        self.frames.len() - 1+                    }+                    Err(err) => {+                        tracing::error!("allocate shm buffer: {err}");+                        return;+                    }+                }+            }+            // All buffers are still held by the compositor; a release event will+            // wake us and `needs_draw` (re-set below) retries then.+            None => {+                self.needs_draw = true;+                return;+            }+        };++        // Rows that differ from what this buffer last showed (all, if fresh).+        let prev = &self.frames[idx].rows;+        let dirty: Vec<usize> = (0..rows)+            .filter(|&y| prev.get(y) != Some(&cur[y]))+            .collect();+        if dirty.is_empty() {+            return;+        }++        // A buffer used for the first time has uninitialized margins; paint the+        // whole thing (background + padding) once, then damage it in full below.+        let fresh = self.frames[idx].rows.is_empty();+        let pad_y = self.to_phys(self.config.main.pad_y) as i32;+        let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {+            return;+        };+        let dims = (w as usize, h as usize);+        let frame = crate::render::Frame {+            theme,+            focused,+            blink_on,+            hovered_link: self.hovered_link,+        };+        if fresh {+            self.renderer.clear(canvas, dims, theme);+        }+        for &y in &dirty {+            self.renderer.render_row(canvas, dims, grid, &frame, y);+        }+        // Draw the search prompt over the (now repainted) bottom row.+        if let Some(text) = &bar_text+            && dirty.contains(&(rows - 1))+        {+            self.renderer+                .render_search_bar(canvas, dims, theme, rows - 1, text);+        }+        // Draw the IME preedit inline over its (repainted) cursor row.+        for &y in &dirty {+            if let Some((col, text)) = &cur[y].preedit {+                self.renderer+                    .render_preedit(canvas, dims, theme, y, *col, text);+            }+        }+        // Draw URL hint labels on top, narrowing to those matching the input.+        if self.url_mode {+            for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {+                if label.starts_with(&self.url_input) {+                    self.renderer+                        .render_label(canvas, dims, theme, hit.row, hit.col, label);+                }+            }+        }+        self.frames[idx].rows = cur;++        let surface = self.window.wl_surface();+        if let Err(err) = self.frames[idx].buffer.attach_to(surface) {+            tracing::error!("attach buffer: {err}");+            return;+        }+        // With a viewport the buffer is presented at the logical destination, so+        // its own scale stays 1; without one, fall back to integer buffer scale.+        if let Some(vp) = &self.viewport {+            surface.set_buffer_scale(1);+            vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+        } else {+            surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);+        }+        if fresh {+            surface.damage_buffer(0, 0, w as i32, h as i32);+        } else {+            for &y in &dirty {+                let top = pad_y + y as i32 * m.height as i32;+                surface.damage_buffer(0, top, w as i32, m.height as i32);+            }+        }+        surface.frame(&self.qh, surface.clone());+        self.window.commit();+        self.frame_pending = true;+    }+}

show truncated diff