clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 1f1451f108c0
tarballunverified47 files changed+9926-9500
@@ -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"@@ -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]@@ -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 = true@@ -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.@@ -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);+ }+}@@ -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+ }+}@@ -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);+ }+}@@ -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())+ );+ }+}@@ -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,+};@@ -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()+ );+ }+}@@ -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));+ }+}@@ -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,+ }+}@@ -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 = true@@ -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);+ }+}@@ -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");+ }+}@@ -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(®ular.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");+ }+}@@ -0,0 +1,136 @@+use std::num::NonZeroU16;++use super::*;++/// A URL detected in the visible viewport, with the `(row, col)` of its first+/// character in viewport coordinates.+#[derive(Clone, PartialEq, Eq, Debug)]+pub struct UrlHit {+ pub url: String,+ pub row: usize,+ pub col: usize,+}++/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the+/// delimiters that conventionally bound a URL in flowing text).+fn is_url_char(c: char) -> bool {+ !c.is_whitespace()+ && !c.is_control()+ && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')+}++/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.+/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs+/// to the first non-URL character, with trailing sentence punctuation trimmed.+fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {+ let mut out = Vec::new();+ let mut i = 0;+ while i + 2 < chars.len() {+ if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {+ // Backtrack over the scheme.+ let mut start = i;+ while start > 0 {+ let c = chars[start - 1];+ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {+ start -= 1;+ } else {+ break;+ }+ }+ if start < i && chars[start].is_ascii_alphabetic() {+ let mut end = i + 3;+ while end < chars.len() && is_url_char(chars[end]) {+ end += 1;+ }+ // Trim trailing punctuation that is usually sentence-level.+ while end > i + 3+ && matches!(+ chars[end - 1],+ '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'+ )+ {+ end -= 1;+ }+ if end > i + 3 {+ out.push((start, end));+ i = end;+ continue;+ }+ }+ }+ i += 1;+ }+ out+}++impl Grid {+ /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An+ /// empty/`None` URI ends the current link.+ pub fn set_link(&mut self, uri: Option<&str>) {+ self.pen.link = match uri {+ Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),+ _ => None,+ };+ }++ /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table+ /// is capped so a pathological stream cannot grow it without bound).+ fn intern_link(&mut self, uri: &str) -> NonZeroU16 {+ if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {+ return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");+ }+ // u16::MAX distinct links is far past any real document; reuse the last+ // slot once saturated rather than overflow the id space.+ if self.links.len() < usize::from(u16::MAX) - 1 {+ self.links.push(uri.into());+ } else {+ *self.links.last_mut().expect("table is non-empty when full") = uri.into();+ }+ NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")+ }++ /// The URI for a hyperlink id, if it is still in the table.+ pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {+ self.links+ .get(usize::from(id.get()) - 1)+ .map(|s| s.as_ref())+ }++ /// The hyperlink id of the cell at an absolute `(row, col)`, if any.+ pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {+ self.abs_row(abs_row).get(col).and_then(|c| c.link)+ }+ /// Detect plain-text URLs across the visible viewport, returning each with+ /// the viewport `(row, col)` of its first character. Soft-wrapped rows are+ /// joined so a URL split across a wrap is found whole; hard line breaks end+ /// a URL.+ pub fn visible_urls(&self) -> Vec<UrlHit> {+ let mut chars: Vec<char> = Vec::new();+ let mut pos: Vec<(usize, usize)> = Vec::new();+ for y in 0..self.rows {+ let line = self.line_at_abs(self.view_to_abs(y));+ for (x, cell) in line.cells.iter().enumerate() {+ if cell.flags.contains(Flags::WIDE_CONT) {+ continue;+ }+ chars.push(cell.c);+ pos.push((y, x));+ }+ if !line.wrapped {+ chars.push('\n'); // a hard break terminates any URL+ pos.push((y, usize::MAX));+ }+ }+ find_urls(&chars)+ .into_iter()+ .map(|(s, e)| {+ let (row, col) = pos[s];+ UrlHit {+ url: chars[s..e].iter().collect(),+ row,+ col,+ }+ })+ .collect()+ }+}@@ -0,0 +1,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), "");+ }+}@@ -0,0 +1,148 @@+use super::*;++/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+struct Match {+ row: usize,+ col: usize,+ len: usize,+}++/// Incremental scrollback search: the query, every hit, and the focused one.+#[derive(Clone, Debug, Default)]+pub(super) struct SearchState {+ query: String,+ matches: Vec<Match>,+ current: usize,+}++impl Grid {+ // --- scrollback search ---++ /// Set the search query and recompute matches over scrollback + the live+ /// screen, focusing the most recent hit and scrolling it into view. An+ /// empty query keeps search mode active but clears the hit list.+ pub fn set_search(&mut self, query: &str) {+ let matches = if query.is_empty() {+ Vec::new()+ } else {+ self.compute_matches(query)+ };+ let current = matches.len().saturating_sub(1);+ self.search = Some(SearchState {+ query: query.to_string(),+ matches,+ current,+ });+ self.jump_to_current();+ }++ /// Move the focused match `forward` (toward newer) or back (toward older),+ /// wrapping, and scroll it into view.+ pub fn search_step(&mut self, forward: bool) {+ if let Some(s) = self.search.as_mut()+ && !s.matches.is_empty()+ {+ let n = s.matches.len();+ s.current = if forward {+ (s.current + 1) % n+ } else {+ (s.current + n - 1) % n+ };+ }+ self.jump_to_current();+ }++ pub fn clear_search(&mut self) {+ self.search = None;+ }++ /// The current query, while search mode is active.+ pub fn search_query(&self) -> Option<&str> {+ self.search.as_ref().map(|s| s.query.as_str())+ }++ /// `(focused match index 1-based, total matches)` for the search prompt.+ pub fn search_count(&self) -> (usize, usize) {+ self.search.as_ref().map_or((0, 0), |s| {+ let total = s.matches.len();+ (if total == 0 { 0 } else { s.current + 1 }, total)+ })+ }++ /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.+ pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {+ let Some(s) = self.search.as_ref() else {+ return Vec::new();+ };+ s.matches+ .iter()+ .enumerate()+ .filter(|(_, m)| m.row == row && m.len > 0)+ .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))+ .collect()+ }++ fn compute_matches(&self, query: &str) -> Vec<Match> {+ // Smart case: an uppercase letter in the query forces case sensitivity.+ let sensitive = query.chars().any(|c| c.is_ascii_uppercase());+ let fold = |c: char| {+ if sensitive { c } else { c.to_ascii_lowercase() }+ };+ let needle: Vec<char> = query.chars().map(fold).collect();+ if needle.is_empty() {+ return Vec::new();+ }+ let total = self.scrollback.len() + self.rows;+ let mut matches = Vec::new();+ for row in 0..total {+ let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();+ let mut i = 0;+ while i + needle.len() <= hay.len() {+ if hay[i..i + needle.len()] == needle[..] {+ matches.push(Match {+ row,+ col: i,+ len: needle.len(),+ });+ i += needle.len();+ } else {+ i += 1;+ }+ }+ }+ matches+ }++ /// Scroll the viewport so the focused match is on screen, centering it only+ /// when it would otherwise be off the visible range.+ fn jump_to_current(&mut self) {+ let Some(abs) = self+ .search+ .as_ref()+ .and_then(|s| s.matches.get(s.current))+ .map(|m| m.row)+ else {+ return;+ };+ let sb = self.scrollback.len();+ let top = sb - self.view_offset;+ let bottom = top + self.rows - 1;+ if abs < top || abs > bottom {+ let target = sb as isize + self.rows as isize / 2 - abs as isize;+ self.view_offset = target.clamp(0, sb as isize) as usize;+ }+ }++ /// Slide search hits up by `n` rows after scrollback eviction, dropping any+ /// that scrolled off the top.+ pub(super) fn shift_search(&mut self, n: usize) {+ if let Some(s) = self.search.as_mut() {+ s.matches.retain(|m| m.row >= n);+ for m in &mut s.matches {+ m.row -= n;+ }+ s.current = s.current.min(s.matches.len().saturating_sub(1));+ }+ }+}@@ -0,0 +1,190 @@+use super::*;++impl Grid {+ /// Slide an active selection up by `n` rows after scrollback eviction,+ /// dropping it if either endpoint scrolled off the top.+ pub(super) fn shift_selection(&mut self, n: usize) {+ if let Some((a, b)) = self.selection {+ if a.row < n || b.row < n {+ self.selection = None;+ } else {+ self.selection = Some((+ Point {+ row: a.row - n,+ ..a+ },+ Point {+ row: b.row - n,+ ..b+ },+ ));+ }+ }+ }++ pub fn clear_selection(&mut self) {+ self.selection = None;+ }++ /// Begin a linear selection at an absolute point (drag anchor).+ pub fn start_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = false;+ }++ /// Begin a rectangular (block) selection at an absolute point.+ pub fn start_block_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = true;+ }++ /// Move the selection head (drag), keeping the anchor fixed.+ pub fn extend_selection(&mut self, row: usize, col: usize) {+ if let Some((_, head)) = self.selection.as_mut() {+ *head = Point { row, col };+ }+ }++ /// Select the word at an absolute point, breaking on whitespace and the+ /// default delimiter set.+ pub fn select_word(&mut self, row: usize, col: usize) {+ let delims = &self.word_delimiters;+ let line = self.abs_row(row);+ if col >= line.len() || !is_word(line[col].c, delims) {+ self.start_selection(row, col);+ return;+ }+ let mut lo = col;+ while lo > 0 && is_word(line[lo - 1].c, delims) {+ lo -= 1;+ }+ let mut hi = col;+ while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {+ hi += 1;+ }+ self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));+ self.selection_block = false;+ }++ /// Select the whole line at an absolute row.+ pub fn select_line(&mut self, row: usize) {+ let last = self.abs_row(row).len().saturating_sub(1);+ self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));+ self.selection_block = false;+ }++ /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block+ /// selection.+ fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {+ let (a, b) = self.selection?;+ Some((+ a.row.min(b.row),+ a.row.max(b.row),+ a.col.min(b.col),+ a.col.max(b.col),+ ))+ }++ /// Normalized selection (start <= end in reading order), if any.+ fn ordered_selection(&self) -> Option<(Point, Point)> {+ self.selection.map(|(a, b)| {+ if (a.row, a.col) <= (b.row, b.col) {+ (a, b)+ } else {+ (b, a)+ }+ })+ }++ /// Whether the cell at an absolute `(row, col)` falls inside the selection.+ pub fn is_selected(&self, row: usize, col: usize) -> bool {+ if self.selection_block {+ let Some((r0, r1, c0, c1)) = self.block_rect() else {+ return false;+ };+ return row >= r0 && row <= r1 && col >= c0 && col <= c1;+ }+ let Some((start, end)) = self.ordered_selection() else {+ return false;+ };+ if row < start.row || row > end.row {+ return false;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row { end.col } else { usize::MAX };+ col >= lo && col <= hi+ }++ /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if+ /// any part of that row is selected.+ pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ return (row >= r0 && row <= r1).then_some((c0, c1));+ }+ let (start, end) = self.ordered_selection()?;+ if row < start.row || row > end.row {+ return None;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ end.col+ } else {+ self.abs_row(row).len().saturating_sub(1)+ };+ Some((lo, hi))+ }++ /// The selected text, with trailing blanks trimmed per line and rows joined+ /// by newlines. `None` if there is no selection.+ pub fn selection_text(&self) -> Option<String> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ let mut out = String::new();+ for row in r0..=r1 {+ out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());+ if row != r1 {+ out.push('\n');+ }+ }+ return Some(out);+ }+ let (start, end) = self.ordered_selection()?;+ let mut out = String::new();+ for row in start.row..=end.row {+ let line = self.abs_row(row);+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ (end.col + 1).min(line.len())+ } else {+ line.len()+ };+ out.push_str(self.row_slice_text(row, lo, hi).trim_end());+ if row != end.row {+ out.push('\n');+ }+ }+ Some(out)+ }++ /// The characters of an absolute row in `[from, to)`, skipping wide+ /// continuation cells.+ pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {+ let mut out = String::new();+ for cell in self+ .abs_row(row)+ .get(from..to.min(self.abs_row(row).len()))+ .unwrap_or(&[])+ .iter()+ .filter(|c| !c.flags.contains(Flags::WIDE_CONT))+ {+ out.push(cell.c);+ if let Some(marks) = &cell.combining {+ out.push_str(marks);+ }+ }+ out+ }+}@@ -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)+}@@ -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");+ }+}@@ -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]));+ }+}@@ -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));+ }+}@@ -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");+ }+}@@ -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 ¶ms[1..] {+ if let Some(i) = parse_index(p) {+ self.theme.reset_palette_index(i);+ }+ }+ }+ }+ // OSC 10/11: foreground / background; OSC 110/111 reset them.+ Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),+ Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),+ Some(&n) if n == b"110" => self.theme.reset_fg(),+ Some(&n) if n == b"111" => self.theme.reset_bg(),+ // OSC 17/19: selection (highlight) background / foreground.+ Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),+ Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),+ // OSC 12: set cursor colour; OSC 112: reset to default.+ Some(&n) if n == b"12" => {+ self.grid+ .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));+ }+ Some(&n) if n == b"112" => self.grid.set_cursor_color(None),+ // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or+ // `?` to query. We only touch `c` (clipboard) and `p` (primary).+ Some(&n) if n == b"52" => {+ let target = params.get(1).copied().unwrap_or(b"");+ let data = params.get(2).copied().unwrap_or(b"");+ let primary = target.first() == Some(&b'p');+ if data == b"?" {+ self.clipboard_ops.push(ClipboardOp::Query { primary });+ } else if let Some(text) =+ base64_decode(data).and_then(|b| String::from_utf8(b).ok())+ {+ self.clipboard_ops.push(ClipboardOp::Set { primary, text });+ }+ }+ _ => {}+ }+ }++ fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {+ // XTGETTCAP arrives as `DCS + q <names> ST`.+ if action == 'q' && intermediates == [b'+'] {+ self.xtgettcap = Some(Vec::new());+ }+ }++ fn put(&mut self, byte: u8) {+ if let Some(buf) = self.xtgettcap.as_mut() {+ buf.push(byte);+ }+ }++ fn unhook(&mut self) {+ if let Some(payload) = self.xtgettcap.take() {+ self.answer_xtgettcap(&payload);+ }+ }+}@@ -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)