brewery
notashelf /
1f1451f108c01b9f5fd26f21c870712d55a146fb

beer

public

A terminal worth pouring time into

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

Commit 1f1451f108c0

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 10:37 UTC

unverified47 files changed+9926-9500
meta: split protocol codecs and encoders into a beer-protocols crate

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib7706308c892e43d2044fbb766505e9e6a6a6964
diff --git a/Cargo.lock b/Cargo.lockindex 1802465..c04ac89 100644--- a/Cargo.lock+++ b/Cargo.lock@@ -31,9 +31,10 @@ checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"  [[package]] name = "beer"-version = "0.3.1"+version = "0.4.0" dependencies = [  "anyhow",+ "beer-protocols",  "calloop",  "calloop-wayland-source",  "fontconfig",@@ -56,6 +57,13 @@ dependencies = [ ]  [[package]]+name = "beer-protocols"+version = "0.4.0"+dependencies = [+ "smithay-client-toolkit",+]++[[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index"diff --git a/Cargo.toml b/Cargo.tomlindex b4f961f..4e4ff30 100644--- a/Cargo.toml+++ b/Cargo.toml@@ -1,49 +1,18 @@-[package]-name = "beer"-version = "0.3.1"+[workspace]+resolver = "3"+members = ["crates/beer", "crates/beer-protocols"]++[workspace.package]+version = "0.4.0" edition = "2024" rust-version = "1.95.0"-description = "A fast, software-rendered, Wayland-native terminal emulator" license = "EUPL-1.2"-readme = true--[dependencies]-anyhow = "1.0.102"-calloop = { version = "0.14.4", features = ["signals"] }-calloop-wayland-source = "0.4.1"-fontconfig = "0.11.0"-freetype-rs = "0.38.0"-harfbuzz_rs_now = "2.3.2"-lru = "0.18.0"-pound = "0.1.6"-rustix = { version = "1.1.4", features = [-  "pty",-  "process",-  "termios",-  "stdio",-  "fs",-] }-serde = { version = "1.0.228", features = ["derive"] }-serde_ignored = "0.1.14"-smithay-client-toolkit = "0.20.0"-thiserror = "2.0.18"-toml = "1.1.2"-tracing = "0.1.44"-tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }-unicode-width = "0.2.2"-vte = "0.15.0"-wayland-client = "0.31.14"-wayland-protocols = { version = "0.32.13", features = [-  "client",-  "staging",-  "unstable",-] } -[lints.rust]+[workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" missing_debug_implementations = "warn" -[lints.clippy]+[workspace.lints.clippy] all = { level = "warn", priority = -1 }  [profile.release]diff --git a/crates/beer-protocols/Cargo.toml b/crates/beer-protocols/Cargo.tomlnew file mode 100644index 0000000..39c2da0--- /dev/null+++ b/crates/beer-protocols/Cargo.toml@@ -0,0 +1,17 @@+[package]+name = "beer-protocols"+version.workspace = true+edition.workspace = true+rust-version.workspace = true+license.workspace = true+description = "Terminal protocol codecs and key/mouse wire encoders used by beer"+readme = "README.md"++[dependencies]+# Provides the keyboard vocabulary (Keysym, Modifiers, KeyEvent) the key+# encoder consumes; the version is kept in lockstep with beer so the types+# unify to a single definition across the workspace.+smithay-client-toolkit = "0.20.0"++[lints]+workspace = truediff --git a/crates/beer-protocols/README.md b/crates/beer-protocols/README.mdnew file mode 100644index 0000000..230cff4--- /dev/null+++ b/crates/beer-protocols/README.md@@ -0,0 +1,212 @@+# beer-protocols++The terminal-protocol building blocks beer is built on: byte codecs, the+terminfo capability table, character-set translation, SGR parsing, and the+keyboard/mouse wire encoders. Everything here is a pure function or a plain+data type, with no terminal state, so each protocol detail can be read and+tested on its own. beer feeds these into its `vte`-driven dispatcher and its+grid model.++This README doubles as the reference for **which escape sequences, OSC+commands, and POSIX behaviours beer actually implements** - the parts a user+notices. Sequences not listed here are parsed and ignored rather than passed+through. Notation: `ESC` is `0x1b`, `CSI` is `ESC [`, `OSC` is `ESC ]`, `DCS`+is `ESC P`, `ST` is the string terminator `ESC \` (a `BEL` is also accepted to+end an OSC). `Ps` is a numeric parameter, `Pt` a text parameter.++## Crate layout++| Module | What it covers |+| --- | --- |+| `codec` | base64 (OSC 52), hex (XTGETTCAP names), `file://` URI percent-decoding (OSC 7) |+| `caps` | terminfo capabilities answered over XTGETTCAP |+| `charset` | G0/G1 designation and DEC special-graphics line drawing |+| `sgr` | the multi-parameter SGR colour and underline forms |+| `key` | legacy xterm/VT and kitty keyboard-protocol key encoding |+| `mouse` | X10 / UTF-8 / SGR mouse-report encoding |+| `style` | the enums an SGR/DECSET stream selects |++---++## C0 control characters++| Byte | Name | Effect |+| --- | --- | --- |+| `0x07` | BEL | rings the bell (visual flash / command / urgency, per config) |+| `0x08` | BS | backspace |+| `0x09` | HT | tab to next stop |+| `0x0A`-`0x0C` | LF/VT/FF | line feed |+| `0x0D` | CR | carriage return |+| `0x0E` | SO | invoke G1 (shift out) |+| `0x0F` | SI | invoke G0 (shift in) |++## Cursor movement and editing (CSI)++| Sequence | Name | Effect |+| --- | --- | --- |+| `CSI Ps A` | CUU | cursor up |+| `CSI Ps B` / `CSI Ps e` | CUD | cursor down |+| `CSI Ps C` / `CSI Ps a` | CUF | cursor forward |+| `CSI Ps D` | CUB | cursor back |+| `CSI Ps E` | CNL | cursor down, to column 1 |+| `CSI Ps F` | CPL | cursor up, to column 1 |+| `CSI Ps G` / `CSI Ps \`` | CHA | move to column |+| `CSI Ps d` | VPA | move to row |+| `CSI Ps ; Ps H` / `f` | CUP/HVP | move to row;col |+| `CSI Ps J` | ED | erase in display (0 below, 1 above, 2 all) |+| `CSI Ps K` | EL | erase in line (0 right, 1 left, 2 all) |+| `CSI Ps @` | ICH | insert blank characters |+| `CSI Ps P` | DCH | delete characters |+| `CSI Ps L` | IL | insert lines |+| `CSI Ps M` | DL | delete lines |+| `CSI Ps X` | ECH | erase characters |+| `CSI Ps S` | SU | scroll up |+| `CSI Ps T` | SD | scroll down |+| `CSI Ps ; Ps r` | DECSTBM | set scroll region (top;bottom) |+| `CSI Ps g` | TBC | clear tab stop (0) or all tabs (3) |+| `CSI s` / `CSI u` | SCOSC/SCORC | save / restore cursor |++## ESC (non-CSI)++| Sequence | Name | Effect |+| --- | --- | --- |+| `ESC D` | IND | line feed |+| `ESC M` | RI | reverse index |+| `ESC E` | NEL | next line |+| `ESC 7` / `ESC 8` | DECSC/DECRC | save / restore cursor |+| `ESC H` | HTS | set tab stop |+| `ESC c` | RIS | full reset |+| `ESC ( c` / `ESC ) c` | SCS | designate G0 / G1 charset (`0` = DEC special graphics, `B` = ASCII) |++## Graphic rendition (SGR, `CSI Ps m`)++Bold (1), dim (2), italic (3), underline (4), blink (5/6), reverse (7), hidden+(8), strike (9), and their resets (22-29). Overline (53) and its reset (55).+Underline styles via `4:x` and SGR 21: single, double, curly, dotted, dashed.++Colours: the 16 ANSI colours (30-37, 90-97 foreground; 40-47, 100-107+background) and default (39/49). Extended colour for foreground (38),+background (48), and underline (58/59), in both the legacy semicolon form+(`38;5;n`, `38;2;r;g;b`) and the colon-subparameter form (`38:5:n`,+`38:2:r:g:b`, with an ignored colour-space id). 256-colour and 24-bit truecolor+are fully supported.++## Modes (DECSET/DECRST `CSI [?] Ps h` / `l`)++| Mode | Name | Effect |+| --- | --- | --- |+| `4` (ANSI) | IRM | insert/replace |+| `?1` | DECCKM | application cursor keys |+| `?6` | DECOM | origin mode |+| `?7` | DECAWM | autowrap |+| `?25` | DECTCEM | cursor visibility |+| `?9` | - | X10 mouse reporting |+| `?1000` | - | normal mouse (press/release) |+| `?1002` | - | button-event mouse (drag) |+| `?1003` | - | any-event mouse (all motion) |+| `?1004` | - | focus in/out reporting |+| `?1005` | - | UTF-8 mouse coordinates |+| `?1006` | - | SGR mouse coordinates |+| `?47` / `?1047` | - | alternate screen |+| `?1049` | - | alternate screen + save/restore cursor |+| `?2004` | - | bracketed paste |+| `?2026` | - | synchronized output |++Mode state is reportable with **DECRQM** (`CSI [?] Ps $ p`), which replies+`CSI [?] Ps ; state $ y` (1 set, 2 reset, 0 unrecognized).++## Cursor style (DECSCUSR, `CSI Ps SP q`)++`0`/`1` blinking block, `2` steady block, `3` blinking underline, `4` steady+underline, `5` blinking bar, `6` steady bar. The configured default applies+until an application overrides it.++## Device reports++| Sequence | Name | Reply |+| --- | --- | --- |+| `CSI c` | DA1 | `CSI ?62;22c` (VT220 + ANSI colour) |+| `CSI > c` | DA2 | `CSI >0;276;0c` |+| `CSI = c` | DA3 | `DCS !\|00000000 ST` |+| `CSI 5 n` | DSR | `CSI 0n` (terminal OK) |+| `CSI 6 n` | CPR | `CSI row;col R` (cursor position) |+| `CSI > q` | XTVERSION | `DCS >\|beer(version) ST` |+| `DCS + q <names> ST` | XTGETTCAP | per name, `DCS 1 + r name=value ST` or `DCS 0 + r name ST` |++XTGETTCAP answers `TN` (terminal name `beer`), `Co`/`colors` (256), and `RGB`+(`8/8/8`, i.e. truecolor).++## Window title++`OSC 0 ; Pt` and `OSC 2 ; Pt` set the title. The title stack (`CSI 22 t` push,+`CSI 23 t` pop) is supported, so full-screen programs can save and restore it.++## OSC commands++| Command | Effect |+| --- | --- |+| `OSC 0` / `OSC 2` | set window title |+| `OSC 4 ; idx ; spec` | set / query palette entry (`?` queries) |+| `OSC 104 [; idx ...]` | reset palette (all, or listed entries) |+| `OSC 10` / `OSC 11` | set / query default foreground / background |+| `OSC 110` / `OSC 111` | reset foreground / background |+| `OSC 12` / `OSC 112` | set / reset cursor colour |+| `OSC 17` / `OSC 19` | set / query selection background / foreground |+| `OSC 7 ; file://host/path` | report working directory (used for new windows) |+| `OSC 8 ; params ; URI` | hyperlink (empty URI ends it) |+| `OSC 9 ; Pt` | desktop notification (iTerm2 style) |+| `OSC 777 ; notify ; title ; body` | desktop notification (rxvt style) |+| `OSC 99 ; metadata ; body` | desktop notification (kitty style, single-chunk) |+| `OSC 52 ; target ; data` | clipboard set (base64) or query (`?`) |+| `OSC 133 ; A/B/C/D` | shell-integration prompt marks |++Colour specs follow the X11 forms `#rrggbb` and `rgb:rr/gg/bb` (1-4 hex digits+per channel). Colour queries reply in the `rgb:rrrr/gggg/bbbb` form.++### Clipboard (OSC 52)++`OSC 52 ; c ; <base64>` sets the clipboard, `; p ;` the primary selection;+`; c ; ?` queries. For privacy, a query is answered from the text beer itself+last placed there, **not** the live system clipboard - so a remote program+cannot read what another application copied.++### Shell integration (OSC 133 / OSC 7)++Prompt marks `A` (prompt start), `B` (command start), `C` (output start), and+`D` (command end) drive prompt-jumping and "pipe last command output". `OSC 7`+tracks the working directory so a new window opens in the same place.++## Keyboard++The legacy xterm/VT encoding covers cursor keys (with DECCKM application mode),+the editing keypad (Insert/Delete/PageUp/PageDown), F1-F12, and the+xterm modifier parameter (`CSI 1 ; m <letter>`), with Alt sending an ESC+(meta) prefix.++The **kitty keyboard protocol** (`CSI > flags u` push, `CSI < flags u` pop,+`CSI = flags ; mode u` set, `CSI ? u` query) is implemented with the+disambiguate, report-event-types, report-alternate-keys, report-all-keys, and+report-associated-text enhancement flags. Keys are encoded as `CSI+code[:shifted] ; mod[:event] [; text] u`, with a 32-deep push/pop stack. The+common keys (Enter, Tab, Backspace) keep their legacy bytes when unmodified so+a plain shell stays usable.++## Mouse++Reports are framed in the legacy byte form (`CSI M Cb Cx Cy`), the UTF-8+coordinate form (DECSET 1005), or the SGR form (`CSI < Cb ; Cx ; Cy M/m`,+DECSET 1006). Shift/Alt/Ctrl modifier bits and the motion bit are encoded;+legacy releases collapse to button code 3. Reporting level is chosen by the+application via the mouse modes above; with reporting off the pointer drives+local selection and scrollback.++## POSIX / terminal behaviour++- A real PTY pair (`rustix` `openpt`/`grantpt`/`unlockpt`), with the child's+  `TERM` set to the configured value (default `beer`) and the window size kept+  in sync via `TIOCSWINSZ` (`SIGWINCH` reaches the child).+- The child's exit status is propagated as beer's own exit code.+- Alternate screen, scroll regions, autowrap and reflow on resize, and a+  scrollback buffer.+- Bracketed paste (DECSET 2004) and synchronized output (DECSET 2026) so+  applications can paste safely and update atomically.diff --git a/crates/beer-protocols/src/caps.rs b/crates/beer-protocols/src/caps.rsnew file mode 100644index 0000000..b8ff4c2--- /dev/null+++ b/crates/beer-protocols/src/caps.rs@@ -0,0 +1,25 @@+//! Terminfo capability values reported via XTGETTCAP (`DCS + q <name> ST`).++/// Look up a terminfo capability beer reports via XTGETTCAP, by its terminfo+/// name. `None` means the capability is unknown (the reply is then a negative+/// `DCS 0 + r`).+pub 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,+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn known_and_unknown_caps() {+        assert_eq!(cap_value(b"TN"), Some("beer"));+        assert_eq!(cap_value(b"colors"), Some("256"));+        assert_eq!(cap_value(b"nope"), None);+    }+}diff --git a/crates/beer-protocols/src/charset.rs b/crates/beer-protocols/src/charset.rsnew file mode 100644index 0000000..220c931--- /dev/null+++ b/crates/beer-protocols/src/charset.rs@@ -0,0 +1,61 @@+//! G0/G1 character-set designation and the DEC special graphics (line-drawing)+//! translation.++/// A designated character set (`ESC ( c` / `ESC ) c`).+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Charset {+    Ascii,+    DecSpecial,+}++/// Map a designation byte to a [`Charset`]. `0` selects DEC special graphics;+/// everything else falls back to ASCII.+pub fn charset(byte: u8) -> Charset {+    match byte {+        b'0' => Charset::DecSpecial,+        _ => Charset::Ascii,+    }+}++/// Translate a byte under the DEC special graphics set (VT100 line drawing).+pub 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,+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn line_drawing_translates() {+        assert_eq!(charset(b'0'), Charset::DecSpecial);+        assert_eq!(charset(b'B'), Charset::Ascii);+        assert_eq!(dec_special('q'), '─');+        assert_eq!(dec_special('x'), '│');+        assert_eq!(dec_special('A'), 'A'); // unmapped passes through+    }+}diff --git a/crates/beer-protocols/src/codec.rs b/crates/beer-protocols/src/codec.rsnew file mode 100644index 0000000..1fe38e3--- /dev/null+++ b/crates/beer-protocols/src/codec.rs@@ -0,0 +1,154 @@+//! Byte codecs the escape-sequence layer leans on: base64 (OSC 52 clipboard),+//! hex (XTGETTCAP capability names), and percent-decoding of `file://` URIs+//! (OSC 7 working-directory reports).++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.+pub 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 even-length lowercase/uppercase hex string into bytes (XTGETTCAP+/// names arrive hex-encoded).+pub 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()+}++/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.+pub 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+}++/// 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.+pub 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)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[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 decode_hex_rejects_odd_and_nonhex() {+        assert_eq!(decode_hex(b"544e").as_deref(), Some(&b"TN"[..]));+        assert_eq!(decode_hex(b"54e"), None);+        assert_eq!(decode_hex(b"zz"), None);+    }++    #[test]+    fn file_uri_decodes_percent_and_drops_host() {+        assert_eq!(+            file_uri_path(b"file://hermes/home/user/my%20dir").as_deref(),+            Some("/home/user/my dir")+        );+        assert_eq!(file_uri_path(b"file://host").as_deref(), None);+    }+}diff --git a/crates/beer-protocols/src/key.rs b/crates/beer-protocols/src/key.rsnew file mode 100644index 0000000..630bca1--- /dev/null+++ b/crates/beer-protocols/src/key.rs@@ -0,0 +1,578 @@+//! Keyboard encoding: translate decoded key events into the byte sequences a+//! terminal application expects, in both the legacy xterm/VT form and the kitty+//! keyboard progressive-enhancement form.++use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};++/// Encode a key press into bytes for the PTY in the legacy xterm/VT form, or+/// `None` if it produces no input. `app_cursor` selects the SS3 cursor-key form+/// (DECCKM).+pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {+    let seq = match event.keysym {+        Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),+        Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),+        Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),+        Keysym::Tab => b"\t".to_vec(),+        Keysym::Escape => b"\x1b".to_vec(),++        Keysym::Up => csi_letter(b'A', mods, app_cursor),+        Keysym::Down => csi_letter(b'B', mods, app_cursor),+        Keysym::Right => csi_letter(b'C', mods, app_cursor),+        Keysym::Left => csi_letter(b'D', mods, app_cursor),+        Keysym::Home => csi_letter(b'H', mods, app_cursor),+        Keysym::End => csi_letter(b'F', mods, app_cursor),++        Keysym::Insert => csi_tilde(2, mods),+        Keysym::Delete => csi_tilde(3, mods),+        Keysym::Page_Up => csi_tilde(5, mods),+        Keysym::Page_Down => csi_tilde(6, mods),++        // F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.+        Keysym::F1 => fkey(b'P', mods),+        Keysym::F2 => fkey(b'Q', mods),+        Keysym::F3 => fkey(b'R', mods),+        Keysym::F4 => fkey(b'S', mods),+        Keysym::F5 => csi_tilde(15, mods),+        Keysym::F6 => csi_tilde(17, mods),+        Keysym::F7 => csi_tilde(18, mods),+        Keysym::F8 => csi_tilde(19, mods),+        Keysym::F9 => csi_tilde(20, mods),+        Keysym::F10 => csi_tilde(21, mods),+        Keysym::F11 => csi_tilde(23, mods),+        Keysym::F12 => csi_tilde(24, mods),++        // Everything else: the xkb-composed text (which already folds in Ctrl),+        // with Alt sending an ESC prefix (meta).+        _ => {+            let text = event.utf8.as_ref()?;+            if text.is_empty() {+                return None;+            }+            prefix_alt(text.as_bytes().to_vec(), mods)+        }+    };+    Some(seq)+}++/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum KeyKind {+    Press = 1,+    Repeat = 2,+    Release = 3,+}++/// How a functional (non-text) key is encoded in the kitty protocol.+enum Func {+    /// `CSI number ... u`.+    Number(u32),+    /// `CSI number ... ~`.+    Tilde(u32),+    /// `CSI 1 ... LETTER`.+    Letter(u8),+}++/// Map a keysym to its kitty functional encoding, or `None` for a text key+/// (whose code is its Unicode codepoint).+fn functional(keysym: Keysym) -> Option<Func> {+    Some(match keysym {+        Keysym::Escape => Func::Number(27),+        Keysym::Return | Keysym::KP_Enter => Func::Number(13),+        Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),+        Keysym::BackSpace => Func::Number(127),+        Keysym::Insert => Func::Tilde(2),+        Keysym::Delete => Func::Tilde(3),+        Keysym::Page_Up => Func::Tilde(5),+        Keysym::Page_Down => Func::Tilde(6),+        Keysym::Up => Func::Letter(b'A'),+        Keysym::Down => Func::Letter(b'B'),+        Keysym::Right => Func::Letter(b'C'),+        Keysym::Left => Func::Letter(b'D'),+        Keysym::Home => Func::Letter(b'H'),+        Keysym::End => Func::Letter(b'F'),+        Keysym::F1 => Func::Letter(b'P'),+        Keysym::F2 => Func::Letter(b'Q'),+        Keysym::F3 => Func::Tilde(13),+        Keysym::F4 => Func::Letter(b'S'),+        Keysym::F5 => Func::Tilde(15),+        Keysym::F6 => Func::Tilde(17),+        Keysym::F7 => Func::Tilde(18),+        Keysym::F8 => Func::Tilde(19),+        Keysym::F9 => Func::Tilde(20),+        Keysym::F10 => Func::Tilde(21),+        Keysym::F11 => Func::Tilde(23),+        Keysym::F12 => Func::Tilde(24),+        _ => return None,+    })+}++/// The kitty key code for a lone modifier key, reported only in report-all mode.+fn modifier_key(keysym: Keysym) -> Option<u32> {+    Some(match keysym {+        Keysym::Caps_Lock => 57358,+        Keysym::Num_Lock => 57360,+        Keysym::Shift_L => 57441,+        Keysym::Control_L => 57442,+        Keysym::Alt_L => 57443,+        Keysym::Super_L => 57444,+        Keysym::Shift_R => 57447,+        Keysym::Control_R => 57448,+        Keysym::Alt_R => 57449,+        Keysym::Super_R => 57450,+        _ => return None,+    })+}++/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8); the wire+/// parameter is `1 + this`.+fn kitty_mod_bits(mods: Modifiers) -> u32 {+    u32::from(mods.shift)+        | (u32::from(mods.alt) << 1)+        | (u32::from(mods.ctrl) << 2)+        | (u32::from(mods.logo) << 3)+}++/// The un-shifted Unicode codepoint of a text key (always the lowercase form,+/// per the protocol). `None` if the key produces no character.+fn base_codepoint(keysym: Keysym) -> Option<u32> {+    let c = keysym.key_char()?;+    Some(u32::from(if c.is_ascii_uppercase() {+        c.to_ascii_lowercase()+    } else {+        c+    }))+}++/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier+/// section is emitted when modifiers/event/text require it; the event sub-field+/// only when the event is not a plain press.+fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {+    let mut s = String::from("\x1b[");+    s.push_str(&field);+    let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();+    if needs_mods {+        s.push(';');+        s.push_str(&mod_param.to_string());+        if event != KeyKind::Press {+            s.push(':');+            s.push_str(&(event as u8).to_string());+        }+    }+    if let Some(text) = text {+        s.push(';');+        let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();+        s.push_str(&codes.join(":"));+    }+    s.push(char::from(term));+    s.into_bytes()+}++/// Encode a key event under the kitty keyboard protocol with the given active+/// `flags`. Returns `None` when the event produces nothing (e.g. a text key+/// release without report-all mode). `app_cursor` only affects unmodified+/// cursor keys, matching legacy behaviour.+pub fn kitty_encode(+    event: &KeyEvent,+    mods: Modifiers,+    flags: u8,+    kind: KeyKind,+    app_cursor: bool,+) -> Option<Vec<u8>> {+    let report_all = flags & 0b1000 != 0;+    let report_events = flags & 0b10 != 0;+    let report_alt = flags & 0b100 != 0;+    let report_text = flags & 0b1_0000 != 0;++    let bits = kitty_mod_bits(mods);+    let mod_param = bits + 1;+    // Events other than a press are only reported when asked for.+    let events_ok = report_events || report_all;+    let kind = if events_ok { kind } else { KeyKind::Press };++    // Lone modifier keys: only in report-all mode.+    if let Some(code) = modifier_key(event.keysym) {+        if !report_all {+            return None;+        }+        return Some(csi(code.to_string(), mod_param, kind, None, b'u'));+    }++    match functional(event.keysym) {+        Some(func) => {+            // Enter/Tab/Backspace keep legacy bytes when unmodified and not in+            // report-all mode, so a shell stays usable.+            let legacy_special = matches!(+                event.keysym,+                Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace+            );+            if legacy_special && !report_all && bits == 0 {+                if kind != KeyKind::Press {+                    return None;+                }+                return Some(match event.keysym {+                    Keysym::BackSpace => b"\x7f".to_vec(),+                    Keysym::Tab => b"\t".to_vec(),+                    _ => b"\r".to_vec(),+                });+            }+            if kind != KeyKind::Press && !events_ok {+                return None;+            }+            Some(match func {+                Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),+                Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),+                Func::Letter(l) => {+                    if mod_param == 1 && kind == KeyKind::Press {+                        // Unmodified: legacy CSI/SS3 form, honouring app-cursor.+                        vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]+                    } else {+                        csi("1".to_string(), mod_param, kind, None, l)+                    }+                }+            })+        }+        None => {+            let cp = base_codepoint(event.keysym)?;+            if report_all {+                if cp == 0 {+                    return None;+                }+                let text = if report_text {+                    event+                        .utf8+                        .as_deref()+                        .filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))+                } else {+                    None+                };+                let field = alt_field(cp, event.keysym, mods, report_alt);+                return Some(csi(field, mod_param, kind, text, b'u'));+            }+            // Without report-all, text keys only report presses.+            if kind != KeyKind::Press {+                return None;+            }+            // Plain or shift-only keys send their text; ctrl/alt/super → CSI u.+            if bits & !1 == 0 {+                let text = event.utf8.as_ref()?;+                if text.is_empty() {+                    return None;+                }+                return Some(text.as_bytes().to_vec());+            }+            let field = alt_field(cp, event.keysym, mods, report_alt);+            Some(csi(field, mod_param, KeyKind::Press, None, b'u'))+        }+    }+}++/// The key-code field, with the shifted alternate appended (`code:shifted`) when+/// alternate-key reporting is on and shift is held.+fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {+    if report_alt+        && mods.shift+        && let Some(shifted) = keysym.key_char().map(u32::from)+        && shifted != cp+    {+        return format!("{cp}:{shifted}");+    }+    cp.to_string()+}++fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {+    if mods.alt {+        let mut out = Vec::with_capacity(bytes.len() + 1);+        out.push(0x1b);+        out.extend_from_slice(&bytes);+        out+    } else {+        bytes+    }+}++/// xterm modifier parameter: 1 + a bitfield of the held modifiers.+fn modifier_param(mods: Modifiers) -> u8 {+    1 + u8::from(mods.shift)+        + (u8::from(mods.alt) << 1)+        + (u8::from(mods.ctrl) << 2)+        + (u8::from(mods.logo) << 3)+}++/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and+/// `ESC [ 1 ; m X` when modifiers are held.+fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]+    } else {+        let mut v = format!("\x1b[1;{m}").into_bytes();+        v.push(final_byte);+        v+    }+}++/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.+fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        format!("\x1b[{n}~").into_bytes()+    } else {+        format!("\x1b[{n};{m}~").into_bytes()+    }+}++fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {+    let m = modifier_param(mods);+    if m == 1 {+        vec![0x1b, b'O', final_byte]+    } else {+        let mut v = format!("\x1b[1;{m}").into_bytes();+        v.push(final_byte);+        v+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {+        KeyEvent {+            time: 0,+            raw_code: 0,+            keysym,+            utf8: utf8.map(str::to_owned),+        }+    }++    const NONE: Modifiers = Modifiers {+        ctrl: false,+        alt: false,+        shift: false,+        caps_lock: false,+        logo: false,+        num_lock: false,+    };++    fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {+        Modifiers {+            ctrl,+            shift,+            alt,+            ..NONE+        }+    }++    #[test]+    fn plain_text_passes_through() {+        assert_eq!(+            encode(&key(Keysym::a, Some("a")), NONE, false),+            Some(b"a".to_vec())+        );+    }++    #[test]+    fn alt_prefixes_escape() {+        let m = Modifiers { alt: true, ..NONE };+        assert_eq!(+            encode(&key(Keysym::a, Some("a")), m, false),+            Some(b"\x1ba".to_vec())+        );+    }++    #[test]+    fn arrows_respect_application_mode() {+        assert_eq!(+            encode(&key(Keysym::Up, None), NONE, false),+            Some(b"\x1b[A".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::Up, None), NONE, true),+            Some(b"\x1bOA".to_vec())+        );+    }++    #[test]+    fn modified_arrow_uses_csi_param() {+        let m = Modifiers { ctrl: true, ..NONE };+        assert_eq!(+            encode(&key(Keysym::Right, None), m, false),+            Some(b"\x1b[1;5C".to_vec())+        );+    }++    #[test]+    fn kitty_plain_text_key_stays_text() {+        // Disambiguate only: an unmodified letter is still sent as text.+        assert_eq!(+            kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),+            Some(b"a".to_vec())+        );+    }++    #[test]+    fn kitty_ctrl_letter_is_csi_u() {+        // ctrl+a → CSI 97 ; 5 u  (5 = 1 + ctrl).+        assert_eq!(+            kitty_encode(+                &key(Keysym::a, None),+                mods(true, false, false),+                0b1,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[97;5u".to_vec())+        );+    }++    #[test]+    fn kitty_escape_disambiguated() {+        assert_eq!(+            kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),+            Some(b"\x1b[27u".to_vec())+        );+    }++    #[test]+    fn kitty_associated_text_and_alternate() {+        // shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.+        let flags = 0b1_1001; // disambiguate | report-all | associated-text+        assert_eq!(+            kitty_encode(+                &key(Keysym::A, Some("A")),+                mods(false, true, false),+                flags,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[97;2;65u".to_vec())+        );+        // ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.+        assert_eq!(+            kitty_encode(+                &key(Keysym::A, None),+                mods(true, true, false),+                0b101,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[97:65;6u".to_vec())+        );+    }++    #[test]+    fn kitty_release_only_with_report_all() {+        let release = |flags| {+            kitty_encode(+                &key(Keysym::a, None),+                mods(true, false, false),+                flags,+                KeyKind::Release,+                false,+            )+        };+        // Event reporting alone does not report text-key release.+        assert_eq!(release(0b11), None);+        // Report-all does: CSI 97 ; 5 : 3 u.+        assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));+    }++    #[test]+    fn kitty_lock_keys_do_not_leak() {+        let numlock = Modifiers {+            num_lock: true,+            ..NONE+        };+        // Num lock on must not turn unmodified Enter into a CSI u sequence.+        assert_eq!(+            kitty_encode(+                &key(Keysym::Return, None),+                numlock,+                0b1,+                KeyKind::Press,+                false+            ),+            Some(b"\r".to_vec())+        );+        // Nor pollute the modifier parameter of a real chord.+        let ctrl_numlock = Modifiers {+            ctrl: true,+            num_lock: true,+            ..NONE+        };+        assert_eq!(+            kitty_encode(+                &key(Keysym::a, None),+                ctrl_numlock,+                0b1,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[97;5u".to_vec())+        );+    }++    #[test]+    fn kitty_functional_keys() {+        // Unmodified arrow keeps the legacy form (app-cursor honoured).+        assert_eq!(+            kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),+            Some(b"\x1b[A".to_vec())+        );+        assert_eq!(+            kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),+            Some(b"\x1bOA".to_vec())+        );+        // Modified arrow → CSI 1 ; mods LETTER.+        assert_eq!(+            kitty_encode(+                &key(Keysym::Up, None),+                mods(true, false, false),+                0b1,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[1;5A".to_vec())+        );+        // F5 keeps its tilde form.+        assert_eq!(+            kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),+            Some(b"\x1b[15~".to_vec())+        );+        // Unmodified Enter is still a carriage return; report-all makes it CSI u.+        assert_eq!(+            kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),+            Some(b"\r".to_vec())+        );+        assert_eq!(+            kitty_encode(+                &key(Keysym::Return, None),+                NONE,+                0b1001,+                KeyKind::Press,+                false+            ),+            Some(b"\x1b[13u".to_vec())+        );+    }++    #[test]+    fn special_keys() {+        assert_eq!(+            encode(&key(Keysym::Return, None), NONE, false),+            Some(b"\r".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::BackSpace, None), NONE, false),+            Some(b"\x7f".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::Delete, None), NONE, false),+            Some(b"\x1b[3~".to_vec())+        );+        assert_eq!(+            encode(&key(Keysym::F5, None), NONE, false),+            Some(b"\x1b[15~".to_vec())+        );+    }+}diff --git a/crates/beer-protocols/src/lib.rs b/crates/beer-protocols/src/lib.rsnew file mode 100644index 0000000..7b0ea4f--- /dev/null+++ b/crates/beer-protocols/src/lib.rs@@ -0,0 +1,36 @@+//! Terminal protocol building blocks for [beer](https://github.com/NotAShelf/beer).+//!+//! This crate gathers the self-contained pieces of beer's terminal-protocol+//! support: the byte codecs an escape stream needs, the terminfo capability+//! table, character-set translation, SGR colour/underline parsing, and the+//! keyboard/mouse wire encoders. It deliberately holds no terminal state (no+//! grid, no parser loop): every item here is a pure function or a plain data+//! type, so each protocol detail can be read and tested on its own. beer wires+//! these into its `vte`-driven dispatcher and its [`Grid`] model.+//!+//! The modules map onto the protocols a terminal user cares about:+//!+//! - [`codec`] - base64 (OSC 52 clipboard), hex (XTGETTCAP), and `file://` URI+//!   percent-decoding (OSC 7).+//! - [`caps`] - the terminfo capabilities answered over XTGETTCAP.+//! - [`charset`] - G0/G1 designation and DEC special-graphics line drawing.+//! - [`sgr`] - the multi-parameter SGR colour and underline forms.+//! - [`key`] - legacy xterm/VT and kitty keyboard-protocol key encoding.+//! - [`mouse`] - X10/UTF-8/SGR mouse-report encoding.+//! - [`style`] - the enums an SGR/DECSET stream selects (colour, underline,+//!   cursor shape, mouse protocol/encoding, shell-integration prompt marks).+//!+//! See `README.md` for the full inventory of escape sequences, OSC commands,+//! and POSIX behaviour beer implements.++pub mod caps;+pub mod charset;+pub mod codec;+pub mod key;+pub mod mouse;+pub mod sgr;+pub mod style;++pub use style::{+    Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline, prompt_kind,+};diff --git a/crates/beer-protocols/src/mouse.rs b/crates/beer-protocols/src/mouse.rsnew file mode 100644index 0000000..5ed3150--- /dev/null+++ b/crates/beer-protocols/src/mouse.rs@@ -0,0 +1,106 @@+//! Mouse-event encoding: frame a button/motion report in the wire form the+//! application selected (legacy byte form, UTF-8, or SGR).++use std::io::Write as _;++use smithay_client_toolkit::seat::keyboard::Modifiers;++use crate::style::MouseEncoding;++/// Encode a mouse event for the application. `button` is the base code (0/1/2+/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based+/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.+pub fn encode_mouse(+    encoding: MouseEncoding,+    button: u8,+    col: usize,+    row: usize,+    pressed: bool,+    motion: bool,+    mods: Modifiers,+) -> Vec<u8> {+    let mod_bits =+        (u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);+    let motion_bit = if motion { 32 } else { 0 };+    let mut out = Vec::new();+    match encoding {+        MouseEncoding::Sgr => {+            let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);+            let final_byte = if pressed { 'M' } else { 'm' };+            let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);+        }+        enc => {+            // Legacy form collapses every button release to code 3.+            let base = if !pressed && button <= 2 { 3 } else { button };+            let cb = 32 + base + motion_bit + mod_bits;+            out.extend_from_slice(b"\x1b[M");+            push_coord(&mut out, u32::from(cb), false);+            let utf8 = enc == MouseEncoding::Utf8;+            push_coord(&mut out, col as u32 + 33, utf8);+            push_coord(&mut out, row as u32 + 33, utf8);+        }+    }+    out+}++/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when+/// the extended (1005) mode is active, and clamping otherwise.+fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {+    if utf8 {+        let v = value.min(0x7ff);+        if v >= 0x80 {+            out.push(0xc0 | (v >> 6) as u8);+            out.push(0x80 | (v & 0x3f) as u8);+        } else {+            out.push(v as u8);+        }+    } else {+        out.push(value.min(255) as u8);+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    const NONE: Modifiers = Modifiers {+        ctrl: false,+        alt: false,+        shift: false,+        caps_lock: false,+        logo: false,+        num_lock: false,+    };++    #[test]+    fn mouse_sgr_press_and_release() {+        // Left press at column 3, row 5 (0-based) -> SGR with 1-based coords.+        assert_eq!(+            encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE),+            b"\x1b[<0;4;6M".to_vec()+        );+        assert_eq!(+            encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE),+            b"\x1b[<0;4;6m".to_vec()+        );+    }++    #[test]+    fn mouse_legacy_release_collapses_to_three() {+        // Legacy release: button bits become 3; values offset by 32.+        assert_eq!(+            encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE),+            vec![0x1b, b'[', b'M', 32 + 3, 33, 33]+        );+    }++    #[test]+    fn mouse_motion_and_modifiers_set_bits() {+        let mods = Modifiers { ctrl: true, ..NONE };+        // Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl).+        assert_eq!(+            encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods),+            b"\x1b[<48;1;1M".to_vec()+        );+    }+}diff --git a/crates/beer-protocols/src/sgr.rs b/crates/beer-protocols/src/sgr.rsnew file mode 100644index 0000000..ebdd572--- /dev/null+++ b/crates/beer-protocols/src/sgr.rs@@ -0,0 +1,97 @@+//! SGR (Select Graphic Rendition) parsing: the colour and underline forms that+//! need more than a single parameter. Plain attribute toggles (bold, reverse,+//! ...) stay in the dispatcher; what lives here is the multi-parameter parsing+//! that benefits from being testable in isolation.++use crate::style::{Color, Underline};++/// Map an SGR 4 parameter (`4` or `4:x`) to an underline style.+pub 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,+    }+}++/// Parse an SGR 38/48/58 extended colour, given the full parameter list and the+/// index of the introducer. Returns the colour and how many top-level+/// parameters it consumed (1 for the colon-subparameter form, more for the+/// legacy semicolon form).+pub 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),+    }+}++/// Parse the colon-subparameter colour form: `5:idx` (indexed) or `2[:cs]:r:g:b`+/// (direct RGB, with an optional colour-space id that is ignored).+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,+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn underline_styles() {+        assert_eq!(underline_from(&[4]), Underline::Single);+        assert_eq!(underline_from(&[4, 3]), Underline::Curly);+        assert_eq!(underline_from(&[4, 0]), Underline::None);+    }++    #[test]+    fn extended_colour_semicolon_and_colon() {+        // `38;2;10;20;30` direct RGB.+        let items: Vec<&[u16]> = vec![&[38], &[2], &[10], &[20], &[30]];+        assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(10, 20, 30)), 5));+        // `38:2:40:50:60` colon subparameters.+        let items: Vec<&[u16]> = vec![&[38, 2, 40, 50, 60]];+        assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(40, 50, 60)), 1));+        // `38;5;1` indexed.+        let items: Vec<&[u16]> = vec![&[38], &[5], &[1]];+        assert_eq!(ext_color(&items, 0), (Some(Color::Indexed(1)), 3));+    }+}diff --git a/crates/beer-protocols/src/style.rs b/crates/beer-protocols/src/style.rsnew file mode 100644index 0000000..a0305bd--- /dev/null+++ b/crates/beer-protocols/src/style.rs@@ -0,0 +1,83 @@+//! The protocol vocabulary: the enums an SGR/DECSET stream selects, shared by+//! the parser, the grid model, and the renderer.++/// A cell colour: terminal default, a palette index, or direct RGB (SGR 30-49,+/// 90-107, and the `38`/`48`/`58` extended forms).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Color {+    #[default]+    Default,+    Indexed(u8),+    Rgb(u8, u8, u8),+}++/// 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, `CSI Ps SP q`).+#[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,+}++/// 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,+}++/// Map an OSC 133 mark letter (`A`/`B`/`C`/`D`) to a [`PromptKind`].+pub 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,+    }+}diff --git a/crates/beer/Cargo.toml b/crates/beer/Cargo.tomlnew file mode 100644index 0000000..ba8fc0d--- /dev/null+++ b/crates/beer/Cargo.toml@@ -0,0 +1,44 @@+[package]+name = "beer"+version.workspace = true+edition.workspace = true+rust-version.workspace = true+license.workspace = true+description = "A fast, software-rendered, Wayland-native terminal emulator"+readme = true++[dependencies]+anyhow = "1.0.102"+beer-protocols = { path = "../beer-protocols" }+calloop = { version = "0.14.4", features = ["signals"] }+calloop-wayland-source = "0.4.1"+fontconfig = "0.11.0"+freetype-rs = "0.38.0"+harfbuzz_rs_now = "2.3.2"+lru = "0.18.0"+pound = "0.1.6"+rustix = { version = "1.1.4", features = [+  "pty",+  "process",+  "termios",+  "stdio",+  "fs",+] }+serde = { version = "1.0.228", features = ["derive"] }+serde_ignored = "0.1.14"+smithay-client-toolkit = "0.20.0"+thiserror = "2.0.18"+toml = "1.1.2"+tracing = "0.1.44"+tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }+unicode-width = "0.2.2"+vte = "0.15.0"+wayland-client = "0.31.14"+wayland-protocols = { version = "0.32.13", features = [+  "client",+  "staging",+  "unstable",+] }++[lints]+workspace = truediff --git a/crates/beer/src/bindings.rs b/crates/beer/src/bindings.rsnew file mode 100644index 0000000..21f6837--- /dev/null+++ b/crates/beer/src/bindings.rs@@ -0,0 +1,457 @@+//! Configurable key bindings: chord strings (e.g. `Ctrl+Shift+C`) mapped to+//! editor actions, plus text bindings that send a literal string.++use std::collections::HashMap;++use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};++/// An action a key binding can trigger.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Action {+    Copy,+    Paste,+    PastePrimary,+    ScrollPageUp,+    ScrollPageDown,+    ScrollTop,+    ScrollBottom,+    SearchStart,+    FontIncrease,+    FontDecrease,+    FontReset,+    Fullscreen,+    NewWindow,+    JumpPromptUp,+    JumpPromptDown,+    PipeCommandOutput,+    UrlMode,+    UnicodeInput,+}++impl Action {+    fn parse(name: &str) -> Option<Self> {+        Some(match name {+            "copy" => Self::Copy,+            "paste" => Self::Paste,+            "paste-primary" => Self::PastePrimary,+            "scrollback-up" => Self::ScrollPageUp,+            "scrollback-down" => Self::ScrollPageDown,+            "scrollback-top" => Self::ScrollTop,+            "scrollback-bottom" => Self::ScrollBottom,+            "search" => Self::SearchStart,+            "font-increase" => Self::FontIncrease,+            "font-decrease" => Self::FontDecrease,+            "font-reset" => Self::FontReset,+            "fullscreen" => Self::Fullscreen,+            "new-window" => Self::NewWindow,+            "jump-prompt-up" => Self::JumpPromptUp,+            "jump-prompt-down" => Self::JumpPromptDown,+            "pipe-command-output" => Self::PipeCommandOutput,+            "url-mode" => Self::UrlMode,+            "unicode-input" => Self::UnicodeInput,+            _ => return None,+        })+    }+}++/// A parsed chord: a key plus the modifiers that must be held exactly.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+struct Chord {+    key: Keysym,+    ctrl: bool,+    shift: bool,+    alt: bool,+    logo: bool,+}++impl Chord {+    /// Parse a chord like `Ctrl+Shift+C`. Modifier order is irrelevant; the+    /// final token is the key. Returns `None` if no key is recognized.+    fn parse(spec: &str) -> Option<Self> {+        let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);+        let mut key = None;+        for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {+            match token.to_ascii_lowercase().as_str() {+                "ctrl" | "control" => ctrl = true,+                "shift" => shift = true,+                "alt" | "mod1" | "meta" => alt = true,+                "super" | "logo" | "cmd" | "mod4" => logo = true,+                _ => key = keysym_from_token(token),+            }+        }+        Some(Self {+            key: key?,+            ctrl,+            shift,+            alt,+            logo,+        })+    }++    /// Whether `event`/`mods` match this chord. Letters compare case-insensitively+    /// (Shift is matched via the modifier, not the keysym case).+    fn matches(&self, event: &KeyEvent, mods: Modifiers) -> bool {+        if mods.ctrl != self.ctrl+            || mods.shift != self.shift+            || mods.alt != self.alt+            || mods.logo != self.logo+        {+            return false;+        }+        event.keysym == self.key+            || matches!(+                (self.key.key_char(), event.keysym.key_char()),+                (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b)+            )+    }+}++/// A mouse button that a `[mouse-bindings]` chord can name.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum MouseButton {+    Left,+    Middle,+    Right,+}++/// A parsed mouse chord: a button plus the modifiers that must be held exactly.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+struct MouseChord {+    button: MouseButton,+    ctrl: bool,+    shift: bool,+    alt: bool,+    logo: bool,+}++impl MouseChord {+    /// Parse a chord like `Shift+Middle`; the final non-modifier token is the+    /// button. Returns `None` if no button is recognized.+    fn parse(spec: &str) -> Option<Self> {+        let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);+        let mut button = None;+        for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {+            match token.to_ascii_lowercase().as_str() {+                "ctrl" | "control" => ctrl = true,+                "shift" => shift = true,+                "alt" | "mod1" | "meta" => alt = true,+                "super" | "logo" | "cmd" | "mod4" => logo = true,+                "left" | "button1" => button = Some(MouseButton::Left),+                "middle" | "button2" => button = Some(MouseButton::Middle),+                "right" | "button3" => button = Some(MouseButton::Right),+                other => {+                    tracing::warn!("unknown mouse button {other:?} in binding");+                    return None;+                }+            }+        }+        Some(Self {+            button: button?,+            ctrl,+            shift,+            alt,+            logo,+        })+    }++    fn matches(&self, button: MouseButton, mods: Modifiers) -> bool {+        self.button == button+            && mods.ctrl == self.ctrl+            && mods.shift == self.shift+            && mods.alt == self.alt+            && mods.logo == self.logo+    }+}++/// The resolved binding tables for a session.+#[derive(Clone, Debug, Default)]+pub struct Bindings {+    keys: Vec<(Chord, Action)>,+    text: Vec<(Chord, Vec<u8>)>,+    mouse: Vec<(MouseChord, Action)>,+}++impl Bindings {+    /// Build the tables from config, starting from the built-in defaults and+    /// applying user overrides (a value of `none` unbinds a default chord).+    pub fn from_config(+        key_bindings: &HashMap<String, String>,+        text_bindings: &HashMap<String, String>,+        mouse_bindings: &HashMap<String, String>,+    ) -> Self {+        let mut keys: Vec<(Chord, Action)> = Vec::new();+        let mut add = |chord: &str, action: Action| {+            if let Some(c) = Chord::parse(chord) {+                keys.retain(|(existing, _)| *existing != c);+                keys.push((c, action));+            }+        };+        for (chord, action) in DEFAULT_BINDINGS {+            if let Some(a) = Action::parse(action) {+                add(chord, a);+            }+        }+        for (chord, action) in key_bindings {+            if let Some(c) = Chord::parse(chord) {+                keys.retain(|(existing, _)| *existing != c);+                if let Some(a) = Action::parse(action) {+                    keys.push((c, a));+                } else if action != "none" {+                    tracing::warn!("unknown key-binding action {action:?}");+                }+            }+        }++        let mut text = Vec::new();+        for (chord, value) in text_bindings {+            if let Some(c) = Chord::parse(chord) {+                text.push((c, unescape(value)));+            }+        }++        let mut mouse: Vec<(MouseChord, Action)> = Vec::new();+        let mut add_mouse = |chord: &str, action: Option<Action>| {+            if let Some(c) = MouseChord::parse(chord) {+                mouse.retain(|(existing, _)| *existing != c);+                if let Some(a) = action {+                    mouse.push((c, a));+                }+            }+        };+        for (chord, action) in DEFAULT_MOUSE_BINDINGS {+            add_mouse(chord, Action::parse(action));+        }+        for (chord, action) in mouse_bindings {+            if action == "none" {+                add_mouse(chord, None);+            } else if let Some(a) = Action::parse(action) {+                add_mouse(chord, Some(a));+            } else {+                tracing::warn!("unknown mouse-binding action {action:?}");+            }+        }++        Self { keys, text, mouse }+    }++    /// The action bound to this key event, if any.+    pub fn action(&self, event: &KeyEvent, mods: Modifiers) -> Option<Action> {+        self.keys+            .iter()+            .find(|(c, _)| c.matches(event, mods))+            .map(|(_, a)| *a)+    }++    /// The literal bytes bound to this key event, if any.+    pub fn text(&self, event: &KeyEvent, mods: Modifiers) -> Option<&[u8]> {+        self.text+            .iter()+            .find(|(c, _)| c.matches(event, mods))+            .map(|(_, t)| t.as_slice())+    }++    /// The action bound to this mouse button + modifiers, if any.+    pub fn mouse_action(&self, button: MouseButton, mods: Modifiers) -> Option<Action> {+        self.mouse+            .iter()+            .find(|(c, _)| c.matches(button, mods))+            .map(|(_, a)| *a)+    }+}++/// Built-in default key bindings (chord, action name).+const DEFAULT_BINDINGS: &[(&str, &str)] = &[+    ("Ctrl+Shift+C", "copy"),+    ("Ctrl+Shift+V", "paste"),+    ("Ctrl+Shift+F", "search"),+    ("Shift+Page_Up", "scrollback-up"),+    ("Shift+Page_Down", "scrollback-down"),+    ("Ctrl+Shift+Home", "scrollback-top"),+    ("Ctrl+Shift+End", "scrollback-bottom"),+    ("Ctrl+plus", "font-increase"),+    ("Ctrl+equal", "font-increase"),+    ("Ctrl+minus", "font-decrease"),+    ("Ctrl+0", "font-reset"),+    ("F11", "fullscreen"),+    ("Ctrl+Shift+N", "new-window"),+    ("Ctrl+Shift+Up", "jump-prompt-up"),+    ("Ctrl+Shift+Down", "jump-prompt-down"),+    ("Ctrl+Shift+O", "url-mode"),+    ("Ctrl+Shift+U", "unicode-input"),+];++/// Built-in default mouse bindings (chord, action name). Left-button select and+/// drag are built-in gestures, not bindings, so only the other buttons appear.+const DEFAULT_MOUSE_BINDINGS: &[(&str, &str)] = &[("Middle", "paste-primary")];++/// Map a key token to a keysym: a single character, or a named special key.+fn keysym_from_token(token: &str) -> Option<Keysym> {+    let mut chars = token.chars();+    if let (Some(c), None) = (chars.next(), chars.clone().next()) {+        // A single character; bind case-insensitively via the lowercase keysym.+        return Some(Keysym::from_char(c.to_ascii_lowercase()));+    }+    Some(match token {+        "Return" | "Enter" => Keysym::Return,+        "Tab" => Keysym::Tab,+        "Escape" | "Esc" => Keysym::Escape,+        "Space" => Keysym::space,+        "BackSpace" => Keysym::BackSpace,+        "Delete" => Keysym::Delete,+        "Insert" => Keysym::Insert,+        "Home" => Keysym::Home,+        "End" => Keysym::End,+        "Page_Up" | "PageUp" | "Prior" => Keysym::Page_Up,+        "Page_Down" | "PageDown" | "Next" => Keysym::Page_Down,+        "Up" => Keysym::Up,+        "Down" => Keysym::Down,+        "Left" => Keysym::Left,+        "Right" => Keysym::Right,+        "plus" => Keysym::plus,+        "minus" => Keysym::minus,+        "equal" => Keysym::equal,+        "F1" => Keysym::F1,+        "F2" => Keysym::F2,+        "F3" => Keysym::F3,+        "F4" => Keysym::F4,+        "F5" => Keysym::F5,+        "F6" => Keysym::F6,+        "F7" => Keysym::F7,+        "F8" => Keysym::F8,+        "F9" => Keysym::F9,+        "F10" => Keysym::F10,+        "F11" => Keysym::F11,+        "F12" => Keysym::F12,+        other => {+            tracing::warn!("unknown key name {other:?} in binding");+            return None;+        }+    })+}++/// Decode the escapes a text binding may contain: `\e \n \r \t \\` and `\xNN`.+fn unescape(s: &str) -> Vec<u8> {+    let mut out = Vec::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c != '\\' {+            let mut buf = [0u8; 4];+            out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());+            continue;+        }+        match chars.next() {+            Some('e') => out.push(0x1b),+            Some('n') => out.push(b'\n'),+            Some('r') => out.push(b'\r'),+            Some('t') => out.push(b'\t'),+            Some('\\') => out.push(b'\\'),+            Some('x') => {+                let hex: String = (0..2).filter_map(|_| chars.next()).collect();+                if let Ok(byte) = u8::from_str_radix(&hex, 16) {+                    out.push(byte);+                }+            }+            Some(other) => {+                out.push(b'\\');+                let mut buf = [0u8; 4];+                out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());+            }+            None => out.push(b'\\'),+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    const NONE: Modifiers = Modifiers {+        ctrl: false,+        alt: false,+        shift: false,+        caps_lock: false,+        logo: false,+        num_lock: false,+    };++    fn key(keysym: Keysym) -> KeyEvent {+        KeyEvent {+            time: 0,+            raw_code: 0,+            keysym,+            utf8: None,+        }+    }++    #[test]+    fn default_copy_binding_matches_case_insensitively() {+        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());+        let mods = Modifiers {+            ctrl: true,+            shift: true,+            ..NONE+        };+        // Shift yields the uppercase keysym; the binding still matches.+        assert_eq!(b.action(&key(Keysym::C), mods), Some(Action::Copy));+        // Without the right modifiers, no match.+        assert_eq!(b.action(&key(Keysym::C), NONE), None);+    }++    #[test]+    fn config_overrides_and_unbinds() {+        let mut kb = HashMap::new();+        kb.insert("Ctrl+Shift+C".to_string(), "none".to_string());+        kb.insert("Ctrl+y".to_string(), "copy".to_string());+        let b = Bindings::from_config(&kb, &HashMap::new(), &HashMap::new());+        let cs = Modifiers {+            ctrl: true,+            shift: true,+            ..NONE+        };+        assert_eq!(b.action(&key(Keysym::C), cs), None); // unbound+        let c = Modifiers { ctrl: true, ..NONE };+        assert_eq!(b.action(&key(Keysym::y), c), Some(Action::Copy));+    }++    #[test]+    fn text_binding_unescapes() {+        let mut tb = HashMap::new();+        tb.insert("Ctrl+Shift+Return".to_string(), "\\x1b\\r".to_string());+        let b = Bindings::from_config(&HashMap::new(), &tb, &HashMap::new());+        let mods = Modifiers {+            ctrl: true,+            shift: true,+            ..NONE+        };+        assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..]));+    }++    #[test]+    fn mouse_bindings_default_override_and_unbind() {+        // Default: middle button pastes the primary selection.+        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());+        assert_eq!(+            b.mouse_action(MouseButton::Middle, NONE),+            Some(Action::PastePrimary)+        );+        assert_eq!(b.mouse_action(MouseButton::Right, NONE), None);++        // Config rebinds right to paste and unbinds the middle default.+        let mut mb = HashMap::new();+        mb.insert("Right".to_string(), "paste".to_string());+        mb.insert("Middle".to_string(), "none".to_string());+        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &mb);+        assert_eq!(+            b.mouse_action(MouseButton::Right, NONE),+            Some(Action::Paste)+        );+        assert_eq!(b.mouse_action(MouseButton::Middle, NONE), None);+        // Modifiers must match exactly.+        let shift = Modifiers {+            shift: true,+            ..NONE+        };+        assert_eq!(b.mouse_action(MouseButton::Right, shift), None);+    }+}diff --git a/crates/beer/src/config.rs b/crates/beer/src/config.rsnew file mode 100644index 0000000..e895eef--- /dev/null+++ b/crates/beer/src/config.rs@@ -0,0 +1,297 @@+//! User configuration: a TOML file at `$XDG_CONFIG_HOME/beer/beer.toml`+//! deserialized into a typed [`Config`]. A missing file uses defaults; a+//! malformed one warns and falls back to defaults rather than failing to start.++use std::path::{Path, PathBuf};++use serde::Deserialize;++/// Top-level configuration. Unknown keys are ignored so a config written for a+/// newer beer still loads.+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Config {+    pub main: Main,+    pub colors: Colors,+    pub cursor: Cursor,+    pub scrollback: Scrollback,+    pub bell: Bell,+    pub mouse: Mouse,+    pub shell_integration: ShellIntegration,+    pub url: Url,+    pub notify: Notify,+    /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;+    /// a value of `"none"` unbinds.+    pub key_bindings: std::collections::HashMap<String, String>,+    /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).+    pub text_bindings: std::collections::HashMap<String, String>,+    /// Mouse chord (e.g. `"Middle"`, `"Shift+Right"`) → action. Merged over the+    /// defaults; `"none"` unbinds. Left-button select/drag stays built in.+    pub mouse_bindings: std::collections::HashMap<String, String>,+}++/// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Cursor {+    /// `block`, `beam`/`bar`, or `underline`.+    pub style: Option<String>,+    /// Whether the cursor blinks by default.+    pub blink: bool,+}++/// `[bell]`: what happens on `BEL` (0x07).+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Bell {+    /// Briefly flash the screen.+    pub visual: bool,+    /// Command (argv) to run on the bell, e.g. `["paplay", "/usr/share/.../bell.oga"]`.+    pub command: Vec<String>,+    /// Request the compositor's attention (xdg-activation) when the bell rings+    /// while the window is unfocused.+    pub urgent: bool,+}++/// `[notify]`: how desktop notifications (OSC 9/777/99) are delivered.+#[derive(Debug, Clone, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Notify {+    /// Notifier argv; the title and body are appended as the last two arguments.+    pub command: Vec<String>,+}++impl Default for Notify {+    fn default() -> Self {+        Self {+            command: vec!["notify-send".to_string()],+        }+    }+}++/// `[mouse]`: pointer and wheel behaviour.+#[derive(Debug, Clone, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Mouse {+    /// Multiplier applied to the lines scrolled per wheel notch.+    pub scroll_multiplier: f64,+    /// On the alternate screen, translate the wheel into arrow-key presses so+    /// full-screen apps that did not request mouse reporting (less, man, …)+    /// still scroll.+    pub alternate_scroll: bool,+}++impl Default for Mouse {+    fn default() -> Self {+        Self {+            scroll_multiplier: 1.0,+            alternate_scroll: true,+        }+    }+}++/// `[shell-integration]`: behaviour driven by OSC 7 / OSC 133 marks.+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct ShellIntegration {+    /// Command the `pipe-command-output` binding feeds the last command's+    /// output to on stdin (argv form, e.g. `["less"]`). Empty disables it.+    pub pipe_command: Vec<String>,+}++/// `[url]`: opening OSC 8 hyperlinks and detected URLs.+#[derive(Debug, Clone, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Url {+    /// Launcher argv the URL is appended to (e.g. `["xdg-open"]`).+    pub launch: Vec<String>,+}++impl Default for Url {+    fn default() -> Self {+        Self {+            launch: vec!["xdg-open".to_string()],+        }+    }+}++/// `[colors]`: foreground/background, the 16 base palette entries, and accents.+/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries+/// keep the built-in default.+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Colors {+    pub foreground: Option<String>,+    pub background: Option<String>,+    pub cursor: Option<String>,+    pub selection_foreground: Option<String>,+    pub selection_background: Option<String>,+    /// The eight regular palette entries (indices 0-7).+    pub regular: Option<Vec<String>>,+    /// The eight bright palette entries (indices 8-15).+    pub bright: Option<Vec<String>>,+    pub match_background: Option<String>,+    pub match_current_background: Option<String>,+    /// Background opacity, 0.0 (transparent) - 1.0 (opaque).+    pub alpha: Option<f32>,+    /// Render bold text with the bright palette variant.+    pub bold_as_bright: Option<bool>,+}++/// `[main]`: fonts, window geometry, padding, and the terminal name.+#[derive(Debug, Clone, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Main {+    /// Primary font family, resolved via fontconfig.+    pub font: String,+    /// Font size in pixels.+    pub font_size: u32,+    /// `TERM` value exported to the child shell.+    pub term: String,+    /// Initial size in character cells.+    pub initial_cols: u16,+    pub initial_rows: u16,+    /// Inner padding in pixels between the window edge and the cell grid.+    pub pad_x: u32,+    pub pad_y: u32,+    /// Characters that break a word for double-click selection. Empty/unset+    /// keeps the built-in default.+    pub word_delimiters: Option<String>,+    /// Hold an idle inhibitor while the window is focused, so the compositor+    /// does not blank the screen or start the screensaver. Default off.+    pub idle_inhibit: bool,+}++impl Default for Main {+    fn default() -> Self {+        Self {+            font: "monospace".to_string(),+            font_size: 16,+            term: "beer".to_string(),+            initial_cols: 80,+            initial_rows: 24,+            pad_x: 2,+            pad_y: 2,+            word_delimiters: None,+            idle_inhibit: false,+        }+    }+}++/// `[scrollback]`: history retention.+#[derive(Debug, Clone, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Scrollback {+    /// Lines of history retained for the main screen.+    pub lines: usize,+}++impl Default for Scrollback {+    fn default() -> Self {+        Self { lines: 10_000 }+    }+}++impl Config {+    /// Load configuration from `explicit` if given, else the default path.+    /// Any read/parse failure logs a warning and returns defaults.+    pub fn load(explicit: Option<&Path>) -> Self {+        let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else {+            return Self::default();+        };+        if !path.exists() {+            return Self::default();+        }+        let text = match std::fs::read_to_string(&path) {+            Ok(text) => text,+            Err(err) => {+                tracing::warn!("read config {}: {err}; using defaults", path.display());+                return Self::default();+            }+        };+        // Deserialize through serde_ignored so a typo'd key (`font-sze`) is+        // reported instead of silently dropped, while still loading: unknown+        // keys stay tolerated, keeping forward-compatibility with newer configs.+        let de = match toml::Deserializer::parse(&text) {+            Ok(de) => de,+            Err(err) => {+                tracing::warn!("config {}: {err}; using defaults", path.display());+                return Self::default();+            }+        };+        match serde_ignored::deserialize(de, |key| {+            tracing::warn!("config {}: unknown key `{key}` ignored", path.display());+        }) {+            Ok(config) => {+                tracing::info!("loaded config from {}", path.display());+                config+            }+            Err(err) => {+                tracing::warn!("config {}: {err}; using defaults", path.display());+                Self::default()+            }+        }+    }+}++/// `$XDG_CONFIG_HOME/beer/beer.toml`, or `~/.config/beer/beer.toml`.+fn default_path() -> Option<PathBuf> {+    if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {+        return Some(PathBuf::from(dir).join("beer/beer.toml"));+    }+    let home = std::env::var_os("HOME")?;+    Some(PathBuf::from(home).join(".config/beer/beer.toml"))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn defaults_are_sane() {+        let c = Config::default();+        assert_eq!(c.main.font, "monospace");+        assert_eq!(c.main.font_size, 16);+        assert_eq!(c.scrollback.lines, 10_000);+    }++    #[test]+    fn parses_partial_config_and_ignores_unknown() {+        let toml = r##"+            [main]+            font = "JetBrains Mono"+            font-size = 14+            unknown-key = "tolerated"++            [colors]+            background = "#000000"+        "##;+        let c: Config = toml::from_str(toml).unwrap();+        assert_eq!(c.main.font, "JetBrains Mono");+        assert_eq!(c.main.font_size, 14);+        // Unset keys keep defaults; unknown tables/keys are ignored.+        assert_eq!(c.main.term, "beer");+        assert_eq!(c.scrollback.lines, 10_000);+    }++    #[test]+    fn unknown_keys_are_reported_but_still_load() {+        let toml = r##"+            [main]+            font-sze = 14+            font = "JetBrains Mono"++            [made-up]+            x = 1+        "##;+        let mut unknown = Vec::new();+        let de = toml::Deserializer::parse(toml).unwrap();+        let c: Config =+            serde_ignored::deserialize(de, |key| unknown.push(key.to_string())).unwrap();+        // The typo and the bogus table are both surfaced...+        assert!(unknown.iter().any(|k| k == "main.font-sze"), "{unknown:?}");+        assert!(unknown.iter().any(|k| k == "made-up"), "{unknown:?}");+        // ...yet the valid key still loaded.+        assert_eq!(c.main.font, "JetBrains Mono");+    }+}diff --git a/crates/beer/src/font.rs b/crates/beer/src/font.rsnew file mode 100644index 0000000..d232707--- /dev/null+++ b/crates/beer/src/font.rs@@ -0,0 +1,664 @@+//! Font discovery, rasterization, and glyph caching.+//!+//! fontconfig resolves family names and performs per-codepoint fallback;+//! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour+//! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own+//! advance is never consulted - only the [`CellMetrics`] taken from the primary+//! face. C interop goes through the `freetype`/`fontconfig` safe wrappers; the+//! sole `unsafe` is reading a face's fixed-strike array (see `nearest_strike`).++use std::collections::HashMap;+use std::fmt;+use std::num::NonZeroUsize;+use std::path::PathBuf;++use fontconfig::{CharSet, Fontconfig, Pattern};+use freetype::bitmap::PixelMode;+use freetype::face::{LoadFlag, StyleFlag};+use freetype::{Face, Library, Matrix, Vector};+use harfbuzz_rs_now as harfbuzz;+use lru::LruCache;+use thiserror::Error;++/// Upper bound on cached glyphs; the working set of a terminal is far smaller,+/// but this caps memory under adversarial all-of-Unicode output.+const GLYPH_CACHE_CAP: usize = 4096;++/// Upper bound on cached shaped clusters (base char + combining marks).+const SHAPE_CACHE_CAP: usize = 1024;++#[derive(Debug, Error)]+pub enum FontError {+    #[error("FreeType: {0}")]+    FreeType(#[from] freetype::Error),+    #[error("could not initialize fontconfig")]+    FontconfigInit,+    #[error("fontconfig: {0}")]+    Fontconfig(#[from] fontconfig::FontconfigError),+    #[error("no font matched family {0:?}")]+    NoFamily(String),+    #[error("font {0:?} reports no size metrics")]+    NoMetrics(String),+}++/// Bold/italic selection, used both to pick a face and to key the glyph cache.+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]+pub struct Style {+    pub bold: bool,+    pub italic: bool,+}++impl Style {+    /// Dense index in `0..4` for array storage.+    fn index(self) -> usize {+        usize::from(self.bold) | (usize::from(self.italic) << 1)+    }++    fn fontconfig_style(self) -> &'static str {+        match (self.bold, self.italic) {+            (false, false) => "Regular",+            (true, false) => "Bold",+            (false, true) => "Italic",+            (true, true) => "Bold Italic",+        }+    }+}++/// Fixed cell geometry in pixels, derived from the primary face.+#[derive(Clone, Copy, Debug)]+pub struct CellMetrics {+    pub width: u32,+    pub height: u32,+    /// Baseline offset from the top of the cell.+    pub ascent: u32,+}++/// A rasterized glyph: its bitmap plus the offsets to place it on the baseline.+#[derive(Clone, Debug)]+pub struct Glyph {+    /// Horizontal offset from the pen position to the bitmap's left edge.+    pub left: i32,+    /// Vertical offset from the baseline up to the bitmap's top edge.+    pub top: i32,+    pub width: u32,+    pub height: u32,+    pub data: GlyphData,+}++/// Glyph pixel data. A `Mask` is tinted with the cell's foreground colour; a+/// `Color` bitmap (emoji) is composited directly.+#[derive(Clone, Debug)]+pub enum GlyphData {+    /// One coverage byte per pixel.+    Mask(Vec<u8>),+    /// Pre-multiplied BGRA, four bytes per pixel.+    Color(Vec<u8>),+}++/// One shaped glyph in a cluster: a glyph index into a specific face plus the+/// pixel offset, relative to the cell origin and baseline, that HarfBuzz placed+/// it at. `x` grows rightward, `y` upward (away from the baseline).+#[derive(Clone, Copy, Debug)]+pub struct Placed {+    pub gid: u32,+    pub x: i32,+    pub y: i32,+}++/// The result of shaping a base char plus its combining marks: the face the+/// cluster was shaped against and the positioned glyphs to draw, in order.+#[derive(Clone, Debug)]+pub struct ShapedCluster {+    pub face_idx: usize,+    pub glyphs: Vec<Placed>,+}++/// A loaded face plus where it came from, so HarfBuzz can be handed the same+/// font bytes that FreeType rasterizes from.+struct FaceEntry {+    face: Face,+    path: PathBuf,+    index: u32,+    /// HarfBuzz font for this face, built on first shape against it.+    hb: Option<harfbuzz::Owned<harfbuzz::Font<'static>>>,+}++/// The font set for one terminal: a primary family with lazily-loaded+/// bold/italic variants and per-codepoint fallback faces, plus glyph caches.+pub struct Fonts {+    library: Library,+    fontconfig: Fontconfig,+    family: String,+    size_px: u32,+    metrics: CellMetrics,+    /// All loaded faces; indices into this vector are stable.+    faces: Vec<FaceEntry>,+    /// Index of each style variant, by [`Style::index`]; filled on demand.+    styled: [Option<usize>; 4],+    /// Fallback faces resolved by coverage, deduplicated by file path.+    fallbacks: HashMap<PathBuf, usize>,+    /// Glyphs keyed by `char` (the common, unshaped path).+    cache: LruCache<(char, usize), Glyph>,+    /// Glyphs keyed by `(glyph index, face, style)` (the shaped path).+    gcache: LruCache<(u32, usize, usize), Glyph>,+    /// Shaped clusters keyed by `(cluster string, style)`.+    shape_cache: LruCache<(Box<str>, usize), Option<ShapedCluster>>,+}++impl fmt::Debug for Fonts {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        f.debug_struct("Fonts")+            .field("family", &self.family)+            .field("size_px", &self.size_px)+            .field("metrics", &self.metrics)+            .field("faces", &self.faces.len())+            .field("cached", &self.cache.len())+            .finish()+    }+}++impl Fonts {+    /// Resolve `family` at `size_px` and compute the cell metrics.+    pub fn new(family: &str, size_px: u32) -> Result<Self, FontError> {+        let library = Library::init()?;+        let fontconfig = Fontconfig::new().ok_or(FontError::FontconfigInit)?;++        let regular = resolve_face(&library, &fontconfig, family, Style::default(), size_px)?;+        let metrics = cell_metrics(&regular.face, family)?;++        let cap = |n| NonZeroUsize::new(n).expect("cache cap is nonzero");+        Ok(Self {+            library,+            fontconfig,+            family: family.to_owned(),+            size_px,+            metrics,+            faces: vec![regular],+            styled: [Some(0), None, None, None],+            fallbacks: HashMap::new(),+            cache: LruCache::new(cap(GLYPH_CACHE_CAP)),+            gcache: LruCache::new(cap(GLYPH_CACHE_CAP)),+            shape_cache: LruCache::new(cap(SHAPE_CACHE_CAP)),+        })+    }++    pub fn metrics(&self) -> CellMetrics {+        self.metrics+    }++    /// Return the rasterized glyph for `c` in `style`, rasterizing and caching+    /// it on first use.+    pub fn glyph(&mut self, c: char, style: Style) -> Result<&Glyph, FontError> {+        let key = (c, style.index());+        if self.cache.get(&key).is_none() {+            let idx = self.face_for(c, style)?;+            let face = &self.faces[idx].face;+            // Synthesize bold/italic only when the resolved face lacks the real+            // variant (most monospace families ship both).+            let (synth_bold, synth_italic) = synth_flags(face, style);+            let glyph = rasterize(face, c, synth_bold, synth_italic)?;+            self.cache.put(key, glyph);+        }+        Ok(self.cache.get(&key).expect("glyph was just inserted"))+    }++    /// Return the rasterized glyph for glyph index `gid` in `face_idx`,+    /// rasterizing and caching on first use. Used by the shaped path, where+    /// HarfBuzz has already chosen the face and glyph.+    pub fn glyph_indexed(+        &mut self,+        face_idx: usize,+        gid: u32,+        style: Style,+    ) -> Result<&Glyph, FontError> {+        let key = (gid, face_idx, style.index());+        if self.gcache.get(&key).is_none() {+            let face = &self.faces[face_idx].face;+            let (synth_bold, synth_italic) = synth_flags(face, style);+            let glyph = rasterize_index(face, gid, synth_bold, synth_italic)?;+            self.gcache.put(key, glyph);+        }+        Ok(self.gcache.get(&key).expect("glyph was just inserted"))+    }++    /// Shape `base` plus its combining `marks` into positioned glyphs using+    /// HarfBuzz, so marks land where the font's GPOS table wants them rather+    /// than stacked at the origin. Returns `None` when shaping is unavailable or+    /// the cluster has glyphs the face does not cover (`.notdef`), so the caller+    /// can fall back to drawing the marks stacked. Results are cached.+    pub fn shape_cluster(+        &mut self,+        base: char,+        marks: &str,+        style: Style,+    ) -> Option<ShapedCluster> {+        let mut cluster = String::with_capacity(base.len_utf8() + marks.len());+        cluster.push(base);+        cluster.push_str(marks);+        let key = (cluster.clone().into_boxed_str(), style.index());+        if let Some(cached) = self.shape_cache.get(&key) {+            return cached.clone();+        }+        let shaped = self.shape_uncached(base, &cluster, style);+        self.shape_cache.put(key, shaped.clone());+        shaped+    }++    fn shape_uncached(&mut self, base: char, cluster: &str, style: Style) -> Option<ShapedCluster> {+        let face_idx = self.face_for(base, style).ok()?;+        let font = self.hb_font(face_idx)?;+        let buffer = harfbuzz::UnicodeBuffer::new().add_str(cluster);+        let output = harfbuzz::shape(font, buffer, &[]);+        let infos = output.get_glyph_infos();+        let positions = output.get_glyph_positions();+        let mut glyphs = Vec::with_capacity(infos.len());+        let mut pen = 0i32;+        for (info, pos) in infos.iter().zip(positions) {+            // A .notdef means this face does not cover part of the cluster; bail+            // so the caller stacks the marks via per-char fallback instead.+            if info.codepoint == 0 {+                return None;+            }+            glyphs.push(Placed {+                gid: info.codepoint,+                // HarfBuzz positions are 26.6 fixed point at our pixel scale.+                x: (pen + pos.x_offset) >> 6,+                y: pos.y_offset >> 6,+            });+            pen += pos.x_advance;+        }+        Some(ShapedCluster { face_idx, glyphs })+    }++    /// Lazily build the HarfBuzz font for `face_idx` from the same file bytes+    /// FreeType loaded. The bytes are leaked to `'static`: a face lives for the+    /// process, and only the handful actually used to shape clusters allocate.+    fn hb_font(&mut self, face_idx: usize) -> Option<&harfbuzz::Owned<harfbuzz::Font<'static>>> {+        if self.faces[face_idx].hb.is_none() {+            let entry = &self.faces[face_idx];+            let bytes = std::fs::read(&entry.path).ok()?;+            let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice());+            let face = harfbuzz::Face::from_bytes(leaked, entry.index);+            let mut font = harfbuzz::Font::new(face);+            let scale = self.size_px as i32 * 64;+            font.set_scale(scale, scale);+            font.set_ppem(self.size_px, self.size_px);+            self.faces[face_idx].hb = Some(font);+        }+        self.faces[face_idx].hb.as_ref()+    }++    /// Pick the face that should render `c`: the requested style if it has the+    /// glyph, then regular, then known fallbacks, then a fresh fontconfig+    /// coverage match. Falls back to the styled face (rendering `.notdef`).+    fn face_for(&mut self, c: char, style: Style) -> Result<usize, FontError> {+        let styled = self.styled_face(style)?;+        if face_has_glyph(&self.faces[styled].face, c) {+            return Ok(styled);+        }+        if let Some(regular) = self.styled[0]+            && regular != styled+            && face_has_glyph(&self.faces[regular].face, c)+        {+            return Ok(regular);+        }+        for &idx in self.fallbacks.values() {+            if face_has_glyph(&self.faces[idx].face, c) {+                return Ok(idx);+            }+        }+        Ok(self.load_fallback(c)?.unwrap_or(styled))+    }++    /// Lazily load the face for `style`, caching regular's index if the variant+    /// cannot be resolved so the lookup is not retried per glyph.+    fn styled_face(&mut self, style: Style) -> Result<usize, FontError> {+        if let Some(idx) = self.styled[style.index()] {+            return Ok(idx);+        }+        let regular = self.styled[0].expect("regular face is loaded at construction");+        let idx = match resolve_face(+            &self.library,+            &self.fontconfig,+            &self.family,+            style,+            self.size_px,+        ) {+            Ok(entry) => {+                self.faces.push(entry);+                self.faces.len() - 1+            }+            Err(_) => regular,+        };+        self.styled[style.index()] = Some(idx);+        Ok(idx)+    }++    /// Ask fontconfig for a font covering `c`, load it, and remember it.+    fn load_fallback(&mut self, c: char) -> Result<Option<usize>, FontError> {+        let mut charset = CharSet::new(&self.fontconfig)?;+        charset.add_char(c)?;+        let mut pattern = Pattern::new(&self.fontconfig)?;+        pattern.add_string(c"family", c"monospace")?;+        pattern.add_charset(charset)?;+        let matched = pattern.font_match()?;++        let path = PathBuf::from(matched.filename()?);+        if let Some(&idx) = self.fallbacks.get(&path) {+            return Ok(Some(idx));+        }+        let index = matched.face_index().unwrap_or(0);+        let face = self.library.new_face(&path, index as isize)?;+        if size_face(&face, self.size_px).is_err() {+            return Ok(None);+        }+        self.faces.push(FaceEntry {+            face,+            path: path.clone(),+            index: index as u32,+            hb: None,+        });+        let idx = self.faces.len() - 1;+        self.fallbacks.insert(path, idx);+        Ok(Some(idx))+    }+}++fn face_has_glyph(face: &Face, c: char) -> bool {+    face.get_char_index(c as usize).is_some_and(|g| g != 0)+}++/// Whether bold/italic must be synthesized: only when the requested style is+/// set but the resolved face lacks the real variant.+fn synth_flags(face: &Face, style: Style) -> (bool, bool) {+    let flags = face.style_flags();+    let synth_bold = style.bold && !flags.contains(StyleFlag::BOLD);+    let synth_italic = style.italic && !flags.contains(StyleFlag::ITALIC);+    (synth_bold, synth_italic)+}++fn resolve_face(+    library: &Library,+    fontconfig: &Fontconfig,+    family: &str,+    style: Style,+    size_px: u32,+) -> Result<FaceEntry, FontError> {+    let font = fontconfig+        .find(family, Some(style.fontconfig_style()))+        .map_err(|_| FontError::NoFamily(family.to_owned()))?;+    let index = font.index.unwrap_or(0);+    let face = library.new_face(&font.path, index as isize)?;+    size_face(&face, size_px)?;+    Ok(FaceEntry {+        face,+        path: font.path,+        index: index as u32,+        hb: None,+    })+}++/// Set a face to `size_px`. Scalable faces size directly; bitmap-strike faces+/// (e.g. colour-emoji fonts) cannot, so select the nearest available strike and+/// let the renderer scale its glyphs into the cell.+fn size_face(face: &Face, size_px: u32) -> Result<(), FontError> {+    match face.set_pixel_sizes(0, size_px) {+        Ok(()) => Ok(()),+        Err(_) if face.has_fixed_sizes() => {+            face.select_size(nearest_strike(face, size_px))?;+            Ok(())+        }+        Err(err) => Err(err.into()),+    }+}++/// Index of the fixed strike whose pixel height is closest to `target`.+fn nearest_strike(face: &Face, target: u32) -> i32 {+    let rec = face.raw();+    let target = i32::try_from(target).unwrap_or(i32::MAX);+    let mut best = 0;+    let mut best_delta = i32::MAX;+    for i in 0..rec.num_fixed_sizes {+        // SAFETY: `available_sizes` points to `num_fixed_sizes` valid+        // `FT_Bitmap_Size` entries for the face's lifetime; `i` is in range.+        let height = i32::from(unsafe { (*rec.available_sizes.offset(i as isize)).height });+        let delta = (height - target).abs();+        if delta < best_delta {+            best = i;+            best_delta = delta;+        }+    }+    best+}++fn cell_metrics(face: &Face, family: &str) -> Result<CellMetrics, FontError> {+    let metrics = face+        .size_metrics()+        .ok_or_else(|| FontError::NoMetrics(family.to_owned()))?;+    // FreeType reports these in 26.6 fixed point.+    let ascent = (metrics.ascender >> 6).max(1) as u32;+    let height = (metrics.height >> 6).max(1) as u32;++    // For a monospace face every advance is equal; measure one ASCII glyph.+    face.load_char('M' as usize, LoadFlag::DEFAULT)?;+    let width = (face.glyph().advance().x >> 6).max(1) as u32;++    Ok(CellMetrics {+        width,+        height,+        ascent,+    })+}++fn rasterize(+    face: &Face,+    c: char,+    synth_bold: bool,+    synth_italic: bool,+) -> Result<Glyph, FontError> {+    rasterize_with(face, synth_bold, synth_italic, |face| {+        face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR)+    })+}++/// Rasterize by glyph index rather than character (the shaped path).+fn rasterize_index(+    face: &Face,+    gid: u32,+    synth_bold: bool,+    synth_italic: bool,+) -> Result<Glyph, FontError> {+    rasterize_with(face, synth_bold, synth_italic, |face| {+        face.load_glyph(gid, LoadFlag::RENDER | LoadFlag::COLOR)+    })+}++fn rasterize_with(+    face: &Face,+    synth_bold: bool,+    synth_italic: bool,+    load: impl FnOnce(&Face) -> Result<(), freetype::Error>,+) -> Result<Glyph, FontError> {+    // A shear transform fakes italics on a face that has no real oblique. It is+    // applied to the outline at load time, so reset it immediately after.+    if synth_italic {+        face.set_transform(&mut shear_matrix(), &mut Vector { x: 0, y: 0 });+    }+    let result = load(face);+    if synth_italic {+        face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 });+    }+    result?;++    let slot = face.glyph();+    let bitmap = slot.bitmap();+    let width = bitmap.width().max(0) as usize;+    let height = bitmap.rows().max(0) as usize;+    let pitch = bitmap.pitch();+    let src = bitmap.buffer();++    let mut data = match bitmap.pixel_mode()? {+        PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)),+        PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)),+        PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)),+        _ => GlyphData::Mask(vec![0; width * height]),+    };+    // Fake bold by widening coverage one pixel to the right (colour glyphs are+    // left alone - there is no such thing as a bold emoji).+    if synth_bold && let GlyphData::Mask(mask) = &mut data {+        embolden(mask, width, height);+    }++    Ok(Glyph {+        left: slot.bitmap_left(),+        top: slot.bitmap_top(),+        width: width as u32,+        height: height as u32,+        data,+    })+}++fn shear_matrix() -> Matrix {+    // ~0.2 horizontal shear in 16.16 fixed point.+    Matrix {+        xx: 0x1_0000,+        xy: 0x3333,+        yx: 0,+        yy: 0x1_0000,+    }+}++fn identity_matrix() -> Matrix {+    Matrix {+        xx: 0x1_0000,+        xy: 0,+        yx: 0,+        yy: 0x1_0000,+    }+}++/// Widen each row's coverage by one pixel (synthetic bold).+fn embolden(mask: &mut [u8], width: usize, height: usize) {+    for y in 0..height {+        let row = &mut mask[y * width..y * width + width];+        for x in (1..width).rev() {+            row[x] = row[x].max(row[x - 1]);+        }+    }+}++/// Copy `height` rows of `row_bytes` each out of FreeType's padded buffer,+/// honouring pitch sign (positive = top-down).+fn pack_rows(src: &[u8], row_bytes: usize, pitch: i32, height: usize) -> Vec<u8> {+    let stride = pitch.unsigned_abs() as usize;+    let take = row_bytes.min(stride);+    let mut out = vec![0u8; row_bytes * height];+    for row in 0..height {+        let src_row = if pitch >= 0 { row } else { height - 1 - row };+        let start = src_row * stride;+        if start + take <= src.len() {+            out[row * row_bytes..row * row_bytes + take].copy_from_slice(&src[start..start + take]);+        }+    }+    out+}++/// Expand a 1-bit-per-pixel mono bitmap to one coverage byte per pixel.+fn expand_mono(src: &[u8], width: usize, pitch: i32, height: usize) -> Vec<u8> {+    let stride = pitch.unsigned_abs() as usize;+    let mut out = vec![0u8; width * height];+    for row in 0..height {+        let src_row = if pitch >= 0 { row } else { height - 1 - row };+        let base = src_row * stride;+        for x in 0..width {+            let byte = base + x / 8;+            if byte < src.len() && src[byte] & (0x80 >> (x % 8)) != 0 {+                out[row * width + x] = 0xff;+            }+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fonts() -> Fonts {+        Fonts::new("monospace", 16).expect("system has a monospace font")+    }++    #[test]+    fn cell_metrics_are_sane() {+        let m = fonts().metrics();+        assert!(m.width >= 1 && m.height >= 1);+        assert!(m.ascent >= 1 && m.ascent <= m.height);+    }++    #[test]+    fn ascii_glyph_has_ink() {+        let mut f = fonts();+        let glyph = f.glyph('M', Style::default()).expect("rasterize M");+        assert!(glyph.width > 0 && glyph.height > 0);+        match &glyph.data {+            GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "M should have coverage"),+            GlyphData::Color(_) => {}+        }+    }++    #[test]+    fn space_is_blank_but_ok() {+        let mut f = fonts();+        // Space resolves without error; it simply carries no ink.+        f.glyph(' ', Style::default()).expect("rasterize space");+    }++    #[test]+    fn embolden_widens_coverage() {+        // 3x2 mask, one lit pixel per row at x=1.+        let mut mask = vec![0, 255, 0, 0, 200, 0];+        embolden(&mut mask, 3, 2);+        // Each lit pixel bleeds one column to the right; the left edge is unchanged.+        assert_eq!(mask, vec![0, 255, 255, 0, 200, 200]);+    }++    #[test]+    fn shapes_a_simple_cluster() {+        let mut f = fonts();+        // Shaping a bare base char yields exactly its one glyph, and that glyph+        // index rasterizes to ink through the shaped path.+        let shaped = f+            .shape_cluster('a', "", Style::default())+            .expect("monospace shapes 'a'");+        assert_eq!(shaped.glyphs.len(), 1);+        let g = f+            .glyph_indexed(shaped.face_idx, shaped.glyphs[0].gid, Style::default())+            .expect("rasterize shaped glyph");+        match &g.data {+            GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "'a' should have ink"),+            GlyphData::Color(_) => {}+        }+    }++    #[test]+    fn shapes_combining_cluster_without_notdef() {+        // 'e' + combining acute: a covering face shapes it (>=1 glyph, never a+        // .notdef, which `shape_cluster` rejects by returning None); a face+        // missing the mark returns None so the renderer stacks instead. Either+        // outcome is fine - the point is no panic and no notdef leaking through.+        let mut f = fonts();+        if let Some(shaped) = f.shape_cluster('e', "\u{0301}", Style::default()) {+            assert!(!shaped.glyphs.is_empty());+            assert!(shaped.glyphs.iter().all(|g| g.gid != 0));+        }+    }++    #[test]+    fn glyphs_are_cached() {+        let mut f = fonts();+        f.glyph('a', Style::default()).unwrap();+        let before = f.cache.len();+        f.glyph('a', Style::default()).unwrap();+        assert_eq!(f.cache.len(), before, "second lookup must hit the cache");+    }+}diff --git a/crates/beer/src/grid/links.rs b/crates/beer/src/grid/links.rsnew file mode 100644index 0000000..9215316--- /dev/null+++ b/crates/beer/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/crates/beer/src/grid/mod.rs b/crates/beer/src/grid/mod.rsnew file mode 100644index 0000000..ef06572--- /dev/null+++ b/crates/beer/src/grid/mod.rs@@ -0,0 +1,1451 @@+//! 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;++/// The protocol vocabulary an SGR/DECSET stream selects lives in+/// `beer-protocols` and is re-exported here so the grid and renderer keep+/// referring to it as `grid::Color`, `grid::Underline`, and so on.+pub use beer_protocols::{Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline};++/// 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;+    }+}++/// 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,+}++/// 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>>,+    /// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode).+    kitty_current: u8,+    /// Saved flag values for the kitty push/pop stack.+    kitty_stack: Vec<u8>,+}++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`!@#$%^&*()+=[]{}\\|;'\",<>?";++/// Mask of the kitty-keyboard flags we implement (disambiguate, report events,+/// alternate keys, all-keys-as-escapes, associated text).+const KITTY_ALL: u8 = 0b1_1111;++/// 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(),+            kitty_current: 0,+            kitty_stack: Vec::new(),+        }+    }++    /// The active kitty-keyboard flags (0 means legacy encoding).+    pub fn kitty_flags(&self) -> u8 {+        self.kitty_current+    }++    /// Apply `CSI = flags ; mode u`: mode 1 replaces, 2 sets bits, 3 clears bits.+    pub fn kitty_set(&mut self, flags: u8, mode: u8) {+        self.kitty_current = match mode {+            2 => self.kitty_current | flags,+            3 => self.kitty_current & !flags,+            _ => flags,+        } & KITTY_ALL;+    }++    /// Apply `CSI > flags u`: push the current flags and switch to `flags`.+    pub fn kitty_push(&mut self, flags: u8) {+        if self.kitty_stack.len() >= 32 {+            self.kitty_stack.remove(0);+        }+        self.kitty_stack.push(self.kitty_current);+        self.kitty_current = flags & KITTY_ALL;+    }++    /// Apply `CSI < n u`: pop `n` saved flag values, restoring the last one.+    pub fn kitty_pop(&mut self, n: usize) {+        for _ in 0..n.max(1) {+            self.kitty_current = self.kitty_stack.pop().unwrap_or(0);+        }+    }++    /// 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 kitty_flag_stack() {+        let mut g = Grid::new(4, 2);+        assert_eq!(g.kitty_flags(), 0);+        g.kitty_set(0b1, 1); // replace+        assert_eq!(g.kitty_flags(), 0b1);+        g.kitty_set(0b100, 2); // set bits+        assert_eq!(g.kitty_flags(), 0b101);+        g.kitty_set(0b1, 3); // clear bits+        assert_eq!(g.kitty_flags(), 0b100);+        g.kitty_push(0b11); // push current, switch+        assert_eq!(g.kitty_flags(), 0b11);+        g.kitty_pop(1); // restore+        assert_eq!(g.kitty_flags(), 0b100);+        g.kitty_pop(5); // underflow is harmless+        assert_eq!(g.kitty_flags(), 0);+    }++    #[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/crates/beer/src/grid/search.rs b/crates/beer/src/grid/search.rsnew file mode 100644index 0000000..804d594--- /dev/null+++ b/crates/beer/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/crates/beer/src/grid/selection.rs b/crates/beer/src/grid/selection.rsnew file mode 100644index 0000000..74d1fe3--- /dev/null+++ b/crates/beer/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/crates/beer/src/main.rs b/crates/beer/src/main.rsnew file mode 100644index 0000000..3dc53a0--- /dev/null+++ b/crates/beer/src/main.rs@@ -0,0 +1,65 @@+//! beer, a fast, software-rendered, Wayland-native terminal emulator.++mod bindings;+mod config;+mod font;+mod grid;+mod pty;+mod render;+mod theme;+mod vt;+mod wayland;++use std::path::PathBuf;+use std::process::ExitCode;++use pound::Parse;++use crate::config::Config;++/// A fast, software-rendered, Wayland-native terminal emulator.+#[derive(Parse)]+#[pound(name = "beer", version = "0.2.0")]+struct Cli {+    /// Run as a daemon hosting multiple windows.+    #[pound(long)]+    server: bool,+    /// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml).+    #[pound(long)]+    config: Option<PathBuf>,+}++fn main() -> ExitCode {+    init_logging();+    match run(Cli::parse()) {+        Ok(code) => code,+        Err(err) => {+            tracing::error!("{err:#}");+            eprintln!("beer: {err:#}");+            ExitCode::FAILURE+        }+    }+}++fn init_logging() {+    use tracing_subscriber::{EnvFilter, fmt};++    let filter = EnvFilter::try_from_env("BEER_LOG")+        .or_else(|_| EnvFilter::try_from_default_env())+        .unwrap_or_else(|_| EnvFilter::new("warn"));++    fmt()+        .with_env_filter(filter)+        .with_writer(std::io::stderr)+        .init();+}++fn run(cli: Cli) -> anyhow::Result<ExitCode> {+    if cli.server {+        anyhow::bail!("server mode is not implemented yet");+    }++    let config = Config::load(cli.config.as_deref());+    tracing::info!("starting beer");+    wayland::run(config, cli.config)+}diff --git a/crates/beer/src/pty.rs b/crates/beer/src/pty.rsnew file mode 100644index 0000000..7bb559e--- /dev/null+++ b/crates/beer/src/pty.rs@@ -0,0 +1,117 @@+//! Pseudo-terminal: open a master/slave pair and run the user's shell on it.++use std::ffi::OsStr;+use std::io;+use std::os::fd::{AsFd, OwnedFd};+use std::os::unix::process::CommandExt;+use std::path::Path;+use std::process::{Child, Command, ExitStatus, Stdio};++use anyhow::Context;+use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};+use rustix::termios::{Winsize, tcsetwinsize};++/// A running child process attached to a PTY master.+#[derive(Debug)]+pub struct Pty {+    master: OwnedFd,+    child: Child,+}++impl Pty {+    /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on+    /// the slave end with `TERM=term`.+    pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {+        let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)+            .context("open pty master")?;+        grantpt(&master).context("grantpt")?;+        unlockpt(&master).context("unlockpt")?;+        let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY)+            .context("open pty slave")?;++        set_winsize(&master, cols, rows)?;++        let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());+        let argv0 = login_argv0(&shell);++        // Hand the slave to the child's stdio. try_clone gives O_CLOEXEC dups, so+        // the parent's copies and the controlling-tty handle vanish at exec.+        let ctty = slave.try_clone().context("dup slave")?;+        let (stdin, stdout, stderr) = (+            slave.try_clone().context("dup slave")?,+            slave.try_clone().context("dup slave")?,+            slave,+        );++        let mut cmd = Command::new(&shell);+        cmd.arg0(&argv0)+            .env("TERM", term)+            .env_remove("COLUMNS")+            .env_remove("LINES")+            .env_remove("TERMCAP")+            .stdin(Stdio::from(stdin))+            .stdout(Stdio::from(stdout))+            .stderr(Stdio::from(stderr));++        // SAFETY: setsid and the TIOCSCTTY ioctl are async-signal-safe raw+        // syscalls; ctty is a valid fd captured by move. We touch no parent+        // heap state, satisfying pre_exec's contract.+        unsafe {+            cmd.pre_exec(move || {+                rustix::process::setsid()?;+                rustix::process::ioctl_tiocsctty(&ctty)?;+                Ok(())+            });+        }++        let child = cmd.spawn().context("spawn shell")?;+        Ok(Self { master, child })+    }++    /// The PTY master, for reading child output and writing input.+    pub fn master(&self) -> &OwnedFd {+        &self.master+    }++    /// Inform the kernel (and thus the child) of a new terminal size.+    pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {+        set_winsize(&self.master, cols, rows)+    }++    /// Reap the child if it has exited.+    pub fn wait(&mut self) -> io::Result<ExitStatus> {+        self.child.wait()+    }+}++fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> {+    let ws = Winsize {+        ws_row: rows,+        ws_col: cols,+        ws_xpixel: 0,+        ws_ypixel: 0,+    };+    tcsetwinsize(master.as_fd(), ws).context("set pty winsize")+}++/// Login-shell argv[0] is the shell's basename with a leading '-'.+fn login_argv0(shell: &OsStr) -> std::ffi::OsString {+    let name = Path::new(shell)+        .file_name()+        .unwrap_or_else(|| OsStr::new("sh"));+    let mut argv0 = std::ffi::OsString::from("-");+    argv0.push(name);+    argv0+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn argv0_is_dash_prefixed_basename() {+        assert_eq!(login_argv0(OsStr::new("/usr/bin/bash")), "-bash");+        assert_eq!(login_argv0(OsStr::new("zsh")), "-zsh");+        assert_eq!(login_argv0(OsStr::new("")), "-sh");+    }+}diff --git a/crates/beer/src/render.rs b/crates/beer/src/render.rsnew file mode 100644index 0000000..d7206f0--- /dev/null+++ b/crates/beer/src/render.rs@@ -0,0 +1,683 @@+//! Software renderer: compose the grid into an ARGB8888 buffer.+//!+//! The target is a `wl_shm` buffer in `Argb8888`, which on little-endian is+//! `[B, G, R, A]` per pixel. Rendering is two passes per frame - backgrounds+//! then glyphs - so a wide glyph that overflows its cell is not clipped by the+//! neighbouring cell's background fill.++use std::num::NonZeroU16;++use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};+use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};+use crate::theme::{Plane, Rgb, Theme};++/// A mutable view over a BGRA pixel buffer.+struct Canvas<'a> {+    pixels: &'a mut [u8],+    width: usize,+    height: usize,+}++impl Canvas<'_> {+    fn index(&self, x: i32, y: i32) -> Option<usize> {+        if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height {+            return None;+        }+        Some((y as usize * self.width + x as usize) * 4)+    }++    fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {+        self.fill_rect_a(x0, y0, w, h, c, 0xff);+    }++    /// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is+    /// premultiplied ARGB, so a translucent fill stores `rgb * alpha`.+    fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) {+        let x_start = x0.max(0) as usize;+        let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);+        let y_start = y0.max(0) as usize;+        let y_end = ((y0 + h as i32).max(0) as usize).min(self.height);+        if x_start >= x_end {+            return;+        }+        let a = u32::from(alpha);+        let pm = |v: u8| ((u32::from(v) * a) / 255) as u8;+        let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha];+        for y in y_start..y_end {+            let row =+                &mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4];+            for px in row.chunks_exact_mut(4) {+                px.copy_from_slice(&bytes);+            }+        }+    }++    /// Alpha-blend `fg` over the existing pixel with coverage `a`.+    fn blend(&mut self, x: i32, y: i32, fg: Rgb, a: u8) {+        let Some(i) = self.index(x, y) else { return };+        let (a, inv) = (u32::from(a), u32::from(255 - a));+        let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8;+        self.pixels[i] = mix(fg.2, self.pixels[i]);+        self.pixels[i + 1] = mix(fg.1, self.pixels[i + 1]);+        self.pixels[i + 2] = mix(fg.0, self.pixels[i + 2]);+        self.pixels[i + 3] = 0xff;+    }++    /// Set a single opaque pixel.+    fn put(&mut self, x: i32, y: i32, c: Rgb) {+        if let Some(i) = self.index(x, y) {+            self.pixels[i] = c.2;+            self.pixels[i + 1] = c.1;+            self.pixels[i + 2] = c.0;+            self.pixels[i + 3] = 0xff;+        }+    }++    fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) {+        self.fill_rect(x0, y, w, 1, c);+    }++    /// Composite one pre-multiplied BGRA source pixel over the destination.+    fn over(&mut self, x: i32, y: i32, src: &[u8]) {+        let Some(i) = self.index(x, y) else { return };+        let inv = u32::from(255 - src[3]);+        let comp = |s: u8, dst: u8| (u32::from(s) + u32::from(dst) * inv / 255).min(255) as u8;+        self.pixels[i] = comp(src[0], self.pixels[i]);+        self.pixels[i + 1] = comp(src[1], self.pixels[i + 1]);+        self.pixels[i + 2] = comp(src[2], self.pixels[i + 2]);+        self.pixels[i + 3] = 0xff;+    }+}++/// Per-frame constants shared by every row: the colour scheme, focus, and the+/// current blink phase.+#[derive(Clone, Copy, Debug)]+pub struct Frame<'a> {+    pub theme: &'a Theme,+    pub focused: bool,+    pub blink_on: bool,+    /// Hyperlink currently under the pointer; its cells get a hover underline.+    pub hovered_link: Option<NonZeroU16>,+}++#[derive(Debug)]+pub struct Renderer {+    fonts: Fonts,+    /// Inner padding `(x, y)` in pixels between the window edge and the grid.+    pad: (i32, i32),+}++impl Renderer {+    pub fn new(fonts: Fonts) -> Self {+        Self { fonts, pad: (0, 0) }+    }++    pub fn metrics(&self) -> CellMetrics {+        self.fonts.metrics()+    }++    pub fn set_padding(&mut self, pad_x: u32, pad_y: u32) {+        self.pad = (pad_x as i32, pad_y as i32);+    }++    /// Rebuild the font set at a new size (font-resize bindings).+    pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> {+        self.fonts = Fonts::new(family, size_px)?;+        Ok(())+    }++    /// Fill the whole buffer (including the padding margins) with the background+    /// colour. Called once per fresh shm buffer; per-row repaints then leave the+    /// margins untouched.+    pub fn clear(&self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme) {+        let (width, height) = dims;+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        canvas.fill_rect_a(0, 0, width as u32, height as u32, theme.bg, theme.alpha);+    }++    /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):+    /// clear the row band, fill backgrounds (and selection), draw glyphs and+    /// decorations, then the cursor if it sits on this row. `blink_on` is the+    /// current blink phase; blinking cells and a blinking cursor vanish when it+    /// is `false`. Painting one row at a time is what lets the caller damage+    /// only the rows that actually changed.+    pub fn render_row(+        &mut self,+        pixels: &mut [u8],+        dims: (usize, usize),+        grid: &Grid,+        frame: &Frame,+        y: usize,+    ) {+        let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on);+        let (width, height) = dims;+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        let m = self.fonts.metrics();+        let (pad_x, pad_y) = self.pad;+        let cols = grid.cols();+        let row_top = pad_y + y as i32 * m.height as i32;+        canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha);++        // Rows come through the scrollback viewport and may be shorter than+        // `cols` after a resize, so clamp with `take`.+        let abs = grid.view_to_abs(y);+        let cells = grid.view_row(y);+        let search = grid.search_spans_on(abs);+        let match_at = |x: usize| -> Option<bool> {+            search+                .iter()+                .find(|(lo, hi, _)| x >= *lo && x <= *hi)+                .map(|(_, _, current)| *current)+        };+        for (x, cell) in cells.iter().take(cols).enumerate() {+            // Focused match > selection > other match > the cell's own bg.+            let bg = match match_at(x) {+                Some(true) => theme.current_match_bg,+                _ if grid.is_selected(abs, x) => theme.selection_bg,+                Some(false) => theme.match_bg,+                None => cell_colors(cell, theme).1,+            };+            if bg != theme.bg {+                canvas.fill_rect(+                    pad_x + x as i32 * m.width as i32,+                    row_top,+                    m.width,+                    m.height,+                    bg,+                );+            }+        }++        for (x, cell) in cells.iter().take(cols).enumerate() {+            if cell.flags.contains(Flags::WIDE_CONT) {+                continue;+            }+            if cell.flags.contains(Flags::BLINK) && !blink_on {+                continue;+            }+            let (fg, _) = cell_colors(cell, theme);+            let origin_x = pad_x + x as i32 * m.width as i32;+            let style = cell_style(cell);+            // A cell carrying combining marks is shaped as a cluster so the+            // marks land where the font's GPOS table wants them. Shaping returns+            // None for braille (drawn directly) and for clusters the face does+            // not fully cover, both of which fall through to the legacy path.+            let shaped = match &cell.combining {+                Some(marks) if !is_braille(cell.c) => {+                    self.fonts.shape_cluster(cell.c, marks, style)+                }+                _ => None,+            };+            if let Some(shaped) = shaped {+                for placed in &shaped.glyphs {+                    if let Ok(glyph) = self.fonts.glyph_indexed(shaped.face_idx, placed.gid, style)+                    {+                        blit_glyph(+                            &mut canvas,+                            glyph,+                            m,+                            origin_x + placed.x,+                            row_top,+                            placed.y,+                            fg,+                        );+                    }+                }+            } else if is_braille(cell.c) {+                // Drawn directly so the dots are crisp and fill the cell, the+                // way tools like btop expect, rather than however the fallback+                // font happens to size its braille glyphs.+                draw_braille(&mut canvas, cell.c, origin_x, row_top, m, fg);+            } else {+                if cell.c != ' ' {+                    self.draw_glyph(&mut canvas, cell.c, style, origin_x, row_top, fg);+                }+                // No shaper available for this cluster: stack the marks over the+                // base using each mark glyph's own bearings.+                if let Some(marks) = &cell.combining {+                    for mark in marks.chars() {+                        self.draw_glyph(&mut canvas, mark, style, origin_x, row_top, fg);+                    }+                }+            }+            draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);+            // Underline an OSC 8 hyperlink while the pointer hovers over it.+            if cell.link.is_some() && cell.link == frame.hovered_link {+                canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg);+            }+        }++        // The cursor belongs to the live screen; hide it while scrolled back.+        if grid.view_at_bottom() && grid.cursor().1 == y {+            self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on);+        }+    }++    /// Draw the incremental-search prompt across the bottom row, over whatever+    /// grid content was there. The caller marks the bottom row dirty so this+    /// repaints whenever the query or match count changes.+    pub fn render_search_bar(+        &mut self,+        pixels: &mut [u8],+        dims: (usize, usize),+        theme: &Theme,+        row: usize,+        text: &str,+    ) {+        let (width, height) = dims;+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        let m = self.fonts.metrics();+        let (pad_x, pad_y) = self.pad;+        let row_top = pad_y + row as i32 * m.height as i32;+        canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);+        let style = Style {+            bold: false,+            italic: false,+        };+        let mut x = pad_x;+        for c in text.chars() {+            if x as usize + m.width as usize > width {+                break;+            }+            if c != ' ' {+                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);+            }+            x += m.width as i32;+        }+    }++    /// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at+    /// viewport cell `(row, col)`, over whatever was there.+    pub fn render_label(+        &mut self,+        pixels: &mut [u8],+        dims: (usize, usize),+        theme: &Theme,+        row: usize,+        col: usize,+        text: &str,+    ) {+        let (width, height) = dims;+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        let m = self.fonts.metrics();+        let (pad_x, pad_y) = self.pad;+        let row_top = pad_y + row as i32 * m.height as i32;+        let style = Style {+            bold: true,+            italic: false,+        };+        let mut x = pad_x + col as i32 * m.width as i32;+        for c in text.chars() {+            if x as usize + m.width as usize > width {+                break;+            }+            canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg);+            if c != ' ' {+                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg);+            }+            x += m.width as i32;+        }+    }++    /// Draw the IME preedit string inline, starting at grid cell `start_col` of+    /// row `row`, over whatever was there. The preedit sits on the selection+    /// background and is underlined so it reads as uncommitted, in-flight text.+    pub fn render_preedit(+        &mut self,+        pixels: &mut [u8],+        dims: (usize, usize),+        theme: &Theme,+        row: usize,+        start_col: usize,+        text: &str,+    ) {+        let (width, height) = dims;+        let mut canvas = Canvas {+            pixels,+            width,+            height,+        };+        let m = self.fonts.metrics();+        let (pad_x, pad_y) = self.pad;+        let row_top = pad_y + row as i32 * m.height as i32;+        let style = Style {+            bold: false,+            italic: false,+        };+        let mut x = pad_x + start_col as i32 * m.width as i32;+        for c in text.chars() {+            if x < 0 || x as usize + m.width as usize > width {+                break;+            }+            canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg);+            if c != ' ' {+                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);+            }+            // Underline the run a row above the cell bottom.+            canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg);+            x += m.width as i32;+        }+    }++    /// Draw the cursor: a solid block/underline/beam when focused, a hollow+    /// outline when not. A blinking cursor shape is only drawn while `blink_on`.+    fn draw_cursor(+        &mut self,+        canvas: &mut Canvas,+        grid: &Grid,+        theme: &Theme,+        m: CellMetrics,+        focused: bool,+        blink_on: bool,+    ) {+        if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {+            return;+        }+        let (cx, cy) = grid.cursor();+        let x0 = self.pad.0 + cx as i32 * m.width as i32;+        let top = self.pad.1 + cy as i32 * m.height as i32;+        // OSC 12 cursor colour wins, then the configured cursor colour, then fg.+        let color = grid+            .cursor_color()+            .map(|(r, g, b)| Rgb(r, g, b))+            .or(theme.cursor)+            .unwrap_or(theme.fg);++        if !focused {+            let right = x0 + m.width as i32 - 1;+            let bottom = top + m.height as i32 - 1;+            canvas.hline(x0, top, m.width, color);+            canvas.hline(x0, bottom, m.width, color);+            canvas.fill_rect(x0, top, 1, m.height, color);+            canvas.fill_rect(right, top, 1, m.height, color);+            return;+        }++        match grid.cursor_shape() {+            CursorShape::Block => {+                canvas.fill_rect(x0, top, m.width, m.height, color);+                let cell = grid.cell(cx, cy);+                if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) {+                    let (_, bg) = cell_colors(cell, theme);+                    self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg);+                }+            }+            CursorShape::Underline => {+                canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color);+            }+            CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color),+        }+    }++    fn draw_glyph(+        &mut self,+        canvas: &mut Canvas,+        c: char,+        style: Style,+        origin_x: i32,+        cell_top: i32,+        fg: Rgb,+    ) {+        let m = self.fonts.metrics();+        let glyph = match self.fonts.glyph(c, style) {+            Ok(glyph) => glyph,+            Err(err) => {+                tracing::debug!("glyph {c:?}: {err}");+                return;+            }+        };+        blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);+    }+}++/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the+/// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's+/// vertical offset, 0 for the unshaped path).+fn blit_glyph(+    canvas: &mut Canvas,+    glyph: &Glyph,+    m: CellMetrics,+    origin_x: i32,+    cell_top: i32,+    rise: i32,+    fg: Rgb,+) {+    let (gw, gh) = (glyph.width as i32, glyph.height as i32);+    match &glyph.data {+        GlyphData::Mask(mask) => {+            let baseline = cell_top + m.ascent as i32 - rise;+            for gy in 0..gh {+                for gx in 0..gw {+                    let a = mask[(gy * gw + gx) as usize];+                    if a != 0 {+                        canvas.blend(origin_x + glyph.left + gx, baseline - glyph.top + gy, fg, a);+                    }+                }+            }+        }+        // Colour glyphs (emoji) come from a fixed strike at native size;+        // scale them to the line height with nearest-neighbour sampling.+        GlyphData::Color(bgra) if gh > 0 => {+            let scale = m.height as f32 / gh as f32;+            let target_w = (gw as f32 * scale) as i32;+            for ty in 0..m.height as i32 {+                let sy = ((ty as f32 / scale) as i32).min(gh - 1);+                for tx in 0..target_w {+                    let sx = ((tx as f32 / scale) as i32).min(gw - 1);+                    let i = ((sy * gw + sx) * 4) as usize;+                    canvas.over(origin_x + tx, cell_top + ty, &bgra[i..i + 4]);+                }+            }+        }+        GlyphData::Color(_) => {}+    }+}++fn cell_style(cell: &Cell) -> Style {+    Style {+        bold: cell.flags.contains(Flags::BOLD),+        italic: cell.flags.contains(Flags::ITALIC),+    }+}++/// Resolve a cell's (foreground, background) RGB, applying reverse video,+/// bold-as-bright, dim, and hidden.+fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) {+    let bold = cell.flags.contains(Flags::BOLD);+    let mut fg = theme.resolve(cell.fg, Plane::Fg, bold);+    let mut bg = theme.resolve(cell.bg, Plane::Bg, false);+    if cell.flags.contains(Flags::REVERSE) {+        std::mem::swap(&mut fg, &mut bg);+    }+    if cell.flags.contains(Flags::DIM) {+        fg = blend_rgb(fg, bg);+    }+    if cell.flags.contains(Flags::HIDDEN) {+        fg = bg;+    }+    (fg, bg)+}++/// Mix `c` two-thirds of the way from `toward`, used for the dim attribute.+fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb {+    let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8;+    Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2))+}++/// Whether `c` is a Braille Patterns codepoint (U+2800-U+28FF).+fn is_braille(c: char) -> bool {+    ('\u{2800}'..='\u{28ff}').contains(&c)+}++/// Braille dot geometry for a `width`×`height` cell: the square dot side `w`,+/// the two column origins, and the four row origins. Ported verbatim from+/// foot's `box-drawing.c` `draw_braille` - base size and spacing from the cell,+/// then leftover pixels distributed (dot → margin → spacing → margin → dot) so+/// dots land on exact pixels with no rounding drift.+fn braille_geometry(width: i32, height: i32) -> (u32, [i32; 2], [i32; 4]) {+    let mut w = (width / 4).min(height / 8);+    let mut x_spacing = width / 4;+    let mut y_spacing = height / 8;+    let mut x_margin = x_spacing / 2;+    let mut y_margin = y_spacing / 2;++    let mut x_left = width - 2 * x_margin - x_spacing - 2 * w;+    let mut y_left = height - 2 * y_margin - 3 * y_spacing - 4 * w;++    // First, try hard to ensure a non-zero dot width.+    if x_left >= 2 && y_left >= 4 && w == 0 {+        w += 1;+        x_left -= 2;+        y_left -= 4;+    }+    // Second, prefer a non-zero margin.+    if x_left >= 2 && x_margin == 0 {+        x_margin = 1;+        x_left -= 2;+    }+    if y_left >= 2 && y_margin == 0 {+        y_margin = 1;+        y_left -= 2;+    }+    // Third, increase spacing.+    if x_left >= 1 {+        x_spacing += 1;+        x_left -= 1;+    }+    if y_left >= 3 {+        y_spacing += 1;+        y_left -= 3;+    }+    // Fourth, the side margins.+    if x_left >= 2 {+        x_margin += 1;+        x_left -= 2;+    }+    if y_left >= 2 {+        y_margin += 1;+        y_left -= 2;+    }+    // Last, increase the dot width.+    if x_left >= 2 && y_left >= 4 {+        w += 1;+    }++    let xs = [x_margin, x_margin + w + x_spacing];+    let ys = [+        y_margin,+        y_margin + w + y_spacing,+        y_margin + 2 * (w + y_spacing),+        y_margin + 3 * (w + y_spacing),+    ];+    (w.max(0) as u32, xs, ys)+}++/// Draw a braille pattern as a 2×4 grid of `w`×`w` square dots. Geometry ported+/// from foot's `box-drawing.c` `draw_braille`: a dot size and base spacing are+/// derived from the cell, then leftover pixels are distributed (dot width →+/// margins → spacing → …) so dots land on exact pixels with no rounding drift.+/// The low eight bits of the codepoint select dots: bits 0-2 are the left+/// column rows 0-2, bits 3-5 the right column rows 0-2, bits 6-7 the bottom row.+fn draw_braille(canvas: &mut Canvas, c: char, x0: i32, top: i32, m: CellMetrics, fg: Rgb) {+    let (w, xs, ys) = braille_geometry(m.width as i32, m.height as i32);+    let sym = ((c as u32) - 0x2800) as u8;+    // (bit mask, column index, row index).+    const DOTS: [(u8, usize, usize); 8] = [+        (0x01, 0, 0),+        (0x02, 0, 1),+        (0x04, 0, 2),+        (0x08, 1, 0),+        (0x10, 1, 1),+        (0x20, 1, 2),+        (0x40, 0, 3),+        (0x80, 1, 3),+    ];+    for (mask, col, row) in DOTS {+        if sym & mask != 0 {+            canvas.fill_rect(x0 + xs[col], top + ys[row], w, w, fg);+        }+    }+}++/// Draw underline, strikethrough, and overline for one cell.+fn draw_decorations(+    canvas: &mut Canvas,+    cell: &Cell,+    theme: &Theme,+    x0: i32,+    top: i32,+    m: CellMetrics,+    fg: Rgb,+) {+    let w = m.width;+    let baseline = top + m.ascent as i32;+    let uy = (baseline + 1).min(top + m.height as i32 - 1);+    // A `Default` underline colour follows the cell's foreground.+    let uc = match cell.underline_color {+        crate::grid::Color::Default => fg,+        other => theme.resolve(other, Plane::Fg, false),+    };+    match cell.underline {+        Underline::None => {}+        Underline::Single => canvas.hline(x0, uy, w, uc),+        Underline::Double => {+            canvas.hline(x0, uy, w, uc);+            canvas.hline(x0, (uy - 2).max(top), w, uc);+        }+        Underline::Curly => {+            for dx in 0..w as i32 {+                let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 };+                canvas.put(x0 + dx, uy - wobble, uc);+            }+        }+        Underline::Dotted => {+            for dx in (0..w as i32).step_by(2) {+                canvas.put(x0 + dx, uy, uc);+            }+        }+        Underline::Dashed => {+            for dx in 0..w as i32 {+                if (dx / 3) % 2 == 0 {+                    canvas.put(x0 + dx, uy, uc);+                }+            }+        }+    }+    if cell.flags.contains(Flags::OVERLINE) {+        canvas.hline(x0, top, w, fg);+    }+    if cell.flags.contains(Flags::STRIKE) {+        canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);+    }+}++#[cfg(test)]+mod tests {+    use super::braille_geometry;++    // Pinned to foot box-drawing.c draw_braille output (cross-checked numerically+    // identical across cell sizes 4..30 x 6..48); guards against drift.+    #[test]+    fn braille_geometry_matches_foot() {+        assert_eq!(braille_geometry(8, 18), (2, [1, 5], [2, 6, 10, 14]));+        assert_eq!(braille_geometry(10, 20), (2, [1, 6], [1, 6, 11, 16]));+        assert_eq!(braille_geometry(12, 27), (3, [1, 8], [1, 8, 15, 22]));+        assert_eq!(braille_geometry(7, 15), (1, [1, 4], [2, 5, 8, 11]));+    }+}diff --git a/crates/beer/src/theme.rs b/crates/beer/src/theme.rsnew file mode 100644index 0000000..bdf6069--- /dev/null+++ b/crates/beer/src/theme.rs@@ -0,0 +1,256 @@+//! Runtime colours: the foreground/background, the 256-entry palette, and the+//! selection/search/cursor accents. Seeded from config and mutated at runtime+//! by OSC colour escapes (4/104, 10/11, 17/19, and their resets).++use crate::config::Colors as ColorConfig;++/// An 8-bit-per-channel RGB colour.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub struct Rgb(pub u8, pub u8, pub u8);++/// Where a `Color::Default` resolves to.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Plane {+    Fg,+    Bg,+}++/// The active colour scheme.+#[derive(Clone, Debug)]+pub struct Theme {+    pub fg: Rgb,+    pub bg: Rgb,+    /// Cursor body colour; `None` follows the cell under the cursor.+    pub cursor: Option<Rgb>,+    pub selection_fg: Option<Rgb>,+    pub selection_bg: Rgb,+    pub match_bg: Rgb,+    pub current_match_bg: Rgb,+    pub search_bar_bg: Rgb,+    pub palette: [Rgb; 256],+    pub bold_as_bright: bool,+    /// Background opacity, 0 (transparent) - 255 (opaque).+    pub alpha: u8,+    /// Configured defaults, restored by OSC reset escapes (110/111/104).+    default_fg: Rgb,+    default_bg: Rgb,+    default_palette: [Rgb; 256],+}++impl Default for Theme {+    fn default() -> Self {+        let palette = default_palette();+        Self {+            fg: DEFAULT_FG,+            bg: DEFAULT_BG,+            cursor: None,+            selection_fg: None,+            selection_bg: SELECTION_BG,+            match_bg: MATCH_BG,+            current_match_bg: CURRENT_MATCH_BG,+            search_bar_bg: SEARCH_BAR_BG,+            palette,+            bold_as_bright: true,+            alpha: 255,+            default_fg: DEFAULT_FG,+            default_bg: DEFAULT_BG,+            default_palette: palette,+        }+    }+}++impl Theme {+    /// Build a theme from the `[colors]` config table, falling back to the+    /// built-in defaults for any unset entry.+    pub fn from_config(c: &ColorConfig) -> Self {+        let mut theme = Self::default();+        let set = |dst: &mut Rgb, spec: &Option<String>| {+            if let Some(rgb) = spec.as_deref().and_then(parse_color) {+                *dst = rgb;+            }+        };+        set(&mut theme.fg, &c.foreground);+        set(&mut theme.bg, &c.background);+        set(&mut theme.selection_bg, &c.selection_background);+        set(&mut theme.match_bg, &c.match_background);+        set(&mut theme.current_match_bg, &c.match_current_background);+        theme.cursor = c.cursor.as_deref().and_then(parse_color);+        theme.selection_fg = c.selection_foreground.as_deref().and_then(parse_color);+        if let Some(regular) = &c.regular {+            apply_palette(&mut theme.palette, 0, regular);+        }+        if let Some(bright) = &c.bright {+            apply_palette(&mut theme.palette, 8, bright);+        }+        if let Some(a) = c.alpha {+            theme.alpha = (a.clamp(0.0, 1.0) * 255.0).round() as u8;+        }+        if let Some(b) = c.bold_as_bright {+            theme.bold_as_bright = b;+        }+        // Remember the resolved scheme so OSC resets return here, not to the+        // compiled-in defaults.+        theme.default_fg = theme.fg;+        theme.default_bg = theme.bg;+        theme.default_palette = theme.palette;+        theme+    }++    /// Resolve a cell colour to RGB on the given plane, applying bold-as-bright+    /// to the low 8 palette indices.+    pub fn resolve(&self, color: crate::grid::Color, plane: Plane, bold: bool) -> Rgb {+        use crate::grid::Color;+        match color {+            Color::Default => match plane {+                Plane::Fg => self.fg,+                Plane::Bg => self.bg,+            },+            Color::Indexed(i) => {+                let idx = if bold && self.bold_as_bright && i < 8 {+                    i + 8+                } else {+                    i+                };+                self.palette[idx as usize]+            }+            Color::Rgb(r, g, b) => Rgb(r, g, b),+        }+    }++    pub fn set_palette(&mut self, index: u8, rgb: Rgb) {+        self.palette[index as usize] = rgb;+    }++    /// Reset palette entry `index` to its configured value.+    pub fn reset_palette_index(&mut self, index: u8) {+        self.palette[index as usize] = self.default_palette[index as usize];+    }++    /// Reset the whole palette to its configured values.+    pub fn reset_palette(&mut self) {+        self.palette = self.default_palette;+    }++    pub fn reset_fg(&mut self) {+        self.fg = self.default_fg;+    }++    pub fn reset_bg(&mut self) {+        self.bg = self.default_bg;+    }++    /// A copy with foreground and background swapped, for the visual bell flash.+    pub fn inverted(&self) -> Self {+        let mut t = self.clone();+        std::mem::swap(&mut t.fg, &mut t.bg);+        t+    }+}++/// Foreground/background used for `Color::Default`.+const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);+const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);+const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);+const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);+const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);+const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);++/// Overwrite palette entries `[base, base+specs.len())` from hex specs.+fn apply_palette(palette: &mut [Rgb; 256], base: usize, specs: &[String]) {+    for (i, spec) in specs.iter().take(8).enumerate() {+        if let Some(rgb) = parse_color(spec) {+            palette[base + i] = rgb;+        }+    }+}++/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.+fn default_palette() -> [Rgb; 256] {+    const BASE: [Rgb; 16] = [+        Rgb(0x00, 0x00, 0x00),+        Rgb(0xcd, 0x00, 0x00),+        Rgb(0x00, 0xcd, 0x00),+        Rgb(0xcd, 0xcd, 0x00),+        Rgb(0x00, 0x00, 0xee),+        Rgb(0xcd, 0x00, 0xcd),+        Rgb(0x00, 0xcd, 0xcd),+        Rgb(0xe5, 0xe5, 0xe5),+        Rgb(0x7f, 0x7f, 0x7f),+        Rgb(0xff, 0x00, 0x00),+        Rgb(0x00, 0xff, 0x00),+        Rgb(0xff, 0xff, 0x00),+        Rgb(0x5c, 0x5c, 0xff),+        Rgb(0xff, 0x00, 0xff),+        Rgb(0x00, 0xff, 0xff),+        Rgb(0xff, 0xff, 0xff),+    ];+    let cube = |step: u8| -> u8 { if step == 0 { 0 } else { 55 + 40 * step } };+    std::array::from_fn(|i| match i {+        0..=15 => BASE[i],+        16..=231 => {+            let i = i as u8 - 16;+            Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))+        }+        _ => {+            let v = 8 + 10 * (i as u8 - 232);+            Rgb(v, v, v)+        }+    })+}++/// Parse an X11 colour spec: `rgb:rr/gg/bb` (1-4 hex digits per channel) or+/// `#rrggbb`.+pub fn parse_color(spec: &str) -> Option<Rgb> {+    if let Some(rest) = spec.strip_prefix("rgb:") {+        let mut it = rest.split('/');+        let chan = |s: &str| -> Option<u8> {+            let v = u32::from_str_radix(s, 16).ok()?;+            let max = (1u32 << (4 * s.len() as u32)) - 1;+            Some((v * 255 / max) as u8)+        };+        return Some(Rgb(chan(it.next()?)?, chan(it.next()?)?, chan(it.next()?)?));+    }+    let hex = spec.strip_prefix('#')?;+    if hex.len() == 6 {+        let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();+        return Some(Rgb(byte(0)?, byte(2)?, byte(4)?));+    }+    None+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn parses_color_forms() {+        assert_eq!(parse_color("#ff0080"), Some(Rgb(255, 0, 128)));+        assert_eq!(parse_color("rgb:ff/00/80"), Some(Rgb(255, 0, 128)));+        assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(Rgb(255, 0, 128)));+        assert_eq!(parse_color("nope"), None);+    }++    #[test]+    fn palette_cube_and_grey() {+        let p = default_palette();+        assert_eq!(p[16], Rgb(0, 0, 0));+        assert_eq!(p[231], Rgb(255, 255, 255));+        assert_eq!(p[232], Rgb(8, 8, 8));+    }++    #[test]+    fn config_overrides_and_resets() {+        let cfg = ColorConfig {+            background: Some("#000000".to_string()),+            regular: Some(vec!["#111111".to_string()]),+            ..ColorConfig::default()+        };+        let mut theme = Theme::from_config(&cfg);+        assert_eq!(theme.bg, Rgb(0, 0, 0));+        assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));+        // OSC reset returns to the configured value, not the compiled default.+        theme.set_palette(0, Rgb(9, 9, 9));+        theme.reset_palette_index(0);+        assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));+    }+}diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rsnew file mode 100644index 0000000..4021cc5--- /dev/null+++ b/crates/beer/src/vt/mod.rs@@ -0,0 +1,772 @@+//! VT emulation: feed bytes through `vte` and drive the [`Grid`].++mod perform;++use std::io::Write as _;++use vte::Params;++use beer_protocols::caps::cap_value;+use beer_protocols::charset::{Charset, charset, dec_special};+use beer_protocols::codec::{base64_decode, decode_hex, file_uri_path};+use beer_protocols::sgr::{ext_color, underline_from};+use beer_protocols::style::prompt_kind;++use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline};+use crate::theme::{Rgb, Theme};++/// 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 }+}++/// 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\\"),+        }+    }++    /// Reply to a kitty-keyboard flags query (`CSI ? u`) with `CSI ? flags u`.+    fn report_kitty_flags(&mut self) {+        let _ = write!(self.response, "\x1b[?{}u", self.grid.kitty_flags());+    }++    /// 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)+}++/// 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())+}++#[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 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/crates/beer/src/vt/perform.rs b/crates/beer/src/vt/perform.rsnew file mode 100644index 0000000..202f29f--- /dev/null+++ b/crates/beer/src/vt/perform.rs@@ -0,0 +1,250 @@+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(),+            // `CSI u` is SCORC, but the kitty keyboard protocol overloads it with+            // private prefixes: `?` query, `>` push, `<` pop, `=` set flags.+            'u' => match intermediates.first() {+                Some(b'?') => self.report_kitty_flags(),+                Some(b'>') => self.grid.kitty_push(n(params, 0, 0) as u8),+                Some(b'<') => self.grid.kitty_pop(n(params, 0, 1)),+                Some(b'=') => self+                    .grid+                    .kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),+                _ => 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/crates/beer/src/wayland/handlers.rs b/crates/beer/src/wayland/handlers.rsnew file mode 100644index 0000000..95002e4--- /dev/null+++ b/crates/beer/src/wayland/handlers.rs@@ -0,0 +1,883 @@+use super::*;+use crate::bindings::MouseButton;++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();+        self.sync_idle_inhibit();+        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 i = self.seat_index(&seat);+        self.ensure_clipboard_devices(qh, &seat, i);+    }++    fn new_capability(+        &mut self,+        _: &Connection,+        qh: &QueueHandle<Self>,+        seat: wl_seat::WlSeat,+        capability: Capability,+    ) {+        let i = self.seat_index(&seat);+        // A pre-existing seat may reach us via its capabilities without a+        // `new_seat` call, so make sure the clipboard devices are attached.+        self.ensure_clipboard_devices(qh, &seat, i);+        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}"),+            }+        }+        if capability == Capability::Touch && self.seats[i].touch.is_none() {+            match self.seat_state.get_touch(qh, &seat) {+                Ok(touch) => self.seats[i].touch = Some(touch),+                Err(err) => tracing::warn!("get touch: {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();+                }+            }+            Capability::Touch => {+                if let Some(touch) = s.touch.take() {+                    touch.release();+                }+                self.touch_scroll = None;+            }+            _ => {}+        }+    }++    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.sync_idle_inhibit();+        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.sync_idle_inhibit();+        // Drop held-key state so a key released while unfocused can't leak a+        // stale kitty release event later.+        self.keys_down.clear();+        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,+        event: KeyEvent,+    ) {+        self.handle_key_release(&event);+    }++    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,+    }+}++/// Map a Wayland button code to a bindable [`MouseButton`].+fn mouse_button(button: u32) -> Option<MouseButton> {+    match button {+        BTN_LEFT => Some(MouseButton::Left),+        BTN_MIDDLE => Some(MouseButton::Middle),+        BTN_RIGHT => Some(MouseButton::Right),+        _ => 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;+                    }+                    // A configured `[mouse-bindings]` action (Middle defaults to+                    // primary paste) fires before the built-in left-drag select.+                    if let Some(mb) = mouse_button(*button)+                        && let Some(action) = self.bindings.mouse_action(mb, self.modifiers)+                    {+                        self.dispatch_action(action);+                        continue;+                    }+                    if *button == BTN_LEFT {+                        self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+                        self.pointer_press(*time);+                    }+                }+                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;+                    }+                }+                _ => {}+            }+        }+    }+}++// Touch drives one-finger drag-to-scroll of the scrollback viewport. Taps and+// multi-finger gestures are ignored; only the first touch point is tracked.+impl TouchHandler for App {+    fn down(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_touch::WlTouch,+        _serial: u32,+        _time: u32,+        _surface: wl_surface::WlSurface,+        id: i32,+        position: (f64, f64),+    ) {+        if self.touch_scroll.is_none() {+            self.touch_scroll = Some(TouchScroll {+                id,+                last_y: position.1,+                acc: 0.0,+            });+        }+    }++    fn up(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_touch::WlTouch,+        _serial: u32,+        _time: u32,+        id: i32,+    ) {+        if self.touch_scroll.as_ref().is_some_and(|t| t.id == id) {+            self.touch_scroll = None;+        }+    }++    fn motion(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_touch::WlTouch,+        _time: u32,+        id: i32,+        position: (f64, f64),+    ) {+        let cell_h = self.renderer.metrics().height as f64;+        let Some(touch) = self.touch_scroll.as_mut().filter(|t| t.id == id) else {+            return;+        };+        touch.acc += position.1 - touch.last_y;+        touch.last_y = position.1;+        // Dragging down (positive delta) reveals older lines, matching the+        // viewport's "positive scrolls back" convention.+        let lines = (touch.acc / cell_h) as isize;+        if lines != 0 {+            touch.acc -= lines as f64 * cell_h;+            if let Some(session) = self.session.as_mut() {+                session.term.scroll_view(lines);+                self.needs_draw = true;+            }+        }+    }++    fn shape(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_touch::WlTouch,+        _: i32,+        _: f64,+        _: f64,+    ) {+    }++    fn orientation(+        &mut self,+        _: &Connection,+        _: &QueueHandle<Self>,+        _: &wl_touch::WlTouch,+        _: i32,+        _: f64,+    ) {+    }++    fn cancel(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_touch::WlTouch) {+        self.touch_scroll = None;+    }+}++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>,+    ) {+    }+}++// idle-inhibit and content-type are likewise raw protocol objects; none of them+// emit events we act on.+impl Dispatch<ZwpIdleInhibitManagerV1, ()> for App {+    fn event(+        _: &mut Self,+        _: &ZwpIdleInhibitManagerV1,+        _: <ZwpIdleInhibitManagerV1 as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl Dispatch<ZwpIdleInhibitorV1, ()> for App {+    fn event(+        _: &mut Self,+        _: &ZwpIdleInhibitorV1,+        _: zwp_idle_inhibitor_v1::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl Dispatch<WpContentTypeManagerV1, ()> for App {+    fn event(+        _: &mut Self,+        _: &WpContentTypeManagerV1,+        _: <WpContentTypeManagerV1 as Proxy>::Event,+        _: &(),+        _: &Connection,+        _: &QueueHandle<Self>,+    ) {+    }+}++impl Dispatch<WpContentTypeV1, ()> for App {+    fn event(+        _: &mut Self,+        _: &WpContentTypeV1,+        _: wp_content_type_v1::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_touch!(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/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rsnew file mode 100644index 0000000..53f8e29--- /dev/null+++ b/crates/beer/src/wayland/mod.rs@@ -0,0 +1,1893 @@+//! 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_touch, 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,+        },+        touch::TouchHandler,+    },+    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, wl_touch,+    },+};+use wayland_protocols::wp::content_type::v1::client::{+    wp_content_type_manager_v1::WpContentTypeManagerV1,+    wp_content_type_v1::{self, WpContentTypeV1},+};+use wayland_protocols::wp::fractional_scale::v1::client::{+    wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,+    wp_fractional_scale_v1::{self, WpFractionalScaleV1},+};+use wayland_protocols::wp::idle_inhibit::zv1::client::{+    zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,+    zwp_idle_inhibitor_v1::{self, ZwpIdleInhibitorV1},+};+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();+    let idle_inhibit_manager = bind_global::<ZwpIdleInhibitManagerV1>(&globals, &qh);+    // Tag the surface as plain content (a terminal is none of photo/video/game)+    // so the compositor applies no media-specific treatment. Applies on commit.+    let content_type = bind_global::<WpContentTypeManagerV1>(&globals, &qh)+        .map(|mgr| mgr.get_surface_content_type(window.wl_surface(), &qh, ()));+    if let Some(ct) = &content_type {+        ct.set_content_type(wp_content_type_v1::Type::None);+    }++    // 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,+        &config.mouse_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,+        idle_inhibit_manager,+        idle_inhibitor: None,+        content_type,+        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(),+        unicode_input: None,+        keys_down: std::collections::HashSet::new(),+        touch_scroll: None,+        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>,+    touch: Option<wl_touch::WlTouch>,+}++/// A single-finger touch drag in progress, used to scroll the viewport.+#[derive(Debug)]+struct TouchScroll {+    /// Touch point id we are tracking (the first finger down).+    id: i32,+    /// Surface-local y of the last motion, to take per-event deltas.+    last_y: f64,+    /// Sub-cell pixel remainder carried between motions.+    acc: f64,+}++/// 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>,+    /// idle-inhibit-v1 manager; an inhibitor is held while focused when the+    /// `[main] idle-inhibit` config is on, so the screen does not blank.+    idle_inhibit_manager: Option<ZwpIdleInhibitManagerV1>,+    idle_inhibitor: Option<ZwpIdleInhibitorV1>,+    /// content-type-v1 hint object. Set once at startup; held only so the+    /// object (and thus the hint) outlives construction.+    #[allow(+        dead_code,+        reason = "kept alive to preserve the surface content-type hint"+    )]+    content_type: Option<WpContentTypeV1>,+    /// 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,+    /// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.+    unicode_input: Option<String>,+    /// Raw key codes currently held, to tell press from repeat for the kitty+    /// keyboard protocol's event-type reporting.+    keys_down: std::collections::HashSet<u32>,+    /// A single-finger touch drag in progress (scrolls the viewport).+    touch_scroll: Option<TouchScroll>,+    /// 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) {+        // A new arrival of a held key is a repeat; otherwise a fresh press.+        let kind = if self.keys_down.insert(event.raw_code) {+            beer_protocols::key::KeyKind::Press+        } else {+            beer_protocols::key::KeyKind::Repeat+        };++        // The Unicode-input prompt, URL hint mode, and search each capture the+        // keyboard while active.+        if self.unicode_input.is_some() {+            self.unicode_key(event);+            return;+        }+        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, kitty) = self.session.as_ref().map_or((false, 0), |s| {+            (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+        });+        let bytes = if kitty != 0 {+            beer_protocols::key::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)+        } else {+            beer_protocols::key::encode(event, self.modifiers, app_cursor)+        };+        if let Some(bytes) = bytes {+            self.send_to_shell(&bytes);+        }+    }++    /// Handle a key release: only the kitty keyboard protocol cares, and only+    /// when it has asked for event reporting.+    fn handle_key_release(&mut self, event: &KeyEvent) {+        self.keys_down.remove(&event.raw_code);+        let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {+            (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+        });+        if kitty == 0 {+            return;+        }+        if let Some(bytes) = beer_protocols::key::kitty_encode(+            event,+            self.modifiers,+            kitty,+            beer_protocols::key::KeyKind::Release,+            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(),+            Action::UnicodeInput => {+                self.unicode_input = Some(String::new());+                self.needs_draw = true;+            }+        }+    }++    /// Handle a key while Unicode codepoint-input mode is active: accumulate hex+    /// digits, then commit the codepoint as UTF-8 on Enter/Space.+    fn unicode_key(&mut self, event: &KeyEvent) {+        match event.keysym {+            Keysym::Escape => {+                self.unicode_input = None;+                self.needs_draw = true;+            }+            Keysym::BackSpace => {+                if let Some(buf) = self.unicode_input.as_mut() {+                    buf.pop();+                }+                self.needs_draw = true;+            }+            Keysym::Return | Keysym::KP_Enter | Keysym::space => {+                let buf = self.unicode_input.take().unwrap_or_default();+                if let Some(c) = u32::from_str_radix(buf.trim(), 16)+                    .ok()+                    .and_then(char::from_u32)+                {+                    let mut bytes = [0u8; 4];+                    let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();+                    self.send_to_shell(&s);+                }+                self.needs_draw = true;+            }+            _ => {+                if let Some(text) = event.utf8.as_ref() {+                    let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();+                    // Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.+                    if let Some(buf) = self.unicode_input.as_mut()+                        && buf.len() + hex.len() <= 6+                    {+                        buf.push_str(&hex);+                    }+                    self.needs_draw = true;+                }+            }+        }+    }++    /// 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,+            &new.mouse_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)+    }++    /// Attach the seat-scoped clipboard and primary-selection devices to seat+    /// `i` if not already present. Idempotent, so it can run from either+    /// `new_seat` or `new_capability` - some compositors surface a pre-existing+    /// seat's capabilities without ever firing `new_seat`.+    fn ensure_clipboard_devices(+        &mut self,+        qh: &QueueHandle<Self>,+        seat: &wl_seat::WlSeat,+        i: usize,+    ) {+        if self.seats[i].data_device.is_none() {+            let dd = self.data_device_manager.get_data_device(qh, seat);+            self.seats[i].data_device = Some(dd);+        }+        if self.seats[i].primary_device.is_none()+            && let Some(m) = self.primary_manager.as_ref()+        {+            let pd = m.get_selection_device(qh, seat);+            self.seats[i].primary_device = Some(pd);+        }+    }++    /// 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,+            touch: 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 = beer_protocols::mouse::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 = beer_protocols::mouse::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" });+        }+    }++    /// Create or drop the idle inhibitor to match `[main] idle-inhibit` and the+    /// current focus: inhibit only while focused, so a backgrounded terminal+    /// still lets the screen blank. Idempotent; called on every focus change.+    fn sync_idle_inhibit(&mut self) {+        let want = self.config.main.idle_inhibit && self.focused;+        if want && self.idle_inhibitor.is_none() {+            if let Some(mgr) = &self.idle_inhibit_manager {+                self.idle_inhibitor =+                    Some(mgr.create_inhibitor(self.window.wl_surface(), &self.qh, ()));+            }+        } else if !want && let Some(inhibitor) = self.idle_inhibitor.take() {+            inhibitor.destroy();+        }+    }++    /// 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",+                        beer_protocols::codec::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 {+            tracing::debug!("paste: no clipboard selection offer");+            return;+        };+        let Some(mime) = offer.with_mime_types(pick_mime) else {+            tracing::debug!("paste: no acceptable text mime type offered");+            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) => {+                        tracing::debug!("paste: read {} bytes from clipboard", data.len());+                        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/crates/beer/src/wayland/rendering.rs b/crates/beer/src/wayland/rendering.rsnew file mode 100644index 0000000..9c69731--- /dev/null+++ b/crates/beer/src/wayland/rendering.rs@@ -0,0 +1,231 @@+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 = if let Some(hex) = &self.unicode_input {+            Some(format!("unicode: U+{}", hex.to_uppercase()))+        } else {+            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;+    }+}diff --git a/nix/package.nix b/nix/package.nixindex 1b302de..5c19725 100644--- a/nix/package.nix+++ b/nix/package.nix@@ -12,11 +12,11 @@   fontconfig,   harfbuzz, }: let-  cargoTOML = (lib.importTOML ../Cargo.toml).package.version;+  cargoTOML = lib.importTOML ../Cargo.toml; in   rustPlatform.buildRustPackage (finalAttrs: {     pname = "beer";-    version = cargoTOML.package.version;+    version = cargoTOML.workspace.package.version;      src = let       fs = lib.fileset;@@ -25,7 +25,7 @@ in       fs.toSource {         root = s;         fileset = fs.unions [-          (s + /src)+          (s + /crates)           (s + /Cargo.lock)           (s + /Cargo.toml)           (s + /terminfo)diff --git a/src/bindings.rs b/src/bindings.rsdeleted file mode 100644index 21f6837..0000000--- a/src/bindings.rs+++ /dev/null@@ -1,457 +0,0 @@-//! Configurable key bindings: chord strings (e.g. `Ctrl+Shift+C`) mapped to-//! editor actions, plus text bindings that send a literal string.--use std::collections::HashMap;--use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};--/// An action a key binding can trigger.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum Action {-    Copy,-    Paste,-    PastePrimary,-    ScrollPageUp,-    ScrollPageDown,-    ScrollTop,-    ScrollBottom,-    SearchStart,-    FontIncrease,-    FontDecrease,-    FontReset,-    Fullscreen,-    NewWindow,-    JumpPromptUp,-    JumpPromptDown,-    PipeCommandOutput,-    UrlMode,-    UnicodeInput,-}--impl Action {-    fn parse(name: &str) -> Option<Self> {-        Some(match name {-            "copy" => Self::Copy,-            "paste" => Self::Paste,-            "paste-primary" => Self::PastePrimary,-            "scrollback-up" => Self::ScrollPageUp,-            "scrollback-down" => Self::ScrollPageDown,-            "scrollback-top" => Self::ScrollTop,-            "scrollback-bottom" => Self::ScrollBottom,-            "search" => Self::SearchStart,-            "font-increase" => Self::FontIncrease,-            "font-decrease" => Self::FontDecrease,-            "font-reset" => Self::FontReset,-            "fullscreen" => Self::Fullscreen,-            "new-window" => Self::NewWindow,-            "jump-prompt-up" => Self::JumpPromptUp,-            "jump-prompt-down" => Self::JumpPromptDown,-            "pipe-command-output" => Self::PipeCommandOutput,-            "url-mode" => Self::UrlMode,-            "unicode-input" => Self::UnicodeInput,-            _ => return None,-        })-    }-}--/// A parsed chord: a key plus the modifiers that must be held exactly.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct Chord {-    key: Keysym,-    ctrl: bool,-    shift: bool,-    alt: bool,-    logo: bool,-}--impl Chord {-    /// Parse a chord like `Ctrl+Shift+C`. Modifier order is irrelevant; the-    /// final token is the key. Returns `None` if no key is recognized.-    fn parse(spec: &str) -> Option<Self> {-        let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);-        let mut key = None;-        for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {-            match token.to_ascii_lowercase().as_str() {-                "ctrl" | "control" => ctrl = true,-                "shift" => shift = true,-                "alt" | "mod1" | "meta" => alt = true,-                "super" | "logo" | "cmd" | "mod4" => logo = true,-                _ => key = keysym_from_token(token),-            }-        }-        Some(Self {-            key: key?,-            ctrl,-            shift,-            alt,-            logo,-        })-    }--    /// Whether `event`/`mods` match this chord. Letters compare case-insensitively-    /// (Shift is matched via the modifier, not the keysym case).-    fn matches(&self, event: &KeyEvent, mods: Modifiers) -> bool {-        if mods.ctrl != self.ctrl-            || mods.shift != self.shift-            || mods.alt != self.alt-            || mods.logo != self.logo-        {-            return false;-        }-        event.keysym == self.key-            || matches!(-                (self.key.key_char(), event.keysym.key_char()),-                (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b)-            )-    }-}--/// A mouse button that a `[mouse-bindings]` chord can name.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum MouseButton {-    Left,-    Middle,-    Right,-}--/// A parsed mouse chord: a button plus the modifiers that must be held exactly.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct MouseChord {-    button: MouseButton,-    ctrl: bool,-    shift: bool,-    alt: bool,-    logo: bool,-}--impl MouseChord {-    /// Parse a chord like `Shift+Middle`; the final non-modifier token is the-    /// button. Returns `None` if no button is recognized.-    fn parse(spec: &str) -> Option<Self> {-        let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);-        let mut button = None;-        for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {-            match token.to_ascii_lowercase().as_str() {-                "ctrl" | "control" => ctrl = true,-                "shift" => shift = true,-                "alt" | "mod1" | "meta" => alt = true,-                "super" | "logo" | "cmd" | "mod4" => logo = true,-                "left" | "button1" => button = Some(MouseButton::Left),-                "middle" | "button2" => button = Some(MouseButton::Middle),-                "right" | "button3" => button = Some(MouseButton::Right),-                other => {-                    tracing::warn!("unknown mouse button {other:?} in binding");-                    return None;-                }-            }-        }-        Some(Self {-            button: button?,-            ctrl,-            shift,-            alt,-            logo,-        })-    }--    fn matches(&self, button: MouseButton, mods: Modifiers) -> bool {-        self.button == button-            && mods.ctrl == self.ctrl-            && mods.shift == self.shift-            && mods.alt == self.alt-            && mods.logo == self.logo-    }-}--/// The resolved binding tables for a session.-#[derive(Clone, Debug, Default)]-pub struct Bindings {-    keys: Vec<(Chord, Action)>,-    text: Vec<(Chord, Vec<u8>)>,-    mouse: Vec<(MouseChord, Action)>,-}--impl Bindings {-    /// Build the tables from config, starting from the built-in defaults and-    /// applying user overrides (a value of `none` unbinds a default chord).-    pub fn from_config(-        key_bindings: &HashMap<String, String>,-        text_bindings: &HashMap<String, String>,-        mouse_bindings: &HashMap<String, String>,-    ) -> Self {-        let mut keys: Vec<(Chord, Action)> = Vec::new();-        let mut add = |chord: &str, action: Action| {-            if let Some(c) = Chord::parse(chord) {-                keys.retain(|(existing, _)| *existing != c);-                keys.push((c, action));-            }-        };-        for (chord, action) in DEFAULT_BINDINGS {-            if let Some(a) = Action::parse(action) {-                add(chord, a);-            }-        }-        for (chord, action) in key_bindings {-            if let Some(c) = Chord::parse(chord) {-                keys.retain(|(existing, _)| *existing != c);-                if let Some(a) = Action::parse(action) {-                    keys.push((c, a));-                } else if action != "none" {-                    tracing::warn!("unknown key-binding action {action:?}");-                }-            }-        }--        let mut text = Vec::new();-        for (chord, value) in text_bindings {-            if let Some(c) = Chord::parse(chord) {-                text.push((c, unescape(value)));-            }-        }--        let mut mouse: Vec<(MouseChord, Action)> = Vec::new();-        let mut add_mouse = |chord: &str, action: Option<Action>| {-            if let Some(c) = MouseChord::parse(chord) {-                mouse.retain(|(existing, _)| *existing != c);-                if let Some(a) = action {-                    mouse.push((c, a));-                }-            }-        };-        for (chord, action) in DEFAULT_MOUSE_BINDINGS {-            add_mouse(chord, Action::parse(action));-        }-        for (chord, action) in mouse_bindings {-            if action == "none" {-                add_mouse(chord, None);-            } else if let Some(a) = Action::parse(action) {-                add_mouse(chord, Some(a));-            } else {-                tracing::warn!("unknown mouse-binding action {action:?}");-            }-        }--        Self { keys, text, mouse }-    }--    /// The action bound to this key event, if any.-    pub fn action(&self, event: &KeyEvent, mods: Modifiers) -> Option<Action> {-        self.keys-            .iter()-            .find(|(c, _)| c.matches(event, mods))-            .map(|(_, a)| *a)-    }--    /// The literal bytes bound to this key event, if any.-    pub fn text(&self, event: &KeyEvent, mods: Modifiers) -> Option<&[u8]> {-        self.text-            .iter()-            .find(|(c, _)| c.matches(event, mods))-            .map(|(_, t)| t.as_slice())-    }--    /// The action bound to this mouse button + modifiers, if any.-    pub fn mouse_action(&self, button: MouseButton, mods: Modifiers) -> Option<Action> {-        self.mouse-            .iter()-            .find(|(c, _)| c.matches(button, mods))-            .map(|(_, a)| *a)-    }-}--/// Built-in default key bindings (chord, action name).-const DEFAULT_BINDINGS: &[(&str, &str)] = &[-    ("Ctrl+Shift+C", "copy"),-    ("Ctrl+Shift+V", "paste"),-    ("Ctrl+Shift+F", "search"),-    ("Shift+Page_Up", "scrollback-up"),-    ("Shift+Page_Down", "scrollback-down"),-    ("Ctrl+Shift+Home", "scrollback-top"),-    ("Ctrl+Shift+End", "scrollback-bottom"),-    ("Ctrl+plus", "font-increase"),-    ("Ctrl+equal", "font-increase"),-    ("Ctrl+minus", "font-decrease"),-    ("Ctrl+0", "font-reset"),-    ("F11", "fullscreen"),-    ("Ctrl+Shift+N", "new-window"),-    ("Ctrl+Shift+Up", "jump-prompt-up"),-    ("Ctrl+Shift+Down", "jump-prompt-down"),-    ("Ctrl+Shift+O", "url-mode"),-    ("Ctrl+Shift+U", "unicode-input"),-];--/// Built-in default mouse bindings (chord, action name). Left-button select and-/// drag are built-in gestures, not bindings, so only the other buttons appear.-const DEFAULT_MOUSE_BINDINGS: &[(&str, &str)] = &[("Middle", "paste-primary")];--/// Map a key token to a keysym: a single character, or a named special key.-fn keysym_from_token(token: &str) -> Option<Keysym> {-    let mut chars = token.chars();-    if let (Some(c), None) = (chars.next(), chars.clone().next()) {-        // A single character; bind case-insensitively via the lowercase keysym.-        return Some(Keysym::from_char(c.to_ascii_lowercase()));-    }-    Some(match token {-        "Return" | "Enter" => Keysym::Return,-        "Tab" => Keysym::Tab,-        "Escape" | "Esc" => Keysym::Escape,-        "Space" => Keysym::space,-        "BackSpace" => Keysym::BackSpace,-        "Delete" => Keysym::Delete,-        "Insert" => Keysym::Insert,-        "Home" => Keysym::Home,-        "End" => Keysym::End,-        "Page_Up" | "PageUp" | "Prior" => Keysym::Page_Up,-        "Page_Down" | "PageDown" | "Next" => Keysym::Page_Down,-        "Up" => Keysym::Up,-        "Down" => Keysym::Down,-        "Left" => Keysym::Left,-        "Right" => Keysym::Right,-        "plus" => Keysym::plus,-        "minus" => Keysym::minus,-        "equal" => Keysym::equal,-        "F1" => Keysym::F1,-        "F2" => Keysym::F2,-        "F3" => Keysym::F3,-        "F4" => Keysym::F4,-        "F5" => Keysym::F5,-        "F6" => Keysym::F6,-        "F7" => Keysym::F7,-        "F8" => Keysym::F8,-        "F9" => Keysym::F9,-        "F10" => Keysym::F10,-        "F11" => Keysym::F11,-        "F12" => Keysym::F12,-        other => {-            tracing::warn!("unknown key name {other:?} in binding");-            return None;-        }-    })-}--/// Decode the escapes a text binding may contain: `\e \n \r \t \\` and `\xNN`.-fn unescape(s: &str) -> Vec<u8> {-    let mut out = Vec::with_capacity(s.len());-    let mut chars = s.chars().peekable();-    while let Some(c) = chars.next() {-        if c != '\\' {-            let mut buf = [0u8; 4];-            out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());-            continue;-        }-        match chars.next() {-            Some('e') => out.push(0x1b),-            Some('n') => out.push(b'\n'),-            Some('r') => out.push(b'\r'),-            Some('t') => out.push(b'\t'),-            Some('\\') => out.push(b'\\'),-            Some('x') => {-                let hex: String = (0..2).filter_map(|_| chars.next()).collect();-                if let Ok(byte) = u8::from_str_radix(&hex, 16) {-                    out.push(byte);-                }-            }-            Some(other) => {-                out.push(b'\\');-                let mut buf = [0u8; 4];-                out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());-            }-            None => out.push(b'\\'),-        }-    }-    out-}--#[cfg(test)]-mod tests {-    use super::*;--    const NONE: Modifiers = Modifiers {-        ctrl: false,-        alt: false,-        shift: false,-        caps_lock: false,-        logo: false,-        num_lock: false,-    };--    fn key(keysym: Keysym) -> KeyEvent {-        KeyEvent {-            time: 0,-            raw_code: 0,-            keysym,-            utf8: None,-        }-    }--    #[test]-    fn default_copy_binding_matches_case_insensitively() {-        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());-        let mods = Modifiers {-            ctrl: true,-            shift: true,-            ..NONE-        };-        // Shift yields the uppercase keysym; the binding still matches.-        assert_eq!(b.action(&key(Keysym::C), mods), Some(Action::Copy));-        // Without the right modifiers, no match.-        assert_eq!(b.action(&key(Keysym::C), NONE), None);-    }--    #[test]-    fn config_overrides_and_unbinds() {-        let mut kb = HashMap::new();-        kb.insert("Ctrl+Shift+C".to_string(), "none".to_string());-        kb.insert("Ctrl+y".to_string(), "copy".to_string());-        let b = Bindings::from_config(&kb, &HashMap::new(), &HashMap::new());-        let cs = Modifiers {-            ctrl: true,-            shift: true,-            ..NONE-        };-        assert_eq!(b.action(&key(Keysym::C), cs), None); // unbound-        let c = Modifiers { ctrl: true, ..NONE };-        assert_eq!(b.action(&key(Keysym::y), c), Some(Action::Copy));-    }--    #[test]-    fn text_binding_unescapes() {-        let mut tb = HashMap::new();-        tb.insert("Ctrl+Shift+Return".to_string(), "\\x1b\\r".to_string());-        let b = Bindings::from_config(&HashMap::new(), &tb, &HashMap::new());-        let mods = Modifiers {-            ctrl: true,-            shift: true,-            ..NONE-        };-        assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..]));-    }--    #[test]-    fn mouse_bindings_default_override_and_unbind() {-        // Default: middle button pastes the primary selection.-        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());-        assert_eq!(-            b.mouse_action(MouseButton::Middle, NONE),-            Some(Action::PastePrimary)-        );-        assert_eq!(b.mouse_action(MouseButton::Right, NONE), None);--        // Config rebinds right to paste and unbinds the middle default.-        let mut mb = HashMap::new();-        mb.insert("Right".to_string(), "paste".to_string());-        mb.insert("Middle".to_string(), "none".to_string());-        let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &mb);-        assert_eq!(-            b.mouse_action(MouseButton::Right, NONE),-            Some(Action::Paste)-        );-        assert_eq!(b.mouse_action(MouseButton::Middle, NONE), None);-        // Modifiers must match exactly.-        let shift = Modifiers {-            shift: true,-            ..NONE-        };-        assert_eq!(b.mouse_action(MouseButton::Right, shift), None);-    }-}diff --git a/src/config.rs b/src/config.rsdeleted file mode 100644index e895eef..0000000--- a/src/config.rs+++ /dev/null@@ -1,297 +0,0 @@-//! User configuration: a TOML file at `$XDG_CONFIG_HOME/beer/beer.toml`-//! deserialized into a typed [`Config`]. A missing file uses defaults; a-//! malformed one warns and falls back to defaults rather than failing to start.--use std::path::{Path, PathBuf};--use serde::Deserialize;--/// Top-level configuration. Unknown keys are ignored so a config written for a-/// newer beer still loads.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Config {-    pub main: Main,-    pub colors: Colors,-    pub cursor: Cursor,-    pub scrollback: Scrollback,-    pub bell: Bell,-    pub mouse: Mouse,-    pub shell_integration: ShellIntegration,-    pub url: Url,-    pub notify: Notify,-    /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;-    /// a value of `"none"` unbinds.-    pub key_bindings: std::collections::HashMap<String, String>,-    /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).-    pub text_bindings: std::collections::HashMap<String, String>,-    /// Mouse chord (e.g. `"Middle"`, `"Shift+Right"`) → action. Merged over the-    /// defaults; `"none"` unbinds. Left-button select/drag stays built in.-    pub mouse_bindings: std::collections::HashMap<String, String>,-}--/// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Cursor {-    /// `block`, `beam`/`bar`, or `underline`.-    pub style: Option<String>,-    /// Whether the cursor blinks by default.-    pub blink: bool,-}--/// `[bell]`: what happens on `BEL` (0x07).-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Bell {-    /// Briefly flash the screen.-    pub visual: bool,-    /// Command (argv) to run on the bell, e.g. `["paplay", "/usr/share/.../bell.oga"]`.-    pub command: Vec<String>,-    /// Request the compositor's attention (xdg-activation) when the bell rings-    /// while the window is unfocused.-    pub urgent: bool,-}--/// `[notify]`: how desktop notifications (OSC 9/777/99) are delivered.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Notify {-    /// Notifier argv; the title and body are appended as the last two arguments.-    pub command: Vec<String>,-}--impl Default for Notify {-    fn default() -> Self {-        Self {-            command: vec!["notify-send".to_string()],-        }-    }-}--/// `[mouse]`: pointer and wheel behaviour.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Mouse {-    /// Multiplier applied to the lines scrolled per wheel notch.-    pub scroll_multiplier: f64,-    /// On the alternate screen, translate the wheel into arrow-key presses so-    /// full-screen apps that did not request mouse reporting (less, man, …)-    /// still scroll.-    pub alternate_scroll: bool,-}--impl Default for Mouse {-    fn default() -> Self {-        Self {-            scroll_multiplier: 1.0,-            alternate_scroll: true,-        }-    }-}--/// `[shell-integration]`: behaviour driven by OSC 7 / OSC 133 marks.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct ShellIntegration {-    /// Command the `pipe-command-output` binding feeds the last command's-    /// output to on stdin (argv form, e.g. `["less"]`). Empty disables it.-    pub pipe_command: Vec<String>,-}--/// `[url]`: opening OSC 8 hyperlinks and detected URLs.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Url {-    /// Launcher argv the URL is appended to (e.g. `["xdg-open"]`).-    pub launch: Vec<String>,-}--impl Default for Url {-    fn default() -> Self {-        Self {-            launch: vec!["xdg-open".to_string()],-        }-    }-}--/// `[colors]`: foreground/background, the 16 base palette entries, and accents.-/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries-/// keep the built-in default.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Colors {-    pub foreground: Option<String>,-    pub background: Option<String>,-    pub cursor: Option<String>,-    pub selection_foreground: Option<String>,-    pub selection_background: Option<String>,-    /// The eight regular palette entries (indices 0-7).-    pub regular: Option<Vec<String>>,-    /// The eight bright palette entries (indices 8-15).-    pub bright: Option<Vec<String>>,-    pub match_background: Option<String>,-    pub match_current_background: Option<String>,-    /// Background opacity, 0.0 (transparent) - 1.0 (opaque).-    pub alpha: Option<f32>,-    /// Render bold text with the bright palette variant.-    pub bold_as_bright: Option<bool>,-}--/// `[main]`: fonts, window geometry, padding, and the terminal name.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Main {-    /// Primary font family, resolved via fontconfig.-    pub font: String,-    /// Font size in pixels.-    pub font_size: u32,-    /// `TERM` value exported to the child shell.-    pub term: String,-    /// Initial size in character cells.-    pub initial_cols: u16,-    pub initial_rows: u16,-    /// Inner padding in pixels between the window edge and the cell grid.-    pub pad_x: u32,-    pub pad_y: u32,-    /// Characters that break a word for double-click selection. Empty/unset-    /// keeps the built-in default.-    pub word_delimiters: Option<String>,-    /// Hold an idle inhibitor while the window is focused, so the compositor-    /// does not blank the screen or start the screensaver. Default off.-    pub idle_inhibit: bool,-}--impl Default for Main {-    fn default() -> Self {-        Self {-            font: "monospace".to_string(),-            font_size: 16,-            term: "beer".to_string(),-            initial_cols: 80,-            initial_rows: 24,-            pad_x: 2,-            pad_y: 2,-            word_delimiters: None,-            idle_inhibit: false,-        }-    }-}--/// `[scrollback]`: history retention.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Scrollback {-    /// Lines of history retained for the main screen.-    pub lines: usize,-}--impl Default for Scrollback {-    fn default() -> Self {-        Self { lines: 10_000 }-    }-}--impl Config {-    /// Load configuration from `explicit` if given, else the default path.-    /// Any read/parse failure logs a warning and returns defaults.-    pub fn load(explicit: Option<&Path>) -> Self {-        let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else {-            return Self::default();-        };-        if !path.exists() {-            return Self::default();-        }-        let text = match std::fs::read_to_string(&path) {-            Ok(text) => text,-            Err(err) => {-                tracing::warn!("read config {}: {err}; using defaults", path.display());-                return Self::default();-            }-        };-        // Deserialize through serde_ignored so a typo'd key (`font-sze`) is-        // reported instead of silently dropped, while still loading: unknown-        // keys stay tolerated, keeping forward-compatibility with newer configs.-        let de = match toml::Deserializer::parse(&text) {-            Ok(de) => de,-            Err(err) => {-                tracing::warn!("config {}: {err}; using defaults", path.display());-                return Self::default();-            }-        };-        match serde_ignored::deserialize(de, |key| {-            tracing::warn!("config {}: unknown key `{key}` ignored", path.display());-        }) {-            Ok(config) => {-                tracing::info!("loaded config from {}", path.display());-                config-            }-            Err(err) => {-                tracing::warn!("config {}: {err}; using defaults", path.display());-                Self::default()-            }-        }-    }-}--/// `$XDG_CONFIG_HOME/beer/beer.toml`, or `~/.config/beer/beer.toml`.-fn default_path() -> Option<PathBuf> {-    if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {-        return Some(PathBuf::from(dir).join("beer/beer.toml"));-    }-    let home = std::env::var_os("HOME")?;-    Some(PathBuf::from(home).join(".config/beer/beer.toml"))-}--#[cfg(test)]-mod tests {-    use super::*;--    #[test]-    fn defaults_are_sane() {-        let c = Config::default();-        assert_eq!(c.main.font, "monospace");-        assert_eq!(c.main.font_size, 16);-        assert_eq!(c.scrollback.lines, 10_000);-    }--    #[test]-    fn parses_partial_config_and_ignores_unknown() {-        let toml = r##"-            [main]-            font = "JetBrains Mono"-            font-size = 14-            unknown-key = "tolerated"--            [colors]-            background = "#000000"-        "##;-        let c: Config = toml::from_str(toml).unwrap();-        assert_eq!(c.main.font, "JetBrains Mono");-        assert_eq!(c.main.font_size, 14);-        // Unset keys keep defaults; unknown tables/keys are ignored.-        assert_eq!(c.main.term, "beer");-        assert_eq!(c.scrollback.lines, 10_000);-    }--    #[test]-    fn unknown_keys_are_reported_but_still_load() {-        let toml = r##"-            [main]-            font-sze = 14-            font = "JetBrains Mono"--            [made-up]-            x = 1-        "##;-        let mut unknown = Vec::new();-        let de = toml::Deserializer::parse(toml).unwrap();-        let c: Config =-            serde_ignored::deserialize(de, |key| unknown.push(key.to_string())).unwrap();-        // The typo and the bogus table are both surfaced...-        assert!(unknown.iter().any(|k| k == "main.font-sze"), "{unknown:?}");-        assert!(unknown.iter().any(|k| k == "made-up"), "{unknown:?}");-        // ...yet the valid key still loaded.-        assert_eq!(c.main.font, "JetBrains Mono");-    }-}diff --git a/src/font.rs b/src/font.rsdeleted file mode 100644index d232707..0000000--- a/src/font.rs+++ /dev/null@@ -1,664 +0,0 @@-//! Font discovery, rasterization, and glyph caching.-//!-//! fontconfig resolves family names and performs per-codepoint fallback;-//! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour-//! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own-//! advance is never consulted - only the [`CellMetrics`] taken from the primary-//! face. C interop goes through the `freetype`/`fontconfig` safe wrappers; the-//! sole `unsafe` is reading a face's fixed-strike array (see `nearest_strike`).--use std::collections::HashMap;-use std::fmt;-use std::num::NonZeroUsize;-use std::path::PathBuf;--use fontconfig::{CharSet, Fontconfig, Pattern};-use freetype::bitmap::PixelMode;-use freetype::face::{LoadFlag, StyleFlag};-use freetype::{Face, Library, Matrix, Vector};-use harfbuzz_rs_now as harfbuzz;-use lru::LruCache;-use thiserror::Error;--/// Upper bound on cached glyphs; the working set of a terminal is far smaller,-/// but this caps memory under adversarial all-of-Unicode output.-const GLYPH_CACHE_CAP: usize = 4096;--/// Upper bound on cached shaped clusters (base char + combining marks).-const SHAPE_CACHE_CAP: usize = 1024;--#[derive(Debug, Error)]-pub enum FontError {-    #[error("FreeType: {0}")]-    FreeType(#[from] freetype::Error),-    #[error("could not initialize fontconfig")]-    FontconfigInit,-    #[error("fontconfig: {0}")]-    Fontconfig(#[from] fontconfig::FontconfigError),-    #[error("no font matched family {0:?}")]-    NoFamily(String),-    #[error("font {0:?} reports no size metrics")]-    NoMetrics(String),-}--/// Bold/italic selection, used both to pick a face and to key the glyph cache.-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]-pub struct Style {-    pub bold: bool,-    pub italic: bool,-}--impl Style {-    /// Dense index in `0..4` for array storage.-    fn index(self) -> usize {-        usize::from(self.bold) | (usize::from(self.italic) << 1)-    }--    fn fontconfig_style(self) -> &'static str {-        match (self.bold, self.italic) {-            (false, false) => "Regular",-            (true, false) => "Bold",-            (false, true) => "Italic",-            (true, true) => "Bold Italic",-        }-    }-}--/// Fixed cell geometry in pixels, derived from the primary face.-#[derive(Clone, Copy, Debug)]-pub struct CellMetrics {-    pub width: u32,-    pub height: u32,-    /// Baseline offset from the top of the cell.-    pub ascent: u32,-}--/// A rasterized glyph: its bitmap plus the offsets to place it on the baseline.-#[derive(Clone, Debug)]-pub struct Glyph {-    /// Horizontal offset from the pen position to the bitmap's left edge.-    pub left: i32,-    /// Vertical offset from the baseline up to the bitmap's top edge.-    pub top: i32,-    pub width: u32,-    pub height: u32,-    pub data: GlyphData,-}--/// Glyph pixel data. A `Mask` is tinted with the cell's foreground colour; a-/// `Color` bitmap (emoji) is composited directly.-#[derive(Clone, Debug)]-pub enum GlyphData {-    /// One coverage byte per pixel.-    Mask(Vec<u8>),-    /// Pre-multiplied BGRA, four bytes per pixel.-    Color(Vec<u8>),-}--/// One shaped glyph in a cluster: a glyph index into a specific face plus the-/// pixel offset, relative to the cell origin and baseline, that HarfBuzz placed-/// it at. `x` grows rightward, `y` upward (away from the baseline).-#[derive(Clone, Copy, Debug)]-pub struct Placed {-    pub gid: u32,-    pub x: i32,-    pub y: i32,-}--/// The result of shaping a base char plus its combining marks: the face the-/// cluster was shaped against and the positioned glyphs to draw, in order.-#[derive(Clone, Debug)]-pub struct ShapedCluster {-    pub face_idx: usize,-    pub glyphs: Vec<Placed>,-}--/// A loaded face plus where it came from, so HarfBuzz can be handed the same-/// font bytes that FreeType rasterizes from.-struct FaceEntry {-    face: Face,-    path: PathBuf,-    index: u32,-    /// HarfBuzz font for this face, built on first shape against it.-    hb: Option<harfbuzz::Owned<harfbuzz::Font<'static>>>,-}--/// The font set for one terminal: a primary family with lazily-loaded-/// bold/italic variants and per-codepoint fallback faces, plus glyph caches.-pub struct Fonts {-    library: Library,-    fontconfig: Fontconfig,-    family: String,-    size_px: u32,-    metrics: CellMetrics,-    /// All loaded faces; indices into this vector are stable.-    faces: Vec<FaceEntry>,-    /// Index of each style variant, by [`Style::index`]; filled on demand.-    styled: [Option<usize>; 4],-    /// Fallback faces resolved by coverage, deduplicated by file path.-    fallbacks: HashMap<PathBuf, usize>,-    /// Glyphs keyed by `char` (the common, unshaped path).-    cache: LruCache<(char, usize), Glyph>,-    /// Glyphs keyed by `(glyph index, face, style)` (the shaped path).-    gcache: LruCache<(u32, usize, usize), Glyph>,-    /// Shaped clusters keyed by `(cluster string, style)`.-    shape_cache: LruCache<(Box<str>, usize), Option<ShapedCluster>>,-}--impl fmt::Debug for Fonts {-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {-        f.debug_struct("Fonts")-            .field("family", &self.family)-            .field("size_px", &self.size_px)-            .field("metrics", &self.metrics)-            .field("faces", &self.faces.len())-            .field("cached", &self.cache.len())-            .finish()-    }-}--impl Fonts {-    /// Resolve `family` at `size_px` and compute the cell metrics.-    pub fn new(family: &str, size_px: u32) -> Result<Self, FontError> {-        let library = Library::init()?;-        let fontconfig = Fontconfig::new().ok_or(FontError::FontconfigInit)?;--        let regular = resolve_face(&library, &fontconfig, family, Style::default(), size_px)?;-        let metrics = cell_metrics(&regular.face, family)?;--        let cap = |n| NonZeroUsize::new(n).expect("cache cap is nonzero");-        Ok(Self {-            library,-            fontconfig,-            family: family.to_owned(),-            size_px,-            metrics,-            faces: vec![regular],-            styled: [Some(0), None, None, None],-            fallbacks: HashMap::new(),-            cache: LruCache::new(cap(GLYPH_CACHE_CAP)),-            gcache: LruCache::new(cap(GLYPH_CACHE_CAP)),-            shape_cache: LruCache::new(cap(SHAPE_CACHE_CAP)),-        })-    }--    pub fn metrics(&self) -> CellMetrics {-        self.metrics-    }--    /// Return the rasterized glyph for `c` in `style`, rasterizing and caching-    /// it on first use.-    pub fn glyph(&mut self, c: char, style: Style) -> Result<&Glyph, FontError> {-        let key = (c, style.index());-        if self.cache.get(&key).is_none() {-            let idx = self.face_for(c, style)?;-            let face = &self.faces[idx].face;-            // Synthesize bold/italic only when the resolved face lacks the real-            // variant (most monospace families ship both).-            let (synth_bold, synth_italic) = synth_flags(face, style);-            let glyph = rasterize(face, c, synth_bold, synth_italic)?;-            self.cache.put(key, glyph);-        }-        Ok(self.cache.get(&key).expect("glyph was just inserted"))-    }--    /// Return the rasterized glyph for glyph index `gid` in `face_idx`,-    /// rasterizing and caching on first use. Used by the shaped path, where-    /// HarfBuzz has already chosen the face and glyph.-    pub fn glyph_indexed(-        &mut self,-        face_idx: usize,-        gid: u32,-        style: Style,-    ) -> Result<&Glyph, FontError> {-        let key = (gid, face_idx, style.index());-        if self.gcache.get(&key).is_none() {-            let face = &self.faces[face_idx].face;-            let (synth_bold, synth_italic) = synth_flags(face, style);-            let glyph = rasterize_index(face, gid, synth_bold, synth_italic)?;-            self.gcache.put(key, glyph);-        }-        Ok(self.gcache.get(&key).expect("glyph was just inserted"))-    }--    /// Shape `base` plus its combining `marks` into positioned glyphs using-    /// HarfBuzz, so marks land where the font's GPOS table wants them rather-    /// than stacked at the origin. Returns `None` when shaping is unavailable or-    /// the cluster has glyphs the face does not cover (`.notdef`), so the caller-    /// can fall back to drawing the marks stacked. Results are cached.-    pub fn shape_cluster(-        &mut self,-        base: char,-        marks: &str,-        style: Style,-    ) -> Option<ShapedCluster> {-        let mut cluster = String::with_capacity(base.len_utf8() + marks.len());-        cluster.push(base);-        cluster.push_str(marks);-        let key = (cluster.clone().into_boxed_str(), style.index());-        if let Some(cached) = self.shape_cache.get(&key) {-            return cached.clone();-        }-        let shaped = self.shape_uncached(base, &cluster, style);-        self.shape_cache.put(key, shaped.clone());-        shaped-    }--    fn shape_uncached(&mut self, base: char, cluster: &str, style: Style) -> Option<ShapedCluster> {-        let face_idx = self.face_for(base, style).ok()?;-        let font = self.hb_font(face_idx)?;-        let buffer = harfbuzz::UnicodeBuffer::new().add_str(cluster);-        let output = harfbuzz::shape(font, buffer, &[]);-        let infos = output.get_glyph_infos();-        let positions = output.get_glyph_positions();-        let mut glyphs = Vec::with_capacity(infos.len());-        let mut pen = 0i32;-        for (info, pos) in infos.iter().zip(positions) {-            // A .notdef means this face does not cover part of the cluster; bail-            // so the caller stacks the marks via per-char fallback instead.-            if info.codepoint == 0 {-                return None;-            }-            glyphs.push(Placed {-                gid: info.codepoint,-                // HarfBuzz positions are 26.6 fixed point at our pixel scale.-                x: (pen + pos.x_offset) >> 6,-                y: pos.y_offset >> 6,-            });-            pen += pos.x_advance;-        }-        Some(ShapedCluster { face_idx, glyphs })-    }--    /// Lazily build the HarfBuzz font for `face_idx` from the same file bytes-    /// FreeType loaded. The bytes are leaked to `'static`: a face lives for the-    /// process, and only the handful actually used to shape clusters allocate.-    fn hb_font(&mut self, face_idx: usize) -> Option<&harfbuzz::Owned<harfbuzz::Font<'static>>> {-        if self.faces[face_idx].hb.is_none() {-            let entry = &self.faces[face_idx];-            let bytes = std::fs::read(&entry.path).ok()?;-            let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice());-            let face = harfbuzz::Face::from_bytes(leaked, entry.index);-            let mut font = harfbuzz::Font::new(face);-            let scale = self.size_px as i32 * 64;-            font.set_scale(scale, scale);-            font.set_ppem(self.size_px, self.size_px);-            self.faces[face_idx].hb = Some(font);-        }-        self.faces[face_idx].hb.as_ref()-    }--    /// Pick the face that should render `c`: the requested style if it has the-    /// glyph, then regular, then known fallbacks, then a fresh fontconfig-    /// coverage match. Falls back to the styled face (rendering `.notdef`).-    fn face_for(&mut self, c: char, style: Style) -> Result<usize, FontError> {-        let styled = self.styled_face(style)?;-        if face_has_glyph(&self.faces[styled].face, c) {-            return Ok(styled);-        }-        if let Some(regular) = self.styled[0]-            && regular != styled-            && face_has_glyph(&self.faces[regular].face, c)-        {-            return Ok(regular);-        }-        for &idx in self.fallbacks.values() {-            if face_has_glyph(&self.faces[idx].face, c) {-                return Ok(idx);-            }-        }-        Ok(self.load_fallback(c)?.unwrap_or(styled))-    }--    /// Lazily load the face for `style`, caching regular's index if the variant-    /// cannot be resolved so the lookup is not retried per glyph.-    fn styled_face(&mut self, style: Style) -> Result<usize, FontError> {-        if let Some(idx) = self.styled[style.index()] {-            return Ok(idx);-        }-        let regular = self.styled[0].expect("regular face is loaded at construction");-        let idx = match resolve_face(-            &self.library,-            &self.fontconfig,-            &self.family,-            style,-            self.size_px,-        ) {-            Ok(entry) => {-                self.faces.push(entry);-                self.faces.len() - 1-            }-            Err(_) => regular,-        };-        self.styled[style.index()] = Some(idx);-        Ok(idx)-    }--    /// Ask fontconfig for a font covering `c`, load it, and remember it.-    fn load_fallback(&mut self, c: char) -> Result<Option<usize>, FontError> {-        let mut charset = CharSet::new(&self.fontconfig)?;-        charset.add_char(c)?;-        let mut pattern = Pattern::new(&self.fontconfig)?;-        pattern.add_string(c"family", c"monospace")?;-        pattern.add_charset(charset)?;-        let matched = pattern.font_match()?;--        let path = PathBuf::from(matched.filename()?);-        if let Some(&idx) = self.fallbacks.get(&path) {-            return Ok(Some(idx));-        }-        let index = matched.face_index().unwrap_or(0);-        let face = self.library.new_face(&path, index as isize)?;-        if size_face(&face, self.size_px).is_err() {-            return Ok(None);-        }-        self.faces.push(FaceEntry {-            face,-            path: path.clone(),-            index: index as u32,-            hb: None,-        });-        let idx = self.faces.len() - 1;-        self.fallbacks.insert(path, idx);-        Ok(Some(idx))-    }-}--fn face_has_glyph(face: &Face, c: char) -> bool {-    face.get_char_index(c as usize).is_some_and(|g| g != 0)-}--/// Whether bold/italic must be synthesized: only when the requested style is-/// set but the resolved face lacks the real variant.-fn synth_flags(face: &Face, style: Style) -> (bool, bool) {-    let flags = face.style_flags();-    let synth_bold = style.bold && !flags.contains(StyleFlag::BOLD);-    let synth_italic = style.italic && !flags.contains(StyleFlag::ITALIC);-    (synth_bold, synth_italic)-}--fn resolve_face(-    library: &Library,-    fontconfig: &Fontconfig,-    family: &str,-    style: Style,-    size_px: u32,-) -> Result<FaceEntry, FontError> {-    let font = fontconfig-        .find(family, Some(style.fontconfig_style()))-        .map_err(|_| FontError::NoFamily(family.to_owned()))?;-    let index = font.index.unwrap_or(0);-    let face = library.new_face(&font.path, index as isize)?;-    size_face(&face, size_px)?;-    Ok(FaceEntry {-        face,-        path: font.path,-        index: index as u32,-        hb: None,-    })-}--/// Set a face to `size_px`. Scalable faces size directly; bitmap-strike faces-/// (e.g. colour-emoji fonts) cannot, so select the nearest available strike and-/// let the renderer scale its glyphs into the cell.-fn size_face(face: &Face, size_px: u32) -> Result<(), FontError> {-    match face.set_pixel_sizes(0, size_px) {-        Ok(()) => Ok(()),-        Err(_) if face.has_fixed_sizes() => {-            face.select_size(nearest_strike(face, size_px))?;-            Ok(())-        }-        Err(err) => Err(err.into()),-    }-}--/// Index of the fixed strike whose pixel height is closest to `target`.-fn nearest_strike(face: &Face, target: u32) -> i32 {-    let rec = face.raw();-    let target = i32::try_from(target).unwrap_or(i32::MAX);-    let mut best = 0;-    let mut best_delta = i32::MAX;-    for i in 0..rec.num_fixed_sizes {-        // SAFETY: `available_sizes` points to `num_fixed_sizes` valid-        // `FT_Bitmap_Size` entries for the face's lifetime; `i` is in range.-        let height = i32::from(unsafe { (*rec.available_sizes.offset(i as isize)).height });-        let delta = (height - target).abs();-        if delta < best_delta {-            best = i;-            best_delta = delta;-        }-    }-    best-}--fn cell_metrics(face: &Face, family: &str) -> Result<CellMetrics, FontError> {-    let metrics = face-        .size_metrics()-        .ok_or_else(|| FontError::NoMetrics(family.to_owned()))?;-    // FreeType reports these in 26.6 fixed point.-    let ascent = (metrics.ascender >> 6).max(1) as u32;-    let height = (metrics.height >> 6).max(1) as u32;--    // For a monospace face every advance is equal; measure one ASCII glyph.-    face.load_char('M' as usize, LoadFlag::DEFAULT)?;-    let width = (face.glyph().advance().x >> 6).max(1) as u32;--    Ok(CellMetrics {-        width,-        height,-        ascent,-    })-}--fn rasterize(-    face: &Face,-    c: char,-    synth_bold: bool,-    synth_italic: bool,-) -> Result<Glyph, FontError> {-    rasterize_with(face, synth_bold, synth_italic, |face| {-        face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR)-    })-}--/// Rasterize by glyph index rather than character (the shaped path).-fn rasterize_index(-    face: &Face,-    gid: u32,-    synth_bold: bool,-    synth_italic: bool,-) -> Result<Glyph, FontError> {-    rasterize_with(face, synth_bold, synth_italic, |face| {-        face.load_glyph(gid, LoadFlag::RENDER | LoadFlag::COLOR)-    })-}--fn rasterize_with(-    face: &Face,-    synth_bold: bool,-    synth_italic: bool,-    load: impl FnOnce(&Face) -> Result<(), freetype::Error>,-) -> Result<Glyph, FontError> {-    // A shear transform fakes italics on a face that has no real oblique. It is-    // applied to the outline at load time, so reset it immediately after.-    if synth_italic {-        face.set_transform(&mut shear_matrix(), &mut Vector { x: 0, y: 0 });-    }-    let result = load(face);-    if synth_italic {-        face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 });-    }-    result?;--    let slot = face.glyph();-    let bitmap = slot.bitmap();-    let width = bitmap.width().max(0) as usize;-    let height = bitmap.rows().max(0) as usize;-    let pitch = bitmap.pitch();-    let src = bitmap.buffer();--    let mut data = match bitmap.pixel_mode()? {-        PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)),-        PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)),-        PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)),-        _ => GlyphData::Mask(vec![0; width * height]),-    };-    // Fake bold by widening coverage one pixel to the right (colour glyphs are-    // left alone - there is no such thing as a bold emoji).-    if synth_bold && let GlyphData::Mask(mask) = &mut data {-        embolden(mask, width, height);-    }--    Ok(Glyph {-        left: slot.bitmap_left(),-        top: slot.bitmap_top(),-        width: width as u32,-        height: height as u32,-        data,-    })-}--fn shear_matrix() -> Matrix {-    // ~0.2 horizontal shear in 16.16 fixed point.-    Matrix {-        xx: 0x1_0000,-        xy: 0x3333,-        yx: 0,-        yy: 0x1_0000,-    }-}--fn identity_matrix() -> Matrix {-    Matrix {-        xx: 0x1_0000,-        xy: 0,-        yx: 0,-        yy: 0x1_0000,-    }-}--/// Widen each row's coverage by one pixel (synthetic bold).-fn embolden(mask: &mut [u8], width: usize, height: usize) {-    for y in 0..height {-        let row = &mut mask[y * width..y * width + width];-        for x in (1..width).rev() {-            row[x] = row[x].max(row[x - 1]);-        }-    }-}--/// Copy `height` rows of `row_bytes` each out of FreeType's padded buffer,-/// honouring pitch sign (positive = top-down).-fn pack_rows(src: &[u8], row_bytes: usize, pitch: i32, height: usize) -> Vec<u8> {-    let stride = pitch.unsigned_abs() as usize;-    let take = row_bytes.min(stride);-    let mut out = vec![0u8; row_bytes * height];-    for row in 0..height {-        let src_row = if pitch >= 0 { row } else { height - 1 - row };-        let start = src_row * stride;-        if start + take <= src.len() {-            out[row * row_bytes..row * row_bytes + take].copy_from_slice(&src[start..start + take]);-        }-    }-    out-}--/// Expand a 1-bit-per-pixel mono bitmap to one coverage byte per pixel.-fn expand_mono(src: &[u8], width: usize, pitch: i32, height: usize) -> Vec<u8> {-    let stride = pitch.unsigned_abs() as usize;-    let mut out = vec![0u8; width * height];-    for row in 0..height {-        let src_row = if pitch >= 0 { row } else { height - 1 - row };-        let base = src_row * stride;-        for x in 0..width {-            let byte = base + x / 8;-            if byte < src.len() && src[byte] & (0x80 >> (x % 8)) != 0 {-                out[row * width + x] = 0xff;-            }-        }-    }-    out-}--#[cfg(test)]-mod tests {-    use super::*;--    fn fonts() -> Fonts {-        Fonts::new("monospace", 16).expect("system has a monospace font")-    }--    #[test]-    fn cell_metrics_are_sane() {-        let m = fonts().metrics();-        assert!(m.width >= 1 && m.height >= 1);-        assert!(m.ascent >= 1 && m.ascent <= m.height);-    }--    #[test]-    fn ascii_glyph_has_ink() {-        let mut f = fonts();-        let glyph = f.glyph('M', Style::default()).expect("rasterize M");-        assert!(glyph.width > 0 && glyph.height > 0);-        match &glyph.data {-            GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "M should have coverage"),-            GlyphData::Color(_) => {}-        }-    }--    #[test]-    fn space_is_blank_but_ok() {-        let mut f = fonts();-        // Space resolves without error; it simply carries no ink.-        f.glyph(' ', Style::default()).expect("rasterize space");-    }--    #[test]-    fn embolden_widens_coverage() {-        // 3x2 mask, one lit pixel per row at x=1.-        let mut mask = vec![0, 255, 0, 0, 200, 0];-        embolden(&mut mask, 3, 2);-        // Each lit pixel bleeds one column to the right; the left edge is unchanged.-        assert_eq!(mask, vec![0, 255, 255, 0, 200, 200]);-    }--    #[test]-    fn shapes_a_simple_cluster() {-        let mut f = fonts();-        // Shaping a bare base char yields exactly its one glyph, and that glyph-        // index rasterizes to ink through the shaped path.-        let shaped = f-            .shape_cluster('a', "", Style::default())-            .expect("monospace shapes 'a'");-        assert_eq!(shaped.glyphs.len(), 1);-        let g = f-            .glyph_indexed(shaped.face_idx, shaped.glyphs[0].gid, Style::default())-            .expect("rasterize shaped glyph");-        match &g.data {-            GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "'a' should have ink"),-            GlyphData::Color(_) => {}-        }-    }--    #[test]-    fn shapes_combining_cluster_without_notdef() {-        // 'e' + combining acute: a covering face shapes it (>=1 glyph, never a-        // .notdef, which `shape_cluster` rejects by returning None); a face-        // missing the mark returns None so the renderer stacks instead. Either-        // outcome is fine - the point is no panic and no notdef leaking through.-        let mut f = fonts();-        if let Some(shaped) = f.shape_cluster('e', "\u{0301}", Style::default()) {-            assert!(!shaped.glyphs.is_empty());-            assert!(shaped.glyphs.iter().all(|g| g.gid != 0));-        }-    }--    #[test]-    fn glyphs_are_cached() {-        let mut f = fonts();-        f.glyph('a', Style::default()).unwrap();-        let before = f.cache.len();-        f.glyph('a', Style::default()).unwrap();-        assert_eq!(f.cache.len(), before, "second lookup must hit the cache");-    }-}diff --git a/src/grid/links.rs b/src/grid/links.rsdeleted file mode 100644index 9215316..0000000--- a/src/grid/links.rs+++ /dev/null@@ -1,136 +0,0 @@-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.rsdeleted file mode 100644index d82ea33..0000000--- a/src/grid/mod.rs+++ /dev/null@@ -1,1515 +0,0 @@-//! 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>>,-    /// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode).-    kitty_current: u8,-    /// Saved flag values for the kitty push/pop stack.-    kitty_stack: Vec<u8>,-}--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`!@#$%^&*()+=[]{}\\|;'\",<>?";--/// Mask of the kitty-keyboard flags we implement (disambiguate, report events,-/// alternate keys, all-keys-as-escapes, associated text).-const KITTY_ALL: u8 = 0b1_1111;--/// 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(),-            kitty_current: 0,-            kitty_stack: Vec::new(),-        }-    }--    /// The active kitty-keyboard flags (0 means legacy encoding).-    pub fn kitty_flags(&self) -> u8 {-        self.kitty_current-    }--    /// Apply `CSI = flags ; mode u`: mode 1 replaces, 2 sets bits, 3 clears bits.-    pub fn kitty_set(&mut self, flags: u8, mode: u8) {-        self.kitty_current = match mode {-            2 => self.kitty_current | flags,-            3 => self.kitty_current & !flags,-            _ => flags,-        } & KITTY_ALL;-    }--    /// Apply `CSI > flags u`: push the current flags and switch to `flags`.-    pub fn kitty_push(&mut self, flags: u8) {-        if self.kitty_stack.len() >= 32 {-            self.kitty_stack.remove(0);-        }-        self.kitty_stack.push(self.kitty_current);-        self.kitty_current = flags & KITTY_ALL;-    }--    /// Apply `CSI < n u`: pop `n` saved flag values, restoring the last one.-    pub fn kitty_pop(&mut self, n: usize) {-        for _ in 0..n.max(1) {-            self.kitty_current = self.kitty_stack.pop().unwrap_or(0);-        }-    }--    /// 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 kitty_flag_stack() {-        let mut g = Grid::new(4, 2);-        assert_eq!(g.kitty_flags(), 0);-        g.kitty_set(0b1, 1); // replace-        assert_eq!(g.kitty_flags(), 0b1);-        g.kitty_set(0b100, 2); // set bits-        assert_eq!(g.kitty_flags(), 0b101);-        g.kitty_set(0b1, 3); // clear bits-        assert_eq!(g.kitty_flags(), 0b100);-        g.kitty_push(0b11); // push current, switch-        assert_eq!(g.kitty_flags(), 0b11);-        g.kitty_pop(1); // restore-        assert_eq!(g.kitty_flags(), 0b100);-        g.kitty_pop(5); // underflow is harmless-        assert_eq!(g.kitty_flags(), 0);-    }--    #[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.rsdeleted file mode 100644index 804d594..0000000--- a/src/grid/search.rs+++ /dev/null@@ -1,148 +0,0 @@-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.rsdeleted file mode 100644index 74d1fe3..0000000--- a/src/grid/selection.rs+++ /dev/null@@ -1,190 +0,0 @@-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/input.rs b/src/input.rsdeleted file mode 100644index d608ff5..0000000--- a/src/input.rs+++ /dev/null@@ -1,663 +0,0 @@-//! Keyboard encoding: translate decoded key events into the byte sequences a-//! terminal application expects (xterm/VT-style).--use std::io::Write as _;--use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};--use crate::grid::MouseEncoding;--/// Encode a key press into bytes for the PTY, or `None` if it produces no input.-pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {-    let seq = match event.keysym {-        Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),-        Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),-        Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),-        Keysym::Tab => b"\t".to_vec(),-        Keysym::Escape => b"\x1b".to_vec(),--        Keysym::Up => csi_letter(b'A', mods, app_cursor),-        Keysym::Down => csi_letter(b'B', mods, app_cursor),-        Keysym::Right => csi_letter(b'C', mods, app_cursor),-        Keysym::Left => csi_letter(b'D', mods, app_cursor),-        Keysym::Home => csi_letter(b'H', mods, app_cursor),-        Keysym::End => csi_letter(b'F', mods, app_cursor),--        Keysym::Insert => csi_tilde(2, mods),-        Keysym::Delete => csi_tilde(3, mods),-        Keysym::Page_Up => csi_tilde(5, mods),-        Keysym::Page_Down => csi_tilde(6, mods),--        // F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.-        Keysym::F1 => fkey(b'P', mods),-        Keysym::F2 => fkey(b'Q', mods),-        Keysym::F3 => fkey(b'R', mods),-        Keysym::F4 => fkey(b'S', mods),-        Keysym::F5 => csi_tilde(15, mods),-        Keysym::F6 => csi_tilde(17, mods),-        Keysym::F7 => csi_tilde(18, mods),-        Keysym::F8 => csi_tilde(19, mods),-        Keysym::F9 => csi_tilde(20, mods),-        Keysym::F10 => csi_tilde(21, mods),-        Keysym::F11 => csi_tilde(23, mods),-        Keysym::F12 => csi_tilde(24, mods),--        // Everything else: the xkb-composed text (which already folds in Ctrl),-        // with Alt sending an ESC prefix (meta).-        _ => {-            let text = event.utf8.as_ref()?;-            if text.is_empty() {-                return None;-            }-            prefix_alt(text.as_bytes().to_vec(), mods)-        }-    };-    Some(seq)-}--/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.-#[derive(Clone, Copy, PartialEq, Eq)]-pub enum KeyKind {-    Press = 1,-    Repeat = 2,-    Release = 3,-}--/// How a functional (non-text) key is encoded in the kitty protocol.-enum Func {-    /// `CSI number ... u`.-    Number(u32),-    /// `CSI number ... ~`.-    Tilde(u32),-    /// `CSI 1 ... LETTER`.-    Letter(u8),-}--/// Map a keysym to its kitty functional encoding, or `None` for a text key-/// (whose code is its Unicode codepoint).-fn functional(keysym: Keysym) -> Option<Func> {-    Some(match keysym {-        Keysym::Escape => Func::Number(27),-        Keysym::Return | Keysym::KP_Enter => Func::Number(13),-        Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),-        Keysym::BackSpace => Func::Number(127),-        Keysym::Insert => Func::Tilde(2),-        Keysym::Delete => Func::Tilde(3),-        Keysym::Page_Up => Func::Tilde(5),-        Keysym::Page_Down => Func::Tilde(6),-        Keysym::Up => Func::Letter(b'A'),-        Keysym::Down => Func::Letter(b'B'),-        Keysym::Right => Func::Letter(b'C'),-        Keysym::Left => Func::Letter(b'D'),-        Keysym::Home => Func::Letter(b'H'),-        Keysym::End => Func::Letter(b'F'),-        Keysym::F1 => Func::Letter(b'P'),-        Keysym::F2 => Func::Letter(b'Q'),-        Keysym::F3 => Func::Tilde(13),-        Keysym::F4 => Func::Letter(b'S'),-        Keysym::F5 => Func::Tilde(15),-        Keysym::F6 => Func::Tilde(17),-        Keysym::F7 => Func::Tilde(18),-        Keysym::F8 => Func::Tilde(19),-        Keysym::F9 => Func::Tilde(20),-        Keysym::F10 => Func::Tilde(21),-        Keysym::F11 => Func::Tilde(23),-        Keysym::F12 => Func::Tilde(24),-        _ => return None,-    })-}--/// The kitty key code for a lone modifier key, reported only in report-all mode.-fn modifier_key(keysym: Keysym) -> Option<u32> {-    Some(match keysym {-        Keysym::Caps_Lock => 57358,-        Keysym::Num_Lock => 57360,-        Keysym::Shift_L => 57441,-        Keysym::Control_L => 57442,-        Keysym::Alt_L => 57443,-        Keysym::Super_L => 57444,-        Keysym::Shift_R => 57447,-        Keysym::Control_R => 57448,-        Keysym::Alt_R => 57449,-        Keysym::Super_R => 57450,-        _ => return None,-    })-}--/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8); the wire-/// parameter is `1 + this`.-fn kitty_mod_bits(mods: Modifiers) -> u32 {-    u32::from(mods.shift)-        | (u32::from(mods.alt) << 1)-        | (u32::from(mods.ctrl) << 2)-        | (u32::from(mods.logo) << 3)-}--/// The un-shifted Unicode codepoint of a text key (always the lowercase form,-/// per the protocol). `None` if the key produces no character.-fn base_codepoint(keysym: Keysym) -> Option<u32> {-    let c = keysym.key_char()?;-    Some(u32::from(if c.is_ascii_uppercase() {-        c.to_ascii_lowercase()-    } else {-        c-    }))-}--/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier-/// section is emitted when modifiers/event/text require it; the event sub-field-/// only when the event is not a plain press.-fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {-    let mut s = String::from("\x1b[");-    s.push_str(&field);-    let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();-    if needs_mods {-        s.push(';');-        s.push_str(&mod_param.to_string());-        if event != KeyKind::Press {-            s.push(':');-            s.push_str(&(event as u8).to_string());-        }-    }-    if let Some(text) = text {-        s.push(';');-        let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();-        s.push_str(&codes.join(":"));-    }-    s.push(char::from(term));-    s.into_bytes()-}--/// Encode a key event under the kitty keyboard protocol with the given active-/// `flags`. Returns `None` when the event produces nothing (e.g. a text key-/// release without report-all mode). `app_cursor` only affects unmodified-/// cursor keys, matching legacy behaviour.-pub fn kitty_encode(-    event: &KeyEvent,-    mods: Modifiers,-    flags: u8,-    kind: KeyKind,-    app_cursor: bool,-) -> Option<Vec<u8>> {-    let report_all = flags & 0b1000 != 0;-    let report_events = flags & 0b10 != 0;-    let report_alt = flags & 0b100 != 0;-    let report_text = flags & 0b1_0000 != 0;--    let bits = kitty_mod_bits(mods);-    let mod_param = bits + 1;-    // Events other than a press are only reported when asked for.-    let events_ok = report_events || report_all;-    let kind = if events_ok { kind } else { KeyKind::Press };--    // Lone modifier keys: only in report-all mode.-    if let Some(code) = modifier_key(event.keysym) {-        if !report_all {-            return None;-        }-        return Some(csi(code.to_string(), mod_param, kind, None, b'u'));-    }--    match functional(event.keysym) {-        Some(func) => {-            // Enter/Tab/Backspace keep legacy bytes when unmodified and not in-            // report-all mode, so a shell stays usable.-            let legacy_special = matches!(-                event.keysym,-                Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace-            );-            if legacy_special && !report_all && bits == 0 {-                if kind != KeyKind::Press {-                    return None;-                }-                return Some(match event.keysym {-                    Keysym::BackSpace => b"\x7f".to_vec(),-                    Keysym::Tab => b"\t".to_vec(),-                    _ => b"\r".to_vec(),-                });-            }-            if kind != KeyKind::Press && !events_ok {-                return None;-            }-            Some(match func {-                Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),-                Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),-                Func::Letter(l) => {-                    if mod_param == 1 && kind == KeyKind::Press {-                        // Unmodified: legacy CSI/SS3 form, honouring app-cursor.-                        vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]-                    } else {-                        csi("1".to_string(), mod_param, kind, None, l)-                    }-                }-            })-        }-        None => {-            let cp = base_codepoint(event.keysym)?;-            if report_all {-                if cp == 0 {-                    return None;-                }-                let text = if report_text {-                    event-                        .utf8-                        .as_deref()-                        .filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))-                } else {-                    None-                };-                let field = alt_field(cp, event.keysym, mods, report_alt);-                return Some(csi(field, mod_param, kind, text, b'u'));-            }-            // Without report-all, text keys only report presses.-            if kind != KeyKind::Press {-                return None;-            }-            // Plain or shift-only keys send their text; ctrl/alt/super → CSI u.-            if bits & !1 == 0 {-                let text = event.utf8.as_ref()?;-                if text.is_empty() {-                    return None;-                }-                return Some(text.as_bytes().to_vec());-            }-            let field = alt_field(cp, event.keysym, mods, report_alt);-            Some(csi(field, mod_param, KeyKind::Press, None, b'u'))-        }-    }-}--/// The key-code field, with the shifted alternate appended (`code:shifted`) when-/// alternate-key reporting is on and shift is held.-fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {-    if report_alt-        && mods.shift-        && let Some(shifted) = keysym.key_char().map(u32::from)-        && shifted != cp-    {-        return format!("{cp}:{shifted}");-    }-    cp.to_string()-}--/// Encode a mouse event for the application. `button` is the base code (0/1/2-/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based-/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.-pub fn encode_mouse(-    encoding: MouseEncoding,-    button: u8,-    col: usize,-    row: usize,-    pressed: bool,-    motion: bool,-    mods: Modifiers,-) -> Vec<u8> {-    let mod_bits =-        (u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);-    let motion_bit = if motion { 32 } else { 0 };-    let mut out = Vec::new();-    match encoding {-        MouseEncoding::Sgr => {-            let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);-            let final_byte = if pressed { 'M' } else { 'm' };-            let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);-        }-        enc => {-            // Legacy form collapses every button release to code 3.-            let base = if !pressed && button <= 2 { 3 } else { button };-            let cb = 32 + base + motion_bit + mod_bits;-            out.extend_from_slice(b"\x1b[M");-            push_coord(&mut out, u32::from(cb), false);-            let utf8 = enc == MouseEncoding::Utf8;-            push_coord(&mut out, col as u32 + 33, utf8);-            push_coord(&mut out, row as u32 + 33, utf8);-        }-    }-    out-}--/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when-/// the extended (1005) mode is active, and clamping otherwise.-fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {-    if utf8 {-        let v = value.min(0x7ff);-        if v >= 0x80 {-            out.push(0xc0 | (v >> 6) as u8);-            out.push(0x80 | (v & 0x3f) as u8);-        } else {-            out.push(v as u8);-        }-    } else {-        out.push(value.min(255) as u8);-    }-}--fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {-    if mods.alt {-        let mut out = Vec::with_capacity(bytes.len() + 1);-        out.push(0x1b);-        out.extend_from_slice(&bytes);-        out-    } else {-        bytes-    }-}--/// xterm modifier parameter: 1 + a bitfield of the held modifiers.-fn modifier_param(mods: Modifiers) -> u8 {-    1 + u8::from(mods.shift)-        + (u8::from(mods.alt) << 1)-        + (u8::from(mods.ctrl) << 2)-        + (u8::from(mods.logo) << 3)-}--/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and-/// `ESC [ 1 ; m X` when modifiers are held.-fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {-    let m = modifier_param(mods);-    if m == 1 {-        vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]-    } else {-        let mut v = format!("\x1b[1;{m}").into_bytes();-        v.push(final_byte);-        v-    }-}--/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.-fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {-    let m = modifier_param(mods);-    if m == 1 {-        format!("\x1b[{n}~").into_bytes()-    } else {-        format!("\x1b[{n};{m}~").into_bytes()-    }-}--fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {-    let m = modifier_param(mods);-    if m == 1 {-        vec![0x1b, b'O', final_byte]-    } else {-        let mut v = format!("\x1b[1;{m}").into_bytes();-        v.push(final_byte);-        v-    }-}--#[cfg(test)]-mod tests {-    use super::*;--    fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {-        KeyEvent {-            time: 0,-            raw_code: 0,-            keysym,-            utf8: utf8.map(str::to_owned),-        }-    }--    const NONE: Modifiers = Modifiers {-        ctrl: false,-        alt: false,-        shift: false,-        caps_lock: false,-        logo: false,-        num_lock: false,-    };--    #[test]-    fn plain_text_passes_through() {-        assert_eq!(-            encode(&key(Keysym::a, Some("a")), NONE, false),-            Some(b"a".to_vec())-        );-    }--    #[test]-    fn alt_prefixes_escape() {-        let mods = Modifiers { alt: true, ..NONE };-        assert_eq!(-            encode(&key(Keysym::a, Some("a")), mods, false),-            Some(b"\x1ba".to_vec())-        );-    }--    #[test]-    fn arrows_respect_application_mode() {-        assert_eq!(-            encode(&key(Keysym::Up, None), NONE, false),-            Some(b"\x1b[A".to_vec())-        );-        assert_eq!(-            encode(&key(Keysym::Up, None), NONE, true),-            Some(b"\x1bOA".to_vec())-        );-    }--    #[test]-    fn modified_arrow_uses_csi_param() {-        let mods = Modifiers { ctrl: true, ..NONE };-        assert_eq!(-            encode(&key(Keysym::Right, None), mods, false),-            Some(b"\x1b[1;5C".to_vec())-        );-    }--    #[test]-    fn mouse_sgr_press_and_release() {-        // Left press at column 3, row 5 (0-based) → SGR with 1-based coords.-        assert_eq!(-            encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE),-            b"\x1b[<0;4;6M".to_vec()-        );-        assert_eq!(-            encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE),-            b"\x1b[<0;4;6m".to_vec()-        );-    }--    #[test]-    fn mouse_legacy_release_collapses_to_three() {-        // Legacy release: button bits become 3; values offset by 32.-        assert_eq!(-            encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE),-            vec![0x1b, b'[', b'M', 32 + 3, 33, 33]-        );-    }--    #[test]-    fn mouse_motion_and_modifiers_set_bits() {-        let mods = Modifiers { ctrl: true, ..NONE };-        // Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl).-        assert_eq!(-            encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods),-            b"\x1b[<48;1;1M".to_vec()-        );-    }--    fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {-        Modifiers {-            ctrl,-            shift,-            alt,-            ..NONE-        }-    }--    #[test]-    fn kitty_plain_text_key_stays_text() {-        // Disambiguate only: an unmodified letter is still sent as text.-        assert_eq!(-            kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),-            Some(b"a".to_vec())-        );-    }--    #[test]-    fn kitty_ctrl_letter_is_csi_u() {-        // ctrl+a → CSI 97 ; 5 u  (5 = 1 + ctrl).-        assert_eq!(-            kitty_encode(-                &key(Keysym::a, None),-                mods(true, false, false),-                0b1,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[97;5u".to_vec())-        );-    }--    #[test]-    fn kitty_escape_disambiguated() {-        assert_eq!(-            kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),-            Some(b"\x1b[27u".to_vec())-        );-    }--    #[test]-    fn kitty_associated_text_and_alternate() {-        // shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.-        let flags = 0b1_1001; // disambiguate | report-all | associated-text-        assert_eq!(-            kitty_encode(-                &key(Keysym::A, Some("A")),-                mods(false, true, false),-                flags,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[97;2;65u".to_vec())-        );-        // ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.-        assert_eq!(-            kitty_encode(-                &key(Keysym::A, None),-                mods(true, true, false),-                0b101,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[97:65;6u".to_vec())-        );-    }--    #[test]-    fn kitty_release_only_with_report_all() {-        let release = |flags| {-            kitty_encode(-                &key(Keysym::a, None),-                mods(true, false, false),-                flags,-                KeyKind::Release,-                false,-            )-        };-        // Event reporting alone does not report text-key release.-        assert_eq!(release(0b11), None);-        // Report-all does: CSI 97 ; 5 : 3 u.-        assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));-    }--    #[test]-    fn kitty_lock_keys_do_not_leak() {-        let numlock = Modifiers {-            num_lock: true,-            ..NONE-        };-        // Num lock on must not turn unmodified Enter into a CSI u sequence.-        assert_eq!(-            kitty_encode(-                &key(Keysym::Return, None),-                numlock,-                0b1,-                KeyKind::Press,-                false-            ),-            Some(b"\r".to_vec())-        );-        // Nor pollute the modifier parameter of a real chord.-        let ctrl_numlock = Modifiers {-            ctrl: true,-            num_lock: true,-            ..NONE-        };-        assert_eq!(-            kitty_encode(-                &key(Keysym::a, None),-                ctrl_numlock,-                0b1,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[97;5u".to_vec())-        );-    }--    #[test]-    fn kitty_functional_keys() {-        // Unmodified arrow keeps the legacy form (app-cursor honoured).-        assert_eq!(-            kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),-            Some(b"\x1b[A".to_vec())-        );-        assert_eq!(-            kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),-            Some(b"\x1bOA".to_vec())-        );-        // Modified arrow → CSI 1 ; mods LETTER.-        assert_eq!(-            kitty_encode(-                &key(Keysym::Up, None),-                mods(true, false, false),-                0b1,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[1;5A".to_vec())-        );-        // F5 keeps its tilde form.-        assert_eq!(-            kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),-            Some(b"\x1b[15~".to_vec())-        );-        // Unmodified Enter is still a carriage return; report-all makes it CSI u.-        assert_eq!(-            kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),-            Some(b"\r".to_vec())-        );-        assert_eq!(-            kitty_encode(-                &key(Keysym::Return, None),-                NONE,-                0b1001,-                KeyKind::Press,-                false-            ),-            Some(b"\x1b[13u".to_vec())-        );-    }--    #[test]-    fn special_keys() {-        assert_eq!(-            encode(&key(Keysym::Return, None), NONE, false),-            Some(b"\r".to_vec())-        );-        assert_eq!(-            encode(&key(Keysym::BackSpace, None), NONE, false),-            Some(b"\x7f".to_vec())-        );-        assert_eq!(-            encode(&key(Keysym::Delete, None), NONE, false),-            Some(b"\x1b[3~".to_vec())-        );-        assert_eq!(-            encode(&key(Keysym::F5, None), NONE, false),-            Some(b"\x1b[15~".to_vec())-        );-    }-}diff --git a/src/main.rs b/src/main.rsdeleted file mode 100644index c31d897..0000000--- a/src/main.rs+++ /dev/null@@ -1,66 +0,0 @@-//! beer, a fast, software-rendered, Wayland-native terminal emulator.--mod bindings;-mod config;-mod font;-mod grid;-mod input;-mod pty;-mod render;-mod theme;-mod vt;-mod wayland;--use std::path::PathBuf;-use std::process::ExitCode;--use pound::Parse;--use crate::config::Config;--/// A fast, software-rendered, Wayland-native terminal emulator.-#[derive(Parse)]-#[pound(name = "beer", version = "0.2.0")]-struct Cli {-    /// Run as a daemon hosting multiple windows.-    #[pound(long)]-    server: bool,-    /// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml).-    #[pound(long)]-    config: Option<PathBuf>,-}--fn main() -> ExitCode {-    init_logging();-    match run(Cli::parse()) {-        Ok(code) => code,-        Err(err) => {-            tracing::error!("{err:#}");-            eprintln!("beer: {err:#}");-            ExitCode::FAILURE-        }-    }-}--fn init_logging() {-    use tracing_subscriber::{EnvFilter, fmt};--    let filter = EnvFilter::try_from_env("BEER_LOG")-        .or_else(|_| EnvFilter::try_from_default_env())-        .unwrap_or_else(|_| EnvFilter::new("warn"));--    fmt()-        .with_env_filter(filter)-        .with_writer(std::io::stderr)-        .init();-}--fn run(cli: Cli) -> anyhow::Result<ExitCode> {-    if cli.server {-        anyhow::bail!("server mode is not implemented yet");-    }--    let config = Config::load(cli.config.as_deref());-    tracing::info!("starting beer");-    wayland::run(config, cli.config)-}diff --git a/src/pty.rs b/src/pty.rsdeleted file mode 100644index 7bb559e..0000000--- a/src/pty.rs+++ /dev/null@@ -1,117 +0,0 @@-//! Pseudo-terminal: open a master/slave pair and run the user's shell on it.--use std::ffi::OsStr;-use std::io;-use std::os::fd::{AsFd, OwnedFd};-use std::os::unix::process::CommandExt;-use std::path::Path;-use std::process::{Child, Command, ExitStatus, Stdio};--use anyhow::Context;-use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};-use rustix::termios::{Winsize, tcsetwinsize};--/// A running child process attached to a PTY master.-#[derive(Debug)]-pub struct Pty {-    master: OwnedFd,-    child: Child,-}--impl Pty {-    /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on-    /// the slave end with `TERM=term`.-    pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {-        let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)-            .context("open pty master")?;-        grantpt(&master).context("grantpt")?;-        unlockpt(&master).context("unlockpt")?;-        let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY)-            .context("open pty slave")?;--        set_winsize(&master, cols, rows)?;--        let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());-        let argv0 = login_argv0(&shell);--        // Hand the slave to the child's stdio. try_clone gives O_CLOEXEC dups, so-        // the parent's copies and the controlling-tty handle vanish at exec.-        let ctty = slave.try_clone().context("dup slave")?;-        let (stdin, stdout, stderr) = (-            slave.try_clone().context("dup slave")?,-            slave.try_clone().context("dup slave")?,-            slave,-        );--        let mut cmd = Command::new(&shell);-        cmd.arg0(&argv0)-            .env("TERM", term)-            .env_remove("COLUMNS")-            .env_remove("LINES")-            .env_remove("TERMCAP")-            .stdin(Stdio::from(stdin))-            .stdout(Stdio::from(stdout))-            .stderr(Stdio::from(stderr));--        // SAFETY: setsid and the TIOCSCTTY ioctl are async-signal-safe raw-        // syscalls; ctty is a valid fd captured by move. We touch no parent-        // heap state, satisfying pre_exec's contract.-        unsafe {-            cmd.pre_exec(move || {-                rustix::process::setsid()?;-                rustix::process::ioctl_tiocsctty(&ctty)?;-                Ok(())-            });-        }--        let child = cmd.spawn().context("spawn shell")?;-        Ok(Self { master, child })-    }--    /// The PTY master, for reading child output and writing input.-    pub fn master(&self) -> &OwnedFd {-        &self.master-    }--    /// Inform the kernel (and thus the child) of a new terminal size.-    pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {-        set_winsize(&self.master, cols, rows)-    }--    /// Reap the child if it has exited.-    pub fn wait(&mut self) -> io::Result<ExitStatus> {-        self.child.wait()-    }-}--fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> {-    let ws = Winsize {-        ws_row: rows,-        ws_col: cols,-        ws_xpixel: 0,-        ws_ypixel: 0,-    };-    tcsetwinsize(master.as_fd(), ws).context("set pty winsize")-}--/// Login-shell argv[0] is the shell's basename with a leading '-'.-fn login_argv0(shell: &OsStr) -> std::ffi::OsString {-    let name = Path::new(shell)-        .file_name()-        .unwrap_or_else(|| OsStr::new("sh"));-    let mut argv0 = std::ffi::OsString::from("-");-    argv0.push(name);-    argv0-}--#[cfg(test)]-mod tests {-    use super::*;--    #[test]-    fn argv0_is_dash_prefixed_basename() {-        assert_eq!(login_argv0(OsStr::new("/usr/bin/bash")), "-bash");-        assert_eq!(login_argv0(OsStr::new("zsh")), "-zsh");-        assert_eq!(login_argv0(OsStr::new("")), "-sh");-    }-}diff --git a/src/render.rs b/src/render.rsdeleted file mode 100644index d7206f0..0000000--- a/src/render.rs+++ /dev/null@@ -1,683 +0,0 @@-//! Software renderer: compose the grid into an ARGB8888 buffer.-//!-//! The target is a `wl_shm` buffer in `Argb8888`, which on little-endian is-//! `[B, G, R, A]` per pixel. Rendering is two passes per frame - backgrounds-//! then glyphs - so a wide glyph that overflows its cell is not clipped by the-//! neighbouring cell's background fill.--use std::num::NonZeroU16;--use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};-use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};-use crate::theme::{Plane, Rgb, Theme};--/// A mutable view over a BGRA pixel buffer.-struct Canvas<'a> {-    pixels: &'a mut [u8],-    width: usize,-    height: usize,-}--impl Canvas<'_> {-    fn index(&self, x: i32, y: i32) -> Option<usize> {-        if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height {-            return None;-        }-        Some((y as usize * self.width + x as usize) * 4)-    }--    fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {-        self.fill_rect_a(x0, y0, w, h, c, 0xff);-    }--    /// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is-    /// premultiplied ARGB, so a translucent fill stores `rgb * alpha`.-    fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) {-        let x_start = x0.max(0) as usize;-        let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);-        let y_start = y0.max(0) as usize;-        let y_end = ((y0 + h as i32).max(0) as usize).min(self.height);-        if x_start >= x_end {-            return;-        }-        let a = u32::from(alpha);-        let pm = |v: u8| ((u32::from(v) * a) / 255) as u8;-        let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha];-        for y in y_start..y_end {-            let row =-                &mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4];-            for px in row.chunks_exact_mut(4) {-                px.copy_from_slice(&bytes);-            }-        }-    }--    /// Alpha-blend `fg` over the existing pixel with coverage `a`.-    fn blend(&mut self, x: i32, y: i32, fg: Rgb, a: u8) {-        let Some(i) = self.index(x, y) else { return };-        let (a, inv) = (u32::from(a), u32::from(255 - a));-        let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8;-        self.pixels[i] = mix(fg.2, self.pixels[i]);-        self.pixels[i + 1] = mix(fg.1, self.pixels[i + 1]);-        self.pixels[i + 2] = mix(fg.0, self.pixels[i + 2]);-        self.pixels[i + 3] = 0xff;-    }--    /// Set a single opaque pixel.-    fn put(&mut self, x: i32, y: i32, c: Rgb) {-        if let Some(i) = self.index(x, y) {-            self.pixels[i] = c.2;-            self.pixels[i + 1] = c.1;-            self.pixels[i + 2] = c.0;-            self.pixels[i + 3] = 0xff;-        }-    }--    fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) {-        self.fill_rect(x0, y, w, 1, c);-    }--    /// Composite one pre-multiplied BGRA source pixel over the destination.-    fn over(&mut self, x: i32, y: i32, src: &[u8]) {-        let Some(i) = self.index(x, y) else { return };-        let inv = u32::from(255 - src[3]);-        let comp = |s: u8, dst: u8| (u32::from(s) + u32::from(dst) * inv / 255).min(255) as u8;-        self.pixels[i] = comp(src[0], self.pixels[i]);-        self.pixels[i + 1] = comp(src[1], self.pixels[i + 1]);-        self.pixels[i + 2] = comp(src[2], self.pixels[i + 2]);-        self.pixels[i + 3] = 0xff;-    }-}--/// Per-frame constants shared by every row: the colour scheme, focus, and the-/// current blink phase.-#[derive(Clone, Copy, Debug)]-pub struct Frame<'a> {-    pub theme: &'a Theme,-    pub focused: bool,-    pub blink_on: bool,-    /// Hyperlink currently under the pointer; its cells get a hover underline.-    pub hovered_link: Option<NonZeroU16>,-}--#[derive(Debug)]-pub struct Renderer {-    fonts: Fonts,-    /// Inner padding `(x, y)` in pixels between the window edge and the grid.-    pad: (i32, i32),-}--impl Renderer {-    pub fn new(fonts: Fonts) -> Self {-        Self { fonts, pad: (0, 0) }-    }--    pub fn metrics(&self) -> CellMetrics {-        self.fonts.metrics()-    }--    pub fn set_padding(&mut self, pad_x: u32, pad_y: u32) {-        self.pad = (pad_x as i32, pad_y as i32);-    }--    /// Rebuild the font set at a new size (font-resize bindings).-    pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> {-        self.fonts = Fonts::new(family, size_px)?;-        Ok(())-    }--    /// Fill the whole buffer (including the padding margins) with the background-    /// colour. Called once per fresh shm buffer; per-row repaints then leave the-    /// margins untouched.-    pub fn clear(&self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme) {-        let (width, height) = dims;-        let mut canvas = Canvas {-            pixels,-            width,-            height,-        };-        canvas.fill_rect_a(0, 0, width as u32, height as u32, theme.bg, theme.alpha);-    }--    /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):-    /// clear the row band, fill backgrounds (and selection), draw glyphs and-    /// decorations, then the cursor if it sits on this row. `blink_on` is the-    /// current blink phase; blinking cells and a blinking cursor vanish when it-    /// is `false`. Painting one row at a time is what lets the caller damage-    /// only the rows that actually changed.-    pub fn render_row(-        &mut self,-        pixels: &mut [u8],-        dims: (usize, usize),-        grid: &Grid,-        frame: &Frame,-        y: usize,-    ) {-        let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on);-        let (width, height) = dims;-        let mut canvas = Canvas {-            pixels,-            width,-            height,-        };-        let m = self.fonts.metrics();-        let (pad_x, pad_y) = self.pad;-        let cols = grid.cols();-        let row_top = pad_y + y as i32 * m.height as i32;-        canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha);--        // Rows come through the scrollback viewport and may be shorter than-        // `cols` after a resize, so clamp with `take`.-        let abs = grid.view_to_abs(y);-        let cells = grid.view_row(y);-        let search = grid.search_spans_on(abs);-        let match_at = |x: usize| -> Option<bool> {-            search-                .iter()-                .find(|(lo, hi, _)| x >= *lo && x <= *hi)-                .map(|(_, _, current)| *current)-        };-        for (x, cell) in cells.iter().take(cols).enumerate() {-            // Focused match > selection > other match > the cell's own bg.-            let bg = match match_at(x) {-                Some(true) => theme.current_match_bg,-                _ if grid.is_selected(abs, x) => theme.selection_bg,-                Some(false) => theme.match_bg,-                None => cell_colors(cell, theme).1,-            };-            if bg != theme.bg {-                canvas.fill_rect(-                    pad_x + x as i32 * m.width as i32,-                    row_top,-                    m.width,-                    m.height,-                    bg,-                );-            }-        }--        for (x, cell) in cells.iter().take(cols).enumerate() {-            if cell.flags.contains(Flags::WIDE_CONT) {-                continue;-            }-            if cell.flags.contains(Flags::BLINK) && !blink_on {-                continue;-            }-            let (fg, _) = cell_colors(cell, theme);-            let origin_x = pad_x + x as i32 * m.width as i32;-            let style = cell_style(cell);-            // A cell carrying combining marks is shaped as a cluster so the-            // marks land where the font's GPOS table wants them. Shaping returns-            // None for braille (drawn directly) and for clusters the face does-            // not fully cover, both of which fall through to the legacy path.-            let shaped = match &cell.combining {-                Some(marks) if !is_braille(cell.c) => {-                    self.fonts.shape_cluster(cell.c, marks, style)-                }-                _ => None,-            };-            if let Some(shaped) = shaped {-                for placed in &shaped.glyphs {-                    if let Ok(glyph) = self.fonts.glyph_indexed(shaped.face_idx, placed.gid, style)-                    {-                        blit_glyph(-                            &mut canvas,-                            glyph,-                            m,-                            origin_x + placed.x,-                            row_top,-                            placed.y,-                            fg,-                        );-                    }-                }-            } else if is_braille(cell.c) {-                // Drawn directly so the dots are crisp and fill the cell, the-                // way tools like btop expect, rather than however the fallback-                // font happens to size its braille glyphs.-                draw_braille(&mut canvas, cell.c, origin_x, row_top, m, fg);-            } else {-                if cell.c != ' ' {-                    self.draw_glyph(&mut canvas, cell.c, style, origin_x, row_top, fg);-                }-                // No shaper available for this cluster: stack the marks over the-                // base using each mark glyph's own bearings.-                if let Some(marks) = &cell.combining {-                    for mark in marks.chars() {-                        self.draw_glyph(&mut canvas, mark, style, origin_x, row_top, fg);-                    }-                }-            }-            draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);-            // Underline an OSC 8 hyperlink while the pointer hovers over it.-            if cell.link.is_some() && cell.link == frame.hovered_link {-                canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg);-            }-        }--        // The cursor belongs to the live screen; hide it while scrolled back.-        if grid.view_at_bottom() && grid.cursor().1 == y {-            self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on);-        }-    }--    /// Draw the incremental-search prompt across the bottom row, over whatever-    /// grid content was there. The caller marks the bottom row dirty so this-    /// repaints whenever the query or match count changes.-    pub fn render_search_bar(-        &mut self,-        pixels: &mut [u8],-        dims: (usize, usize),-        theme: &Theme,-        row: usize,-        text: &str,-    ) {-        let (width, height) = dims;-        let mut canvas = Canvas {-            pixels,-            width,-            height,-        };-        let m = self.fonts.metrics();-        let (pad_x, pad_y) = self.pad;-        let row_top = pad_y + row as i32 * m.height as i32;-        canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);-        let style = Style {-            bold: false,-            italic: false,-        };-        let mut x = pad_x;-        for c in text.chars() {-            if x as usize + m.width as usize > width {-                break;-            }-            if c != ' ' {-                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);-            }-            x += m.width as i32;-        }-    }--    /// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at-    /// viewport cell `(row, col)`, over whatever was there.-    pub fn render_label(-        &mut self,-        pixels: &mut [u8],-        dims: (usize, usize),-        theme: &Theme,-        row: usize,-        col: usize,-        text: &str,-    ) {-        let (width, height) = dims;-        let mut canvas = Canvas {-            pixels,-            width,-            height,-        };-        let m = self.fonts.metrics();-        let (pad_x, pad_y) = self.pad;-        let row_top = pad_y + row as i32 * m.height as i32;-        let style = Style {-            bold: true,-            italic: false,-        };-        let mut x = pad_x + col as i32 * m.width as i32;-        for c in text.chars() {-            if x as usize + m.width as usize > width {-                break;-            }-            canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg);-            if c != ' ' {-                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg);-            }-            x += m.width as i32;-        }-    }--    /// Draw the IME preedit string inline, starting at grid cell `start_col` of-    /// row `row`, over whatever was there. The preedit sits on the selection-    /// background and is underlined so it reads as uncommitted, in-flight text.-    pub fn render_preedit(-        &mut self,-        pixels: &mut [u8],-        dims: (usize, usize),-        theme: &Theme,-        row: usize,-        start_col: usize,-        text: &str,-    ) {-        let (width, height) = dims;-        let mut canvas = Canvas {-            pixels,-            width,-            height,-        };-        let m = self.fonts.metrics();-        let (pad_x, pad_y) = self.pad;-        let row_top = pad_y + row as i32 * m.height as i32;-        let style = Style {-            bold: false,-            italic: false,-        };-        let mut x = pad_x + start_col as i32 * m.width as i32;-        for c in text.chars() {-            if x < 0 || x as usize + m.width as usize > width {-                break;-            }-            canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg);-            if c != ' ' {-                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);-            }-            // Underline the run a row above the cell bottom.-            canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg);-            x += m.width as i32;-        }-    }--    /// Draw the cursor: a solid block/underline/beam when focused, a hollow-    /// outline when not. A blinking cursor shape is only drawn while `blink_on`.-    fn draw_cursor(-        &mut self,-        canvas: &mut Canvas,-        grid: &Grid,-        theme: &Theme,-        m: CellMetrics,-        focused: bool,-        blink_on: bool,-    ) {-        if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {-            return;-        }-        let (cx, cy) = grid.cursor();-        let x0 = self.pad.0 + cx as i32 * m.width as i32;-        let top = self.pad.1 + cy as i32 * m.height as i32;-        // OSC 12 cursor colour wins, then the configured cursor colour, then fg.-        let color = grid-            .cursor_color()-            .map(|(r, g, b)| Rgb(r, g, b))-            .or(theme.cursor)-            .unwrap_or(theme.fg);--        if !focused {-            let right = x0 + m.width as i32 - 1;-            let bottom = top + m.height as i32 - 1;-            canvas.hline(x0, top, m.width, color);-            canvas.hline(x0, bottom, m.width, color);-            canvas.fill_rect(x0, top, 1, m.height, color);-            canvas.fill_rect(right, top, 1, m.height, color);-            return;-        }--        match grid.cursor_shape() {-            CursorShape::Block => {-                canvas.fill_rect(x0, top, m.width, m.height, color);-                let cell = grid.cell(cx, cy);-                if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) {-                    let (_, bg) = cell_colors(cell, theme);-                    self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg);-                }-            }-            CursorShape::Underline => {-                canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color);-            }-            CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color),-        }-    }--    fn draw_glyph(-        &mut self,-        canvas: &mut Canvas,-        c: char,-        style: Style,-        origin_x: i32,-        cell_top: i32,-        fg: Rgb,-    ) {-        let m = self.fonts.metrics();-        let glyph = match self.fonts.glyph(c, style) {-            Ok(glyph) => glyph,-            Err(err) => {-                tracing::debug!("glyph {c:?}: {err}");-                return;-            }-        };-        blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);-    }-}--/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the-/// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's-/// vertical offset, 0 for the unshaped path).-fn blit_glyph(-    canvas: &mut Canvas,-    glyph: &Glyph,-    m: CellMetrics,-    origin_x: i32,-    cell_top: i32,-    rise: i32,-    fg: Rgb,-) {-    let (gw, gh) = (glyph.width as i32, glyph.height as i32);-    match &glyph.data {-        GlyphData::Mask(mask) => {-            let baseline = cell_top + m.ascent as i32 - rise;-            for gy in 0..gh {-                for gx in 0..gw {-                    let a = mask[(gy * gw + gx) as usize];-                    if a != 0 {-                        canvas.blend(origin_x + glyph.left + gx, baseline - glyph.top + gy, fg, a);-                    }-                }-            }-        }-        // Colour glyphs (emoji) come from a fixed strike at native size;-        // scale them to the line height with nearest-neighbour sampling.-        GlyphData::Color(bgra) if gh > 0 => {-            let scale = m.height as f32 / gh as f32;-            let target_w = (gw as f32 * scale) as i32;-            for ty in 0..m.height as i32 {-                let sy = ((ty as f32 / scale) as i32).min(gh - 1);-                for tx in 0..target_w {-                    let sx = ((tx as f32 / scale) as i32).min(gw - 1);-                    let i = ((sy * gw + sx) * 4) as usize;-                    canvas.over(origin_x + tx, cell_top + ty, &bgra[i..i + 4]);-                }-            }-        }-        GlyphData::Color(_) => {}-    }-}--fn cell_style(cell: &Cell) -> Style {-    Style {-        bold: cell.flags.contains(Flags::BOLD),-        italic: cell.flags.contains(Flags::ITALIC),-    }-}--/// Resolve a cell's (foreground, background) RGB, applying reverse video,-/// bold-as-bright, dim, and hidden.-fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) {-    let bold = cell.flags.contains(Flags::BOLD);-    let mut fg = theme.resolve(cell.fg, Plane::Fg, bold);-    let mut bg = theme.resolve(cell.bg, Plane::Bg, false);-    if cell.flags.contains(Flags::REVERSE) {-        std::mem::swap(&mut fg, &mut bg);-    }-    if cell.flags.contains(Flags::DIM) {-        fg = blend_rgb(fg, bg);-    }-    if cell.flags.contains(Flags::HIDDEN) {-        fg = bg;-    }-    (fg, bg)-}--/// Mix `c` two-thirds of the way from `toward`, used for the dim attribute.-fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb {-    let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8;-    Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2))-}--/// Whether `c` is a Braille Patterns codepoint (U+2800-U+28FF).-fn is_braille(c: char) -> bool {-    ('\u{2800}'..='\u{28ff}').contains(&c)-}--/// Braille dot geometry for a `width`×`height` cell: the square dot side `w`,-/// the two column origins, and the four row origins. Ported verbatim from-/// foot's `box-drawing.c` `draw_braille` - base size and spacing from the cell,-/// then leftover pixels distributed (dot → margin → spacing → margin → dot) so-/// dots land on exact pixels with no rounding drift.-fn braille_geometry(width: i32, height: i32) -> (u32, [i32; 2], [i32; 4]) {-    let mut w = (width / 4).min(height / 8);-    let mut x_spacing = width / 4;-    let mut y_spacing = height / 8;-    let mut x_margin = x_spacing / 2;-    let mut y_margin = y_spacing / 2;--    let mut x_left = width - 2 * x_margin - x_spacing - 2 * w;-    let mut y_left = height - 2 * y_margin - 3 * y_spacing - 4 * w;--    // First, try hard to ensure a non-zero dot width.-    if x_left >= 2 && y_left >= 4 && w == 0 {-        w += 1;-        x_left -= 2;-        y_left -= 4;-    }-    // Second, prefer a non-zero margin.-    if x_left >= 2 && x_margin == 0 {-        x_margin = 1;-        x_left -= 2;-    }-    if y_left >= 2 && y_margin == 0 {-        y_margin = 1;-        y_left -= 2;-    }-    // Third, increase spacing.-    if x_left >= 1 {-        x_spacing += 1;-        x_left -= 1;-    }-    if y_left >= 3 {-        y_spacing += 1;-        y_left -= 3;-    }-    // Fourth, the side margins.-    if x_left >= 2 {-        x_margin += 1;-        x_left -= 2;-    }-    if y_left >= 2 {-        y_margin += 1;-        y_left -= 2;-    }-    // Last, increase the dot width.-    if x_left >= 2 && y_left >= 4 {-        w += 1;-    }--    let xs = [x_margin, x_margin + w + x_spacing];-    let ys = [-        y_margin,-        y_margin + w + y_spacing,-        y_margin + 2 * (w + y_spacing),-        y_margin + 3 * (w + y_spacing),-    ];-    (w.max(0) as u32, xs, ys)-}--/// Draw a braille pattern as a 2×4 grid of `w`×`w` square dots. Geometry ported-/// from foot's `box-drawing.c` `draw_braille`: a dot size and base spacing are-/// derived from the cell, then leftover pixels are distributed (dot width →-/// margins → spacing → …) so dots land on exact pixels with no rounding drift.-/// The low eight bits of the codepoint select dots: bits 0-2 are the left-/// column rows 0-2, bits 3-5 the right column rows 0-2, bits 6-7 the bottom row.-fn draw_braille(canvas: &mut Canvas, c: char, x0: i32, top: i32, m: CellMetrics, fg: Rgb) {-    let (w, xs, ys) = braille_geometry(m.width as i32, m.height as i32);-    let sym = ((c as u32) - 0x2800) as u8;-    // (bit mask, column index, row index).-    const DOTS: [(u8, usize, usize); 8] = [-        (0x01, 0, 0),-        (0x02, 0, 1),-        (0x04, 0, 2),-        (0x08, 1, 0),-        (0x10, 1, 1),-        (0x20, 1, 2),-        (0x40, 0, 3),-        (0x80, 1, 3),-    ];-    for (mask, col, row) in DOTS {-        if sym & mask != 0 {-            canvas.fill_rect(x0 + xs[col], top + ys[row], w, w, fg);-        }-    }-}--/// Draw underline, strikethrough, and overline for one cell.-fn draw_decorations(-    canvas: &mut Canvas,-    cell: &Cell,-    theme: &Theme,-    x0: i32,-    top: i32,-    m: CellMetrics,-    fg: Rgb,-) {-    let w = m.width;-    let baseline = top + m.ascent as i32;-    let uy = (baseline + 1).min(top + m.height as i32 - 1);-    // A `Default` underline colour follows the cell's foreground.-    let uc = match cell.underline_color {-        crate::grid::Color::Default => fg,-        other => theme.resolve(other, Plane::Fg, false),-    };-    match cell.underline {-        Underline::None => {}-        Underline::Single => canvas.hline(x0, uy, w, uc),-        Underline::Double => {-            canvas.hline(x0, uy, w, uc);-            canvas.hline(x0, (uy - 2).max(top), w, uc);-        }-        Underline::Curly => {-            for dx in 0..w as i32 {-                let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 };-                canvas.put(x0 + dx, uy - wobble, uc);-            }-        }-        Underline::Dotted => {-            for dx in (0..w as i32).step_by(2) {-                canvas.put(x0 + dx, uy, uc);-            }-        }-        Underline::Dashed => {-            for dx in 0..w as i32 {-                if (dx / 3) % 2 == 0 {-                    canvas.put(x0 + dx, uy, uc);-                }-            }-        }-    }-    if cell.flags.contains(Flags::OVERLINE) {-        canvas.hline(x0, top, w, fg);-    }-    if cell.flags.contains(Flags::STRIKE) {-        canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);-    }-}--#[cfg(test)]-mod tests {-    use super::braille_geometry;--    // Pinned to foot box-drawing.c draw_braille output (cross-checked numerically-    // identical across cell sizes 4..30 x 6..48); guards against drift.-    #[test]-    fn braille_geometry_matches_foot() {-        assert_eq!(braille_geometry(8, 18), (2, [1, 5], [2, 6, 10, 14]));-        assert_eq!(braille_geometry(10, 20), (2, [1, 6], [1, 6, 11, 16]));-        assert_eq!(braille_geometry(12, 27), (3, [1, 8], [1, 8, 15, 22]));-        assert_eq!(braille_geometry(7, 15), (1, [1, 4], [2, 5, 8, 11]));-    }-}diff --git a/src/theme.rs b/src/theme.rsdeleted file mode 100644index bdf6069..0000000--- a/src/theme.rs+++ /dev/null@@ -1,256 +0,0 @@-//! Runtime colours: the foreground/background, the 256-entry palette, and the-//! selection/search/cursor accents. Seeded from config and mutated at runtime-//! by OSC colour escapes (4/104, 10/11, 17/19, and their resets).--use crate::config::Colors as ColorConfig;--/// An 8-bit-per-channel RGB colour.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub struct Rgb(pub u8, pub u8, pub u8);--/// Where a `Color::Default` resolves to.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum Plane {-    Fg,-    Bg,-}--/// The active colour scheme.-#[derive(Clone, Debug)]-pub struct Theme {-    pub fg: Rgb,-    pub bg: Rgb,-    /// Cursor body colour; `None` follows the cell under the cursor.-    pub cursor: Option<Rgb>,-    pub selection_fg: Option<Rgb>,-    pub selection_bg: Rgb,-    pub match_bg: Rgb,-    pub current_match_bg: Rgb,-    pub search_bar_bg: Rgb,-    pub palette: [Rgb; 256],-    pub bold_as_bright: bool,-    /// Background opacity, 0 (transparent) - 255 (opaque).-    pub alpha: u8,-    /// Configured defaults, restored by OSC reset escapes (110/111/104).-    default_fg: Rgb,-    default_bg: Rgb,-    default_palette: [Rgb; 256],-}--impl Default for Theme {-    fn default() -> Self {-        let palette = default_palette();-        Self {-            fg: DEFAULT_FG,-            bg: DEFAULT_BG,-            cursor: None,-            selection_fg: None,-            selection_bg: SELECTION_BG,-            match_bg: MATCH_BG,-            current_match_bg: CURRENT_MATCH_BG,-            search_bar_bg: SEARCH_BAR_BG,-            palette,-            bold_as_bright: true,-            alpha: 255,-            default_fg: DEFAULT_FG,-            default_bg: DEFAULT_BG,-            default_palette: palette,-        }-    }-}--impl Theme {-    /// Build a theme from the `[colors]` config table, falling back to the-    /// built-in defaults for any unset entry.-    pub fn from_config(c: &ColorConfig) -> Self {-        let mut theme = Self::default();-        let set = |dst: &mut Rgb, spec: &Option<String>| {-            if let Some(rgb) = spec.as_deref().and_then(parse_color) {-                *dst = rgb;-            }-        };-        set(&mut theme.fg, &c.foreground);-        set(&mut theme.bg, &c.background);-        set(&mut theme.selection_bg, &c.selection_background);-        set(&mut theme.match_bg, &c.match_background);-        set(&mut theme.current_match_bg, &c.match_current_background);-        theme.cursor = c.cursor.as_deref().and_then(parse_color);-        theme.selection_fg = c.selection_foreground.as_deref().and_then(parse_color);-        if let Some(regular) = &c.regular {-            apply_palette(&mut theme.palette, 0, regular);-        }-        if let Some(bright) = &c.bright {-            apply_palette(&mut theme.palette, 8, bright);-        }-        if let Some(a) = c.alpha {-            theme.alpha = (a.clamp(0.0, 1.0) * 255.0).round() as u8;-        }-        if let Some(b) = c.bold_as_bright {-            theme.bold_as_bright = b;-        }-        // Remember the resolved scheme so OSC resets return here, not to the-        // compiled-in defaults.-        theme.default_fg = theme.fg;-        theme.default_bg = theme.bg;-        theme.default_palette = theme.palette;-        theme-    }--    /// Resolve a cell colour to RGB on the given plane, applying bold-as-bright-    /// to the low 8 palette indices.-    pub fn resolve(&self, color: crate::grid::Color, plane: Plane, bold: bool) -> Rgb {-        use crate::grid::Color;-        match color {-            Color::Default => match plane {-                Plane::Fg => self.fg,-                Plane::Bg => self.bg,-            },-            Color::Indexed(i) => {-                let idx = if bold && self.bold_as_bright && i < 8 {-                    i + 8-                } else {-                    i-                };-                self.palette[idx as usize]-            }-            Color::Rgb(r, g, b) => Rgb(r, g, b),-        }-    }--    pub fn set_palette(&mut self, index: u8, rgb: Rgb) {-        self.palette[index as usize] = rgb;-    }--    /// Reset palette entry `index` to its configured value.-    pub fn reset_palette_index(&mut self, index: u8) {-        self.palette[index as usize] = self.default_palette[index as usize];-    }--    /// Reset the whole palette to its configured values.-    pub fn reset_palette(&mut self) {-        self.palette = self.default_palette;-    }--    pub fn reset_fg(&mut self) {-        self.fg = self.default_fg;-    }--    pub fn reset_bg(&mut self) {-        self.bg = self.default_bg;-    }--    /// A copy with foreground and background swapped, for the visual bell flash.-    pub fn inverted(&self) -> Self {-        let mut t = self.clone();-        std::mem::swap(&mut t.fg, &mut t.bg);-        t-    }-}--/// Foreground/background used for `Color::Default`.-const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);-const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);-const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);-const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);-const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);-const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);--/// Overwrite palette entries `[base, base+specs.len())` from hex specs.-fn apply_palette(palette: &mut [Rgb; 256], base: usize, specs: &[String]) {-    for (i, spec) in specs.iter().take(8).enumerate() {-        if let Some(rgb) = parse_color(spec) {-            palette[base + i] = rgb;-        }-    }-}--/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.-fn default_palette() -> [Rgb; 256] {-    const BASE: [Rgb; 16] = [-        Rgb(0x00, 0x00, 0x00),-        Rgb(0xcd, 0x00, 0x00),-        Rgb(0x00, 0xcd, 0x00),-        Rgb(0xcd, 0xcd, 0x00),-        Rgb(0x00, 0x00, 0xee),-        Rgb(0xcd, 0x00, 0xcd),-        Rgb(0x00, 0xcd, 0xcd),-        Rgb(0xe5, 0xe5, 0xe5),-        Rgb(0x7f, 0x7f, 0x7f),-        Rgb(0xff, 0x00, 0x00),-        Rgb(0x00, 0xff, 0x00),-        Rgb(0xff, 0xff, 0x00),-        Rgb(0x5c, 0x5c, 0xff),-        Rgb(0xff, 0x00, 0xff),-        Rgb(0x00, 0xff, 0xff),-        Rgb(0xff, 0xff, 0xff),-    ];-    let cube = |step: u8| -> u8 { if step == 0 { 0 } else { 55 + 40 * step } };-    std::array::from_fn(|i| match i {-        0..=15 => BASE[i],-        16..=231 => {-            let i = i as u8 - 16;-            Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))-        }-        _ => {-            let v = 8 + 10 * (i as u8 - 232);-            Rgb(v, v, v)-        }-    })-}--/// Parse an X11 colour spec: `rgb:rr/gg/bb` (1-4 hex digits per channel) or-/// `#rrggbb`.-pub fn parse_color(spec: &str) -> Option<Rgb> {-    if let Some(rest) = spec.strip_prefix("rgb:") {-        let mut it = rest.split('/');-        let chan = |s: &str| -> Option<u8> {-            let v = u32::from_str_radix(s, 16).ok()?;-            let max = (1u32 << (4 * s.len() as u32)) - 1;-            Some((v * 255 / max) as u8)-        };-        return Some(Rgb(chan(it.next()?)?, chan(it.next()?)?, chan(it.next()?)?));-    }-    let hex = spec.strip_prefix('#')?;-    if hex.len() == 6 {-        let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();-        return Some(Rgb(byte(0)?, byte(2)?, byte(4)?));-    }-    None-}--#[cfg(test)]-mod tests {-    use super::*;--    #[test]-    fn parses_color_forms() {-        assert_eq!(parse_color("#ff0080"), Some(Rgb(255, 0, 128)));-        assert_eq!(parse_color("rgb:ff/00/80"), Some(Rgb(255, 0, 128)));-        assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(Rgb(255, 0, 128)));-        assert_eq!(parse_color("nope"), None);-    }--    #[test]-    fn palette_cube_and_grey() {-        let p = default_palette();-        assert_eq!(p[16], Rgb(0, 0, 0));-        assert_eq!(p[231], Rgb(255, 255, 255));-        assert_eq!(p[232], Rgb(8, 8, 8));-    }--    #[test]-    fn config_overrides_and_resets() {-        let cfg = ColorConfig {-            background: Some("#000000".to_string()),-            regular: Some(vec!["#111111".to_string()]),-            ..ColorConfig::default()-        };-        let mut theme = Theme::from_config(&cfg);-        assert_eq!(theme.bg, Rgb(0, 0, 0));-        assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));-        // OSC reset returns to the configured value, not the compiled default.-        theme.set_palette(0, Rgb(9, 9, 9));-        theme.reset_palette_index(0);-        assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));-    }-}diff --git a/src/vt/mod.rs b/src/vt/mod.rsdeleted file mode 100644index af8ac58..0000000--- a/src/vt/mod.rs+++ /dev/null@@ -1,1023 +0,0 @@-//! 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\\"),-        }-    }--    /// Reply to a kitty-keyboard flags query (`CSI ? u`) with `CSI ? flags u`.-    fn report_kitty_flags(&mut self) {-        let _ = write!(self.response, "\x1b[?{}u", self.grid.kitty_flags());-    }--    /// 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.rsdeleted file mode 100644index 202f29f..0000000--- a/src/vt/perform.rs+++ /dev/null@@ -1,250 +0,0 @@-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(),-            // `CSI u` is SCORC, but the kitty keyboard protocol overloads it with-            // private prefixes: `?` query, `>` push, `<` pop, `=` set flags.-            'u' => match intermediates.first() {-                Some(b'?') => self.report_kitty_flags(),-                Some(b'>') => self.grid.kitty_push(n(params, 0, 0) as u8),-                Some(b'<') => self.grid.kitty_pop(n(params, 0, 1)),-                Some(b'=') => self-                    .grid-                    .kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),-                _ => 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/handlers.rs b/src/wayland/handlers.rsdeleted file mode 100644index 95002e4..0000000--- a/src/wayland/handlers.rs+++ /dev/null@@ -1,883 +0,0 @@-use super::*;-use crate::bindings::MouseButton;--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();-        self.sync_idle_inhibit();-        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 i = self.seat_index(&seat);-        self.ensure_clipboard_devices(qh, &seat, i);-    }--    fn new_capability(-        &mut self,-        _: &Connection,-        qh: &QueueHandle<Self>,-        seat: wl_seat::WlSeat,-        capability: Capability,-    ) {-        let i = self.seat_index(&seat);-        // A pre-existing seat may reach us via its capabilities without a-        // `new_seat` call, so make sure the clipboard devices are attached.-        self.ensure_clipboard_devices(qh, &seat, i);-        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}"),-            }-        }-        if capability == Capability::Touch && self.seats[i].touch.is_none() {-            match self.seat_state.get_touch(qh, &seat) {-                Ok(touch) => self.seats[i].touch = Some(touch),-                Err(err) => tracing::warn!("get touch: {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();-                }-            }-            Capability::Touch => {-                if let Some(touch) = s.touch.take() {-                    touch.release();-                }-                self.touch_scroll = None;-            }-            _ => {}-        }-    }--    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.sync_idle_inhibit();-        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.sync_idle_inhibit();-        // Drop held-key state so a key released while unfocused can't leak a-        // stale kitty release event later.-        self.keys_down.clear();-        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,-        event: KeyEvent,-    ) {-        self.handle_key_release(&event);-    }--    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,-    }-}--/// Map a Wayland button code to a bindable [`MouseButton`].-fn mouse_button(button: u32) -> Option<MouseButton> {-    match button {-        BTN_LEFT => Some(MouseButton::Left),-        BTN_MIDDLE => Some(MouseButton::Middle),-        BTN_RIGHT => Some(MouseButton::Right),-        _ => 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;-                    }-                    // A configured `[mouse-bindings]` action (Middle defaults to-                    // primary paste) fires before the built-in left-drag select.-                    if let Some(mb) = mouse_button(*button)-                        && let Some(action) = self.bindings.mouse_action(mb, self.modifiers)-                    {-                        self.dispatch_action(action);-                        continue;-                    }-                    if *button == BTN_LEFT {-                        self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);-                        self.pointer_press(*time);-                    }-                }-                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;-                    }-                }-                _ => {}-            }-        }-    }-}--// Touch drives one-finger drag-to-scroll of the scrollback viewport. Taps and-// multi-finger gestures are ignored; only the first touch point is tracked.-impl TouchHandler for App {-    fn down(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_touch::WlTouch,-        _serial: u32,-        _time: u32,-        _surface: wl_surface::WlSurface,-        id: i32,-        position: (f64, f64),-    ) {-        if self.touch_scroll.is_none() {-            self.touch_scroll = Some(TouchScroll {-                id,-                last_y: position.1,-                acc: 0.0,-            });-        }-    }--    fn up(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_touch::WlTouch,-        _serial: u32,-        _time: u32,-        id: i32,-    ) {-        if self.touch_scroll.as_ref().is_some_and(|t| t.id == id) {-            self.touch_scroll = None;-        }-    }--    fn motion(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_touch::WlTouch,-        _time: u32,-        id: i32,-        position: (f64, f64),-    ) {-        let cell_h = self.renderer.metrics().height as f64;-        let Some(touch) = self.touch_scroll.as_mut().filter(|t| t.id == id) else {-            return;-        };-        touch.acc += position.1 - touch.last_y;-        touch.last_y = position.1;-        // Dragging down (positive delta) reveals older lines, matching the-        // viewport's "positive scrolls back" convention.-        let lines = (touch.acc / cell_h) as isize;-        if lines != 0 {-            touch.acc -= lines as f64 * cell_h;-            if let Some(session) = self.session.as_mut() {-                session.term.scroll_view(lines);-                self.needs_draw = true;-            }-        }-    }--    fn shape(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_touch::WlTouch,-        _: i32,-        _: f64,-        _: f64,-    ) {-    }--    fn orientation(-        &mut self,-        _: &Connection,-        _: &QueueHandle<Self>,-        _: &wl_touch::WlTouch,-        _: i32,-        _: f64,-    ) {-    }--    fn cancel(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_touch::WlTouch) {-        self.touch_scroll = None;-    }-}--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>,-    ) {-    }-}--// idle-inhibit and content-type are likewise raw protocol objects; none of them-// emit events we act on.-impl Dispatch<ZwpIdleInhibitManagerV1, ()> for App {-    fn event(-        _: &mut Self,-        _: &ZwpIdleInhibitManagerV1,-        _: <ZwpIdleInhibitManagerV1 as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl Dispatch<ZwpIdleInhibitorV1, ()> for App {-    fn event(-        _: &mut Self,-        _: &ZwpIdleInhibitorV1,-        _: zwp_idle_inhibitor_v1::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl Dispatch<WpContentTypeManagerV1, ()> for App {-    fn event(-        _: &mut Self,-        _: &WpContentTypeManagerV1,-        _: <WpContentTypeManagerV1 as Proxy>::Event,-        _: &(),-        _: &Connection,-        _: &QueueHandle<Self>,-    ) {-    }-}--impl Dispatch<WpContentTypeV1, ()> for App {-    fn event(-        _: &mut Self,-        _: &WpContentTypeV1,-        _: wp_content_type_v1::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_touch!(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.rsdeleted file mode 100644index b74ba1f..0000000--- a/src/wayland/mod.rs+++ /dev/null@@ -1,1878 +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.--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_touch, 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,-        },-        touch::TouchHandler,-    },-    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, wl_touch,-    },-};-use wayland_protocols::wp::content_type::v1::client::{-    wp_content_type_manager_v1::WpContentTypeManagerV1,-    wp_content_type_v1::{self, WpContentTypeV1},-};-use wayland_protocols::wp::fractional_scale::v1::client::{-    wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,-    wp_fractional_scale_v1::{self, WpFractionalScaleV1},-};-use wayland_protocols::wp::idle_inhibit::zv1::client::{-    zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,-    zwp_idle_inhibitor_v1::{self, ZwpIdleInhibitorV1},-};-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();-    let idle_inhibit_manager = bind_global::<ZwpIdleInhibitManagerV1>(&globals, &qh);-    // Tag the surface as plain content (a terminal is none of photo/video/game)-    // so the compositor applies no media-specific treatment. Applies on commit.-    let content_type = bind_global::<WpContentTypeManagerV1>(&globals, &qh)-        .map(|mgr| mgr.get_surface_content_type(window.wl_surface(), &qh, ()));-    if let Some(ct) = &content_type {-        ct.set_content_type(wp_content_type_v1::Type::None);-    }--    // 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,-        &config.mouse_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,-        idle_inhibit_manager,-        idle_inhibitor: None,-        content_type,-        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(),-        unicode_input: None,-        keys_down: std::collections::HashSet::new(),-        touch_scroll: None,-        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>,-    touch: Option<wl_touch::WlTouch>,-}--/// A single-finger touch drag in progress, used to scroll the viewport.-#[derive(Debug)]-struct TouchScroll {-    /// Touch point id we are tracking (the first finger down).-    id: i32,-    /// Surface-local y of the last motion, to take per-event deltas.-    last_y: f64,-    /// Sub-cell pixel remainder carried between motions.-    acc: f64,-}--/// 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>,-    /// idle-inhibit-v1 manager; an inhibitor is held while focused when the-    /// `[main] idle-inhibit` config is on, so the screen does not blank.-    idle_inhibit_manager: Option<ZwpIdleInhibitManagerV1>,-    idle_inhibitor: Option<ZwpIdleInhibitorV1>,-    /// content-type-v1 hint object. Set once at startup; held only so the-    /// object (and thus the hint) outlives construction.-    #[allow(-        dead_code,-        reason = "kept alive to preserve the surface content-type hint"-    )]-    content_type: Option<WpContentTypeV1>,-    /// 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,-    /// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.-    unicode_input: Option<String>,-    /// Raw key codes currently held, to tell press from repeat for the kitty-    /// keyboard protocol's event-type reporting.-    keys_down: std::collections::HashSet<u32>,-    /// A single-finger touch drag in progress (scrolls the viewport).-    touch_scroll: Option<TouchScroll>,-    /// 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) {-        // A new arrival of a held key is a repeat; otherwise a fresh press.-        let kind = if self.keys_down.insert(event.raw_code) {-            crate::input::KeyKind::Press-        } else {-            crate::input::KeyKind::Repeat-        };--        // The Unicode-input prompt, URL hint mode, and search each capture the-        // keyboard while active.-        if self.unicode_input.is_some() {-            self.unicode_key(event);-            return;-        }-        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, kitty) = self.session.as_ref().map_or((false, 0), |s| {-            (s.term.grid().app_cursor(), s.term.grid().kitty_flags())-        });-        let bytes = if kitty != 0 {-            crate::input::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)-        } else {-            crate::input::encode(event, self.modifiers, app_cursor)-        };-        if let Some(bytes) = bytes {-            self.send_to_shell(&bytes);-        }-    }--    /// Handle a key release: only the kitty keyboard protocol cares, and only-    /// when it has asked for event reporting.-    fn handle_key_release(&mut self, event: &KeyEvent) {-        self.keys_down.remove(&event.raw_code);-        let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {-            (s.term.grid().app_cursor(), s.term.grid().kitty_flags())-        });-        if kitty == 0 {-            return;-        }-        if let Some(bytes) = crate::input::kitty_encode(-            event,-            self.modifiers,-            kitty,-            crate::input::KeyKind::Release,-            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(),-            Action::UnicodeInput => {-                self.unicode_input = Some(String::new());-                self.needs_draw = true;-            }-        }-    }--    /// Handle a key while Unicode codepoint-input mode is active: accumulate hex-    /// digits, then commit the codepoint as UTF-8 on Enter/Space.-    fn unicode_key(&mut self, event: &KeyEvent) {-        match event.keysym {-            Keysym::Escape => {-                self.unicode_input = None;-                self.needs_draw = true;-            }-            Keysym::BackSpace => {-                if let Some(buf) = self.unicode_input.as_mut() {-                    buf.pop();-                }-                self.needs_draw = true;-            }-            Keysym::Return | Keysym::KP_Enter | Keysym::space => {-                let buf = self.unicode_input.take().unwrap_or_default();-                if let Some(c) = u32::from_str_radix(buf.trim(), 16)-                    .ok()-                    .and_then(char::from_u32)-                {-                    let mut bytes = [0u8; 4];-                    let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();-                    self.send_to_shell(&s);-                }-                self.needs_draw = true;-            }-            _ => {-                if let Some(text) = event.utf8.as_ref() {-                    let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();-                    // Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.-                    if let Some(buf) = self.unicode_input.as_mut()-                        && buf.len() + hex.len() <= 6-                    {-                        buf.push_str(&hex);-                    }-                    self.needs_draw = true;-                }-            }-        }-    }--    /// 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,-            &new.mouse_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)-    }--    /// Attach the seat-scoped clipboard and primary-selection devices to seat-    /// `i` if not already present. Idempotent, so it can run from either-    /// `new_seat` or `new_capability` - some compositors surface a pre-existing-    /// seat's capabilities without ever firing `new_seat`.-    fn ensure_clipboard_devices(-        &mut self,-        qh: &QueueHandle<Self>,-        seat: &wl_seat::WlSeat,-        i: usize,-    ) {-        if self.seats[i].data_device.is_none() {-            let dd = self.data_device_manager.get_data_device(qh, seat);-            self.seats[i].data_device = Some(dd);-        }-        if self.seats[i].primary_device.is_none()-            && let Some(m) = self.primary_manager.as_ref()-        {-            let pd = m.get_selection_device(qh, seat);-            self.seats[i].primary_device = Some(pd);-        }-    }--    /// 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,-            touch: 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" });-        }-    }--    /// Create or drop the idle inhibitor to match `[main] idle-inhibit` and the-    /// current focus: inhibit only while focused, so a backgrounded terminal-    /// still lets the screen blank. Idempotent; called on every focus change.-    fn sync_idle_inhibit(&mut self) {-        let want = self.config.main.idle_inhibit && self.focused;-        if want && self.idle_inhibitor.is_none() {-            if let Some(mgr) = &self.idle_inhibit_manager {-                self.idle_inhibitor =-                    Some(mgr.create_inhibitor(self.window.wl_surface(), &self.qh, ()));-            }-        } else if !want && let Some(inhibitor) = self.idle_inhibitor.take() {-            inhibitor.destroy();-        }-    }--    /// 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 {-            tracing::debug!("paste: no clipboard selection offer");-            return;-        };-        let Some(mime) = offer.with_mime_types(pick_mime) else {-            tracing::debug!("paste: no acceptable text mime type offered");-            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) => {-                        tracing::debug!("paste: read {} bytes from clipboard", data.len());-                        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.rsdeleted file mode 100644index 9c69731..0000000--- a/src/wayland/rendering.rs+++ /dev/null@@ -1,231 +0,0 @@-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 = if let Some(hex) = &self.unicode_input {-            Some(format!("unicode: U+{}", hex.to_uppercase()))-        } else {-            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