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(

show full diff (262144 bytes truncated)