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(action);+ continue;+ }+ if *button == BTN_LEFT {+ self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+ self.pointer_press(*time);+ }+ }+ PointerEventKind::Release { button, .. } => {+ self.pointer_pos = event.position;+ let code = button_code(*button);+ if let Some(code) = code+ && self.try_report_button(code, false)+ {+ if self.pressed_button == Some(code) {+ self.pressed_button = None;+ }+ continue;+ }+ if *button == BTN_LEFT {+ self.maybe_open_clicked_link();+ self.pointer_release(qh);+ }+ }+ PointerEventKind::Axis { vertical, .. } => {+ // Wheel notches arrive as value120 (÷120) or legacy discrete+ // steps, ~3 lines each; touchpads send pixels per cell height.+ let (raw, scale) = if vertical.value120 != 0 {+ (f64::from(vertical.value120) / 120.0, 3.0)+ } else if vertical.discrete != 0 {+ (f64::from(vertical.discrete), 3.0)+ } else if cell_h > 0.0 {+ (vertical.absolute / cell_h, 1.0)+ } else {+ continue;+ };+ if raw == 0.0 {+ continue;+ }+ let mult = self.config.mouse.scroll_multiplier.max(0.0);+ let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;+ let up = raw < 0.0;+ // Reporting apps get wheel buttons (64 up / 65 down) as+ // presses, one per line, capped so a flick cannot flood.+ if self.mouse_reporting() {+ let code = if up { 64 } else { 65 };+ for _ in 0..lines.clamp(1, 8) {+ self.try_report_button(code, true);+ }+ continue;+ }+ // On the alternate screen there is no scrollback to move, so+ // (when enabled) translate the wheel into cursor-key presses+ // for apps that did not request mouse reporting.+ let alt = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().alt_active());+ if alt && self.config.mouse.alternate_scroll {+ self.alternate_scroll(up, lines.clamp(1, 8));+ continue;+ }+ // Positive axis = scroll down (toward live); the viewport+ // scrolls the opposite way (negative offset delta).+ let delta = if up { lines } else { -lines };+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(delta);+ self.needs_draw = true;+ }+ }+ _ => {}+ }+ }+ }+}++// Touch drives one-finger drag-to-scroll of the scrollback viewport. Taps and+// multi-finger gestures are ignored; only the first touch point is tracked.+impl TouchHandler for App {+ fn down(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_touch::WlTouch,+ _serial: u32,+ _time: u32,+ _surface: wl_surface::WlSurface,+ id: i32,+ position: (f64, f64),+ ) {+ if self.touch_scroll.is_none() {+ self.touch_scroll = Some(TouchScroll {+ id,+ last_y: position.1,+ acc: 0.0,+ });+ }+ }++ fn up(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_touch::WlTouch,+ _serial: u32,+ _time: u32,+ id: i32,+ ) {+ if self.touch_scroll.as_ref().is_some_and(|t| t.id == id) {+ self.touch_scroll = None;+ }+ }++ fn motion(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_touch::WlTouch,+ _time: u32,+ id: i32,+ position: (f64, f64),+ ) {+ let cell_h = self.renderer.metrics().height as f64;+ let Some(touch) = self.touch_scroll.as_mut().filter(|t| t.id == id) else {+ return;+ };+ touch.acc += position.1 - touch.last_y;+ touch.last_y = position.1;+ // Dragging down (positive delta) reveals older lines, matching the+ // viewport's "positive scrolls back" convention.+ let lines = (touch.acc / cell_h) as isize;+ if lines != 0 {+ touch.acc -= lines as f64 * cell_h;+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(lines);+ self.needs_draw = true;+ }+ }+ }++ fn shape(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_touch::WlTouch,+ _: i32,+ _: f64,+ _: f64,+ ) {+ }++ fn orientation(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_touch::WlTouch,+ _: i32,+ _: f64,+ ) {+ }++ fn cancel(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_touch::WlTouch) {+ self.touch_scroll = None;+ }+}++impl OutputHandler for App {+ fn output_state(&mut self) -> &mut OutputState {+ &mut self.output_state+ }++ fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++ fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++ fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}+}++impl ProvidesRegistryState for App {+ fn registry(&mut self) -> &mut RegistryState {+ &mut self.registry_state+ }+ registry_handlers![OutputState, SeatState];+}++/// Serve the held clipboard text when a paste target requests it.+fn serve(text: &str, fd: WritePipe) {+ let mut file = File::from(OwnedFd::from(fd));+ let _ = file.write_all(text.as_bytes());+}++impl DataDeviceHandler for App {+ fn enter(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &WlDataDevice,+ _: f64,+ _: f64,+ _: &wl_surface::WlSurface,+ ) {+ }+ fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+ fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}+ fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+ fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+}++impl DataOfferHandler for App {+ fn source_actions(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &mut DragOffer,+ _: DndAction,+ ) {+ }+ fn selected_action(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &mut DragOffer,+ _: DndAction,+ ) {+ }+}++impl DataSourceHandler for App {+ fn accept_mime(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &WlDataSource,+ _: Option<String>,+ ) {+ }++ fn send_request(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &WlDataSource,+ _mime: String,+ fd: WritePipe,+ ) {+ if self+ .copy_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ serve(&self.clipboard, fd);+ }+ }++ fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {+ if self+ .copy_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ self.copy_source = None;+ }+ }++ fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+ fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+ fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}+}++impl PrimarySelectionDeviceHandler for App {+ fn selection(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &ZwpPrimarySelectionDeviceV1,+ ) {+ }+}++impl PrimarySelectionSourceHandler for App {+ fn send_request(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &ZwpPrimarySelectionSourceV1,+ _mime: String,+ fd: WritePipe,+ ) {+ if self+ .primary_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ serve(&self.primary_clip, fd);+ }+ }++ fn cancelled(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &ZwpPrimarySelectionSourceV1,+ ) {+ if self+ .primary_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ self.primary_source = None;+ }+ }+}++// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by+// hand. Only the fractional-scale object carries an event we act on.+impl Dispatch<WpFractionalScaleV1, ()> for App {+ fn event(+ state: &mut Self,+ _: &WpFractionalScaleV1,+ event: wp_fractional_scale_v1::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {+ state.set_scale(scale);+ }+ }+}++impl Dispatch<WpFractionalScaleManagerV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpFractionalScaleManagerV1,+ _: <WpFractionalScaleManagerV1 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpViewporter, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpViewporter,+ _: <WpViewporter as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpViewport, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpViewport,+ _: <WpViewport as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++// idle-inhibit and content-type are likewise raw protocol objects; none of them+// emit events we act on.+impl Dispatch<ZwpIdleInhibitManagerV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &ZwpIdleInhibitManagerV1,+ _: <ZwpIdleInhibitManagerV1 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<ZwpIdleInhibitorV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &ZwpIdleInhibitorV1,+ _: zwp_idle_inhibitor_v1::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpContentTypeManagerV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpContentTypeManagerV1,+ _: <WpContentTypeManagerV1 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpContentTypeV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpContentTypeV1,+ _: wp_content_type_v1::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl ActivationHandler for App {+ type RequestData = RequestData;++ fn new_token(&mut self, token: String, _: &RequestData) {+ // The compositor granted an activation token; use it to draw attention.+ if let Some(activation) = self.activation.as_ref() {+ activation.activate::<App>(self.window.wl_surface(), token);+ }+ }+}++impl Dispatch<ZwpTextInputManagerV3, ()> for App {+ fn event(+ _: &mut Self,+ _: &ZwpTextInputManagerV3,+ _: <ZwpTextInputManagerV3 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the+// accumulated transaction on `done` and re-enable on focus enter.+impl Dispatch<ZwpTextInputV3, ()> for App {+ fn event(+ state: &mut Self,+ ti: &ZwpTextInputV3,+ event: zwp_text_input_v3::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ use zwp_text_input_v3::Event;+ match event {+ Event::Enter { .. } => {+ ti.enable();+ ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);+ state.ime_set_cursor_rect(ti);+ ti.commit();+ }+ Event::Leave { .. } => {+ ti.disable();+ ti.commit();+ state.preedit.clear();+ state.ime_preedit_pending.clear();+ state.ime_commit_pending.clear();+ state.needs_draw = true;+ }+ Event::PreeditString { text, .. } => {+ state.ime_preedit_pending = text.unwrap_or_default();+ }+ Event::CommitString { text } => {+ state.ime_commit_pending.push_str(&text.unwrap_or_default());+ }+ Event::Done { .. } => state.ime_done(ti),+ // We do not expose surrounding text, so nothing to delete.+ Event::DeleteSurroundingText { .. } => {}+ _ => {}+ }+ }+}++delegate_compositor!(App);+delegate_output!(App);+delegate_shm!(App);+delegate_seat!(App);+delegate_keyboard!(App);+delegate_pointer!(App);+delegate_touch!(App);+delegate_xdg_shell!(App);+delegate_xdg_window!(App);+delegate_data_device!(App);+delegate_primary_selection!(App);+delegate_registry!(App);+delegate_activation!(App);@@ -0,0 +1,1893 @@+//! Wayland front-end: connection, surface, and software-drawn window.+//!+//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the+//! event loop, so the PTY master fd and timers share one loop.++mod handlers;+mod rendering;++use std::fs::File;+use std::io::{Read as _, Write as _};+use std::num::NonZeroU16;+use std::os::fd::OwnedFd;+use std::os::unix::process::ExitStatusExt;+use std::process::ExitCode;++use std::time::Duration;++use anyhow::Context;+use calloop::generic::Generic;+use calloop::timer::{TimeoutAction, Timer};+use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken};+use calloop_wayland_source::WaylandSource;++use crate::config::Config;+use crate::font::Fonts;+use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit};+use crate::pty::Pty;+use crate::render::Renderer;+use crate::vt::Term;+use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{+ Shape, WpCursorShapeDeviceV1,+};+use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{+ zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,+ zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,+};+use smithay_client_toolkit::{+ activation::{ActivationHandler, ActivationState, RequestData},+ compositor::{CompositorHandler, CompositorState},+ data_device_manager::{+ DataDeviceManagerState, WritePipe,+ data_device::{DataDevice, DataDeviceHandler},+ data_offer::{DataOfferHandler, DragOffer},+ data_source::{CopyPasteSource, DataSourceHandler},+ },+ delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard,+ delegate_output,+ delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,+ delegate_touch, delegate_xdg_shell, delegate_xdg_window,+ output::{OutputHandler, OutputState},+ primary_selection::{+ PrimarySelectionManagerState,+ device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},+ selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},+ },+ registry::{ProvidesRegistryState, RegistryState},+ registry_handlers,+ seat::{+ Capability, SeatHandler, SeatState,+ keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},+ pointer::{+ BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,+ cursor_shape::CursorShapeManager,+ },+ touch::TouchHandler,+ },+ shell::{+ WaylandSurface,+ xdg::{+ XdgShell,+ window::{Window, WindowConfigure, WindowDecorations, WindowHandler},+ },+ },+ shm::{+ Shm, ShmHandler,+ slot::{Buffer, SlotPool},+ },+};+use wayland_client::{+ Connection, Dispatch, Proxy, QueueHandle,+ globals::{GlobalList, registry_queue_init},+ protocol::{+ wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,+ wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,+ wl_surface, wl_touch,+ },+};+use wayland_protocols::wp::content_type::v1::client::{+ wp_content_type_manager_v1::WpContentTypeManagerV1,+ wp_content_type_v1::{self, WpContentTypeV1},+};+use wayland_protocols::wp::fractional_scale::v1::client::{+ wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,+ wp_fractional_scale_v1::{self, WpFractionalScaleV1},+};+use wayland_protocols::wp::idle_inhibit::zv1::client::{+ zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,+ zwp_idle_inhibitor_v1::{self, ZwpIdleInhibitorV1},+};+use wayland_protocols::wp::text_input::zv3::client::{+ zwp_text_input_manager_v3::ZwpTextInputManagerV3,+ zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3},+};+use wayland_protocols::wp::viewporter::client::{+ wp_viewport::WpViewport, wp_viewporter::WpViewporter,+};++use handlers::{cursor_shape_from, hint_labels};+use rendering::FrameBuf;++/// MIME types beer offers and accepts for clipboard text.+const TEXT_MIMES: &[&str] = &[+ "text/plain;charset=utf-8",+ "text/plain;charset=UTF-8",+ "UTF8_STRING",+ "STRING",+ "text/plain",+ "TEXT",+];++/// Pick the first MIME type we understand from an offer's advertised set.+fn pick_mime(mimes: &[String]) -> Option<String> {+ mimes+ .iter()+ .find(|m| TEXT_MIMES.contains(&m.as_str()))+ .cloned()+}++/// Max gap between clicks counted as a multi-click (ms).+const MULTI_CLICK_MS: u32 = 400;++/// Blink half-period: cells/cursor toggle visibility this often.+const BLINK_MS: u64 = 500;++/// Buffers kept for double/triple buffering before we wait for a release.+const MAX_BUFFERS: usize = 3;++/// How long synchronized output (DECSET 2026) may hold the screen before we+/// present anyway, so a misbehaving app cannot freeze the window.+const SYNC_TIMEOUT_MS: u64 = 150;++/// Interval between autoscroll steps while a drag selection runs off an edge.+const AUTOSCROLL_MS: u64 = 40;++/// How long the visual bell inverts the screen.+const FLASH_MS: u64 = 80;++/// Fallback window size in pixels if the configured geometry yields nothing.+const DEFAULT_W: u32 = 800;+const DEFAULT_H: u32 = 600;++/// Run a single window until it is closed, returning the shell's exit code.+pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {+ let conn = Connection::connect_to_env().context("connect to Wayland compositor")?;+ let (globals, event_queue) =+ registry_queue_init(&conn).context("initialize Wayland registry")?;+ let qh = event_queue.handle();++ let mut event_loop: EventLoop<App> =+ EventLoop::try_new().context("create calloop event loop")?;+ WaylandSource::new(conn, event_queue)+ .insert(event_loop.handle())+ .map_err(|e| anyhow::anyhow!("insert Wayland source into event loop: {e}"))?;++ let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;+ let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;+ let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;+ let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)+ .context("wl_data_device_manager not available")?;+ let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();+ let cursor_shape_manager = CursorShapeManager::bind(&globals, &qh).ok();++ let surface = compositor.create_surface(&qh);+ let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);+ window.set_title("beer");+ window.set_app_id("dev.notashelf.beer");+ window.set_min_size(Some((1, 1)));++ // Decorrelate buffer pixels from surface size so we can render at the+ // compositor's preferred fractional scale (crisp glyphs on a 150% output)+ // and present at the logical size. Both are optional; without them the+ // window falls back to integer buffer scaling via `scale_factor_changed`.+ let viewport = bind_global::<WpViewporter>(&globals, &qh)+ .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ()));+ // Fractional scaling needs a viewport to present the scaled buffer back at+ // the logical size; without one we can only do integer buffer scaling.+ let fractional_scale = viewport.as_ref().and_then(|_| {+ bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)+ .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))+ });+ let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &qh);+ let activation = ActivationState::bind(&globals, &qh).ok();+ let idle_inhibit_manager = bind_global::<ZwpIdleInhibitManagerV1>(&globals, &qh);+ // Tag the surface as plain content (a terminal is none of photo/video/game)+ // so the compositor applies no media-specific treatment. Applies on commit.+ let content_type = bind_global::<WpContentTypeManagerV1>(&globals, &qh)+ .map(|mgr| mgr.get_surface_content_type(window.wl_surface(), &qh, ()));+ if let Some(ct) = &content_type {+ ct.set_content_type(wp_content_type_v1::Type::None);+ }++ // First commit with no buffer kicks off the initial configure.+ window.commit();++ let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?;+ let mut renderer = Renderer::new(fonts);+ renderer.set_padding(config.main.pad_x, config.main.pad_y);++ // Start at the configured cell geometry plus padding; the compositor may+ // override it on the first configure.+ let m = renderer.metrics();+ let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1);+ let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);+ let pool = SlotPool::new(+ (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,+ &shm,+ )+ .context("create shm slot pool")?;++ let bindings = crate::bindings::Bindings::from_config(+ &config.key_bindings,+ &config.text_bindings,+ &config.mouse_bindings,+ );+ let font_size = config.main.font_size;++ let mut app = App {+ registry_state: RegistryState::new(&globals),+ output_state: OutputState::new(&globals, &qh),+ seat_state: SeatState::new(&globals, &qh),+ shm,+ pool,+ window,+ renderer,+ loop_handle: event_loop.handle(),+ qh: qh.clone(),+ data_device_manager,+ primary_manager,+ cursor_shape_manager,+ text_input_manager,+ activation,+ idle_inhibit_manager,+ idle_inhibitor: None,+ content_type,+ preedit: String::new(),+ ime_preedit_pending: String::new(),+ ime_commit_pending: String::new(),+ viewport,+ fractional_scale,+ scale120: 120,+ seats: Vec::new(),+ active_seat: 0,+ copy_source: None,+ primary_source: None,+ clipboard: String::new(),+ primary_clip: String::new(),+ selecting: false,+ hovered_link: None,+ pointer_enter_serial: 0,+ press_cell: None,+ pressed_button: None,+ last_report_cell: None,+ autoscroll: 0,+ autoscroll_timer: None,+ pointer_pos: (0.0, 0.0),+ last_click: None,+ serial: 0,+ modifiers: Modifiers::default(),+ // The PTY is spawned on the first configure, once the real window size+ // is known, so the shell starts at the final size and is not hit by a+ // startup SIGWINCH storm that makes it reprint its prompt.+ session: None,+ title: None,+ config,+ config_path,+ bindings,+ font_size,+ fullscreen: false,+ width,+ height,+ needs_draw: false,+ frame_pending: false,+ frames: Vec::new(),+ buf_dims: (0, 0),+ blink_on: true,+ sync_timeout: None,+ flashing: false,+ flash_timer: None,+ searching: false,+ url_mode: false,+ url_hits: Vec::new(),+ url_labels: Vec::new(),+ url_input: String::new(),+ unicode_input: None,+ keys_down: std::collections::HashSet::new(),+ touch_scroll: None,+ focused: true,+ exit: false,+ exit_code: ExitCode::SUCCESS,+ };++ // Toggle the blink phase on a timer so blinking text and cursors animate.+ let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));+ let blink_registered = event_loop+ .handle()+ .insert_source(blink, |_, _, app: &mut App| {+ app.blink_on = !app.blink_on;+ app.needs_draw = true;+ TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))+ });+ if let Err(err) = blink_registered {+ tracing::warn!("register blink timer: {err}");+ }++ // SIGUSR1 reloads the config in place.+ match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {+ Ok(signals) => {+ let registered = event_loop+ .handle()+ .insert_source(signals, |_, _, app: &mut App| app.reload_config());+ if let Err(err) = registered {+ tracing::warn!("register signal source: {err}");+ }+ }+ Err(err) => tracing::warn!("install SIGUSR1 handler: {err}"),+ }++ // Each iteration blocks until an event (PTY output, input, configure, frame+ // callback, blink) arrives, then presents at most one frame; bursts of PTY+ // output between frame callbacks coalesce into a single repaint.+ while !app.exit {+ event_loop+ .dispatch(None, &mut app)+ .context("dispatch event loop")?;+ app.flush();+ }+ Ok(app.exit_code)+}++/// Bind a singleton global at version 1 with `()` user-data, or `None` if the+/// compositor does not advertise it.+fn bind_global<I>(globals: &GlobalList, qh: &QueueHandle<App>) -> Option<I>+where+ I: Proxy + 'static,+ App: Dispatch<I, ()>,+{+ match globals.bind(qh, 1..=1, ()) {+ Ok(global) => Some(global),+ Err(err) => {+ tracing::debug!("bind {}: {err}", I::interface().name);+ None+ }+ }+}++/// Columns and rows that fit a `width`×`height` px window at `metrics`, after+/// reserving `2 * pad` pixels of inner padding on each axis.+fn grid_size(+ metrics: crate::font::CellMetrics,+ width: u32,+ height: u32,+ pad: (u32, u32),+) -> (u16, u16) {+ let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1);+ let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1);+ (cols as u16, rows as u16)+}++/// Write every byte to `fd`, retrying short writes and interrupts.+fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {+ while !buf.is_empty() {+ match rustix::io::write(fd, buf) {+ Ok(0) => return Err(rustix::io::Errno::IO),+ Ok(n) => buf = &buf[n..],+ Err(rustix::io::Errno::INTR) => {}+ Err(e) => return Err(e),+ }+ }+ Ok(())+}++/// The per-window terminal: the PTY and the parsed screen behind it. Created on+/// the first configure, once the real size is known.+#[derive(Debug)]+struct Session {+ pty: Pty,+ term: Term,+}++/// Input devices for one seat. Several seats can drive the single window; the+/// most recently used one owns clipboard/primary claims.+#[derive(Debug)]+struct SeatData {+ seat: wl_seat::WlSeat,+ keyboard: Option<wl_keyboard::WlKeyboard>,+ pointer: Option<wl_pointer::WlPointer>,+ cursor_shape_device: Option<WpCursorShapeDeviceV1>,+ data_device: Option<DataDevice>,+ primary_device: Option<PrimarySelectionDevice>,+ /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.+ text_input: Option<ZwpTextInputV3>,+ touch: Option<wl_touch::WlTouch>,+}++/// A single-finger touch drag in progress, used to scroll the viewport.+#[derive(Debug)]+struct TouchScroll {+ /// Touch point id we are tracking (the first finger down).+ id: i32,+ /// Surface-local y of the last motion, to take per-event deltas.+ last_y: f64,+ /// Sub-cell pixel remainder carried between motions.+ acc: f64,+}++/// Window + Wayland client state shared across all protocol handlers.+#[derive(Debug)]+struct App {+ registry_state: RegistryState,+ output_state: OutputState,+ seat_state: SeatState,+ shm: Shm,+ pool: SlotPool,+ window: Window,+ renderer: Renderer,+ loop_handle: LoopHandle<'static, App>,+ qh: QueueHandle<App>,+ data_device_manager: DataDeviceManagerState,+ primary_manager: Option<PrimarySelectionManagerState>,+ /// Sets the pointer to an I-beam over the window (cursor-shape-v1).+ cursor_shape_manager: Option<CursorShapeManager>,+ /// IME manager (text-input-v3); per-seat handles live in `seats`.+ text_input_manager: Option<ZwpTextInputManagerV3>,+ /// xdg-activation, used to request attention on an urgent bell.+ activation: Option<ActivationState>,+ /// idle-inhibit-v1 manager; an inhibitor is held while focused when the+ /// `[main] idle-inhibit` config is on, so the screen does not blank.+ idle_inhibit_manager: Option<ZwpIdleInhibitManagerV1>,+ idle_inhibitor: Option<ZwpIdleInhibitorV1>,+ /// content-type-v1 hint object. Set once at startup; held only so the+ /// object (and thus the hint) outlives construction.+ #[allow(+ dead_code,+ reason = "kept alive to preserve the surface content-type hint"+ )]+ content_type: Option<WpContentTypeV1>,+ /// Committed IME preedit string shown inline at the cursor while composing.+ preedit: String,+ /// Preedit/commit accumulated since the last text-input `done`.+ ime_preedit_pending: String,+ ime_commit_pending: String,+ /// Presents a scaled buffer at the logical surface size (viewporter).+ viewport: Option<WpViewport>,+ /// Per-surface fractional-scale object; kept alive to receive scale events.+ fractional_scale: Option<WpFractionalScaleV1>,+ /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5).+ scale120: u32,+ /// One entry per seat; `active_seat` indexes the most recently used.+ seats: Vec<SeatData>,+ active_seat: usize,+ /// Held while we own the clipboard / primary selection, serving paste reads.+ copy_source: Option<CopyPasteSource>,+ primary_source: Option<PrimarySelectionSource>,+ clipboard: String,+ primary_clip: String,+ /// A left-button drag is in progress.+ selecting: bool,+ /// OSC 8 hyperlink under the pointer, underlined and opened on click.+ hovered_link: Option<NonZeroU16>,+ /// Serial of the last pointer enter, reused to update the cursor shape.+ pointer_enter_serial: u32,+ /// Cell `(abs_row, col)` of the last left-press, for click-to-open links.+ press_cell: Option<(usize, usize)>,+ /// Button base code held down while mouse reporting, for drag reports.+ pressed_button: Option<u8>,+ /// Last cell a motion report was emitted for, to suppress duplicates.+ last_report_cell: Option<(usize, usize)>,+ /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live.+ autoscroll: isize,+ /// Calloop token for the repeating autoscroll timer, when armed.+ autoscroll_timer: Option<RegistrationToken>,+ pointer_pos: (f64, f64),+ /// Last click (time ms, abs row, col, count) for double/triple detection.+ last_click: Option<(u32, usize, usize, u32)>,+ /// Most recent input serial, used to claim selections.+ serial: u32,+ modifiers: Modifiers,+ /// `None` until the first configure spawns the shell.+ session: Option<Session>,+ /// Last title applied to the toplevel, to avoid redundant requests.+ title: Option<String>,+ /// The active user configuration.+ config: Config,+ /// Path the config was loaded from, for SIGUSR1 live reload.+ config_path: Option<std::path::PathBuf>,+ /// Resolved key/text bindings.+ bindings: crate::bindings::Bindings,+ /// Current font size in pixels (changed by font-resize bindings).+ font_size: u32,+ /// Whether the toplevel is fullscreen.+ fullscreen: bool,+ width: u32,+ height: u32,+ /// The grid changed and the window wants repainting on the next frame.+ needs_draw: bool,+ /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.+ frame_pending: bool,+ /// Double/triple-buffer ring, each tagged with the rows it currently shows.+ frames: Vec<FrameBuf>,+ /// Pixel size the `frames` buffers were allocated for.+ buf_dims: (u32, u32),+ /// Current blink phase, toggled by a timer; off hides blinking ink.+ blink_on: bool,+ /// Armed while synchronized output holds the screen, to force it open.+ sync_timeout: Option<RegistrationToken>,+ /// The visual bell is inverting the screen.+ flashing: bool,+ /// Timer that ends the visual-bell flash.+ flash_timer: Option<RegistrationToken>,+ /// Whether incremental search mode is active (the query lives in the grid).+ searching: bool,+ /// URL hint mode: detected URLs get keyboard labels to open them.+ url_mode: bool,+ /// Detected URLs and their hint labels (parallel), while `url_mode` is on.+ url_hits: Vec<UrlHit>,+ url_labels: Vec<String>,+ /// Label characters typed so far in URL mode.+ url_input: String,+ /// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.+ unicode_input: Option<String>,+ /// Raw key codes currently held, to tell press from repeat for the kitty+ /// keyboard protocol's event-type reporting.+ keys_down: std::collections::HashSet<u32>,+ /// A single-finger touch drag in progress (scrolls the viewport).+ touch_scroll: Option<TouchScroll>,+ /// Whether the toplevel currently has keyboard focus (drives the cursor).+ focused: bool,+ exit: bool,+ /// Exit code to return, taken from the shell when it exits.+ exit_code: ExitCode,+}++impl App {+ /// Spawn the shell at the current window size and start reading its output.+ fn spawn_session(&mut self) {+ let (cols, rows) = self.grid_dims();+ let pty = match Pty::spawn(cols, rows, &self.config.main.term) {+ Ok(pty) => pty,+ Err(err) => {+ tracing::error!("spawn shell: {err:#}");+ self.exit = true;+ return;+ }+ };+ let read_fd = match pty.master().try_clone() {+ Ok(fd) => fd,+ Err(err) => {+ tracing::error!("clone pty master: {err}");+ self.exit = true;+ return;+ }+ };++ let mut parser = vte::Parser::new();+ let source = Generic::new(read_fd, Interest::READ, Mode::Level);+ let registered = self+ .loop_handle+ .insert_source(source, move |_, fd, app: &mut App| {+ let mut buf = [0u8; 4096];+ let n = match rustix::io::read(&*fd, &mut buf) {+ Ok(0) => {+ app.child_exited();+ return Ok(PostAction::Remove);+ }+ Ok(n) => n,+ Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => {+ return Ok(PostAction::Continue);+ }+ Err(_) => {+ app.child_exited();+ return Ok(PostAction::Remove);+ }+ };+ if let Some(session) = app.session.as_mut() {+ parser.advance(&mut session.term, &buf[..n]);+ }+ app.after_feed();+ Ok(PostAction::Continue)+ });+ if let Err(err) = registered {+ tracing::error!("register pty in event loop: {err}");+ self.exit = true;+ return;+ }++ let mut term = Term::new(cols as usize, rows as usize);+ term.set_theme(crate::theme::Theme::from_config(&self.config.colors));+ let grid = term.grid_mut();+ grid.set_word_delimiters(self.config.main.word_delimiters.clone());+ grid.set_scrollback_cap(self.config.scrollback.lines);+ if let Some(shape) = cursor_shape_from(self.config.cursor.style.as_deref()) {+ grid.set_cursor_shape(shape);+ }+ grid.set_cursor_blink(self.config.cursor.blink);+ self.session = Some(Session { pty, term });+ }++ /// Handle a key (initial press or repeat): configured bindings first, then+ /// text bindings, else the byte encoding sent to the shell (which snaps the+ /// viewport back to the live screen).+ fn handle_key(&mut self, event: &KeyEvent) {+ // A new arrival of a held key is a repeat; otherwise a fresh press.+ let kind = if self.keys_down.insert(event.raw_code) {+ beer_protocols::key::KeyKind::Press+ } else {+ beer_protocols::key::KeyKind::Repeat+ };++ // The Unicode-input prompt, URL hint mode, and search each capture the+ // keyboard while active.+ if self.unicode_input.is_some() {+ self.unicode_key(event);+ return;+ }+ if self.url_mode {+ self.url_key(event);+ return;+ }+ if self.searching {+ self.search_key(event);+ return;+ }+ if let Some(action) = self.bindings.action(event, self.modifiers) {+ self.dispatch_action(action);+ return;+ }+ if let Some(text) = self.bindings.text(event, self.modifiers) {+ let bytes = text.to_vec();+ self.send_to_shell(&bytes);+ return;+ }++ let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {+ (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+ });+ let bytes = if kitty != 0 {+ beer_protocols::key::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)+ } else {+ beer_protocols::key::encode(event, self.modifiers, app_cursor)+ };+ if let Some(bytes) = bytes {+ self.send_to_shell(&bytes);+ }+ }++ /// Handle a key release: only the kitty keyboard protocol cares, and only+ /// when it has asked for event reporting.+ fn handle_key_release(&mut self, event: &KeyEvent) {+ self.keys_down.remove(&event.raw_code);+ let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {+ (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+ });+ if kitty == 0 {+ return;+ }+ if let Some(bytes) = beer_protocols::key::kitty_encode(+ event,+ self.modifiers,+ kitty,+ beer_protocols::key::KeyKind::Release,+ app_cursor,+ ) {+ self.send_to_shell(&bytes);+ }+ }++ /// Write key/text bytes to the shell, snapping the viewport to the live+ /// screen and clearing any selection first.+ fn send_to_shell(&mut self, bytes: &[u8]) {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_to_bottom();+ session.term.grid_mut().clear_selection();+ self.needs_draw = true;+ if let Err(err) = write_all(session.pty.master(), bytes) {+ tracing::warn!("write key to pty: {err}");+ }+ }+ }++ /// Run a bound editor action.+ fn dispatch_action(&mut self, action: crate::bindings::Action) {+ use crate::bindings::Action;+ match action {+ Action::Copy => {+ let qh = self.qh.clone();+ self.set_clipboard(&qh);+ }+ Action::Paste => self.paste_clipboard(),+ Action::PastePrimary => self.paste_primary(),+ Action::ScrollPageUp => self.scroll_page(true),+ Action::ScrollPageDown => self.scroll_page(false),+ Action::ScrollTop => {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(isize::MAX);+ self.needs_draw = true;+ }+ }+ Action::ScrollBottom => {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_to_bottom();+ self.needs_draw = true;+ }+ }+ Action::SearchStart => self.toggle_search(),+ Action::FontIncrease => self.change_font_size(self.font_size + 1),+ Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),+ Action::FontReset => self.change_font_size(self.config.main.font_size),+ Action::Fullscreen => self.toggle_fullscreen(),+ Action::NewWindow => self.spawn_new_window(),+ Action::JumpPromptUp => self.jump_prompt(true),+ Action::JumpPromptDown => self.jump_prompt(false),+ Action::PipeCommandOutput => self.pipe_command_output(),+ Action::UrlMode => self.enter_url_mode(),+ Action::UnicodeInput => {+ self.unicode_input = Some(String::new());+ self.needs_draw = true;+ }+ }+ }++ /// Handle a key while Unicode codepoint-input mode is active: accumulate hex+ /// digits, then commit the codepoint as UTF-8 on Enter/Space.+ fn unicode_key(&mut self, event: &KeyEvent) {+ match event.keysym {+ Keysym::Escape => {+ self.unicode_input = None;+ self.needs_draw = true;+ }+ Keysym::BackSpace => {+ if let Some(buf) = self.unicode_input.as_mut() {+ buf.pop();+ }+ self.needs_draw = true;+ }+ Keysym::Return | Keysym::KP_Enter | Keysym::space => {+ let buf = self.unicode_input.take().unwrap_or_default();+ if let Some(c) = u32::from_str_radix(buf.trim(), 16)+ .ok()+ .and_then(char::from_u32)+ {+ let mut bytes = [0u8; 4];+ let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();+ self.send_to_shell(&s);+ }+ self.needs_draw = true;+ }+ _ => {+ if let Some(text) = event.utf8.as_ref() {+ let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();+ // Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.+ if let Some(buf) = self.unicode_input.as_mut()+ && buf.len() + hex.len() <= 6+ {+ buf.push_str(&hex);+ }+ self.needs_draw = true;+ }+ }+ }+ }++ /// Enter URL hint mode: detect the visible URLs and label them. No-op (with+ /// a brief log) when there are none.+ fn enter_url_mode(&mut self) {+ let Some(session) = self.session.as_ref() else {+ return;+ };+ let hits = session.term.grid().visible_urls();+ if hits.is_empty() {+ return;+ }+ self.url_labels = hint_labels(hits.len());+ self.url_hits = hits;+ self.url_input = String::new();+ self.url_mode = true;+ self.needs_draw = true;+ }++ /// Leave URL hint mode, discarding any partial label input.+ fn exit_url_mode(&mut self) {+ self.url_mode = false;+ self.url_hits.clear();+ self.url_labels.clear();+ self.url_input.clear();+ // Drop the labelled buffers so the next present repaints without labels.+ self.frames.clear();+ self.needs_draw = true;+ }++ /// Handle a key while URL hint mode is active: build up a label, open the+ /// matching URL, or cancel.+ fn url_key(&mut self, event: &KeyEvent) {+ match event.keysym {+ Keysym::Escape => self.exit_url_mode(),+ Keysym::BackSpace => {+ self.url_input.pop();+ self.needs_draw = true;+ }+ _ => {+ let Some(text) = event.utf8.as_ref() else {+ return;+ };+ for c in text.chars().filter(|c| c.is_ascii_alphabetic()) {+ self.url_input.push(c.to_ascii_lowercase());+ }+ // Exact match opens; if no label even has this prefix, cancel.+ if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) {+ let url = self.url_hits[i].url.clone();+ self.exit_url_mode();+ self.open_url(&url);+ } else if !self+ .url_labels+ .iter()+ .any(|l| l.starts_with(&self.url_input))+ {+ self.exit_url_mode();+ } else {+ self.needs_draw = true;+ }+ }+ }+ }++ /// Scroll the viewport to the previous/next shell prompt (OSC 133).+ fn jump_prompt(&mut self, up: bool) {+ if let Some(session) = self.session.as_mut() {+ session.term.grid_mut().jump_prompt(up);+ self.needs_draw = true;+ }+ }++ /// Feed the last command's output (between OSC 133 C and D) to the configured+ /// command on stdin.+ fn pipe_command_output(&mut self) {+ let argv = &self.config.shell_integration.pipe_command;+ let Some((program, args)) = argv.split_first() else {+ tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured");+ return;+ };+ let Some(text) = self+ .session+ .as_ref()+ .and_then(|s| s.term.grid().last_command_output())+ else {+ return;+ };+ let mut cmd = std::process::Command::new(program);+ cmd.args(args)+ .stdin(std::process::Stdio::piped())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {+ cmd.current_dir(cwd);+ }+ match cmd.spawn() {+ Ok(mut child) => {+ if let Some(mut stdin) = child.stdin.take() {+ let _ = stdin.write_all(text.as_bytes());+ }+ }+ Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),+ }+ }++ /// Launch another beer process in the shell's reported working directory+ /// (OSC 7), inheriting the same config. The child is fully detached.+ fn spawn_new_window(&mut self) {+ let exe = match std::env::current_exe() {+ Ok(exe) => exe,+ Err(err) => {+ tracing::warn!("locate beer executable: {err}");+ return;+ }+ };+ let mut cmd = std::process::Command::new(exe);+ if let Some(path) = self.config_path.as_ref() {+ cmd.arg("--config").arg(path);+ }+ if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {+ cmd.current_dir(cwd);+ }+ cmd.stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Err(err) = cmd.spawn() {+ tracing::warn!("spawn new window: {err}");+ }+ }++ /// Send `count` cursor-up/down keys to the shell for alternate-scroll,+ /// honouring the application cursor-key mode (DECCKM).+ fn alternate_scroll(&mut self, up: bool, count: isize) {+ let app_cursor = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().app_cursor());+ let seq: &[u8] = match (up, app_cursor) {+ (true, false) => b"\x1b[A",+ (true, true) => b"\x1bOA",+ (false, false) => b"\x1b[B",+ (false, true) => b"\x1bOB",+ };+ for _ in 0..count {+ self.write_to_pty(seq);+ }+ }++ /// Scroll the viewport one page back (`up`) or toward the live screen.+ fn scroll_page(&mut self, up: bool) {+ if let Some(session) = self.session.as_mut() {+ let page = session.term.page() as isize;+ session.term.scroll_view(if up { page } else { -page });+ self.needs_draw = true;+ }+ }++ /// Toggle the toplevel between fullscreen and windowed.+ fn toggle_fullscreen(&mut self) {+ self.fullscreen = !self.fullscreen;+ if self.fullscreen {+ self.window.set_fullscreen(None);+ } else {+ self.window.unset_fullscreen();+ }+ }++ /// Re-read the config file and apply it in place (SIGUSR1).+ fn reload_config(&mut self) {+ let new = Config::load(self.config_path.as_deref());+ self.bindings = crate::bindings::Bindings::from_config(+ &new.key_bindings,+ &new.text_bindings,+ &new.mouse_bindings,+ );+ let font_changed = new.main.font != self.config.main.font+ || new.main.font_size != self.config.main.font_size;+ if font_changed {+ self.font_size = new.main.font_size;+ }+ self.config.main.font = new.main.font.clone();+ self.config.main.font_size = new.main.font_size;+ self.config.main.pad_x = new.main.pad_x;+ self.config.main.pad_y = new.main.pad_y;+ // Re-rasterize at the active scale and update padding in one place.+ self.rescale_render();+ if let Some(session) = self.session.as_mut() {+ session+ .term+ .set_theme(crate::theme::Theme::from_config(&new.colors));+ let grid = session.term.grid_mut();+ grid.set_word_delimiters(new.main.word_delimiters.clone());+ grid.set_scrollback_cap(new.scrollback.lines);+ if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {+ grid.set_cursor_shape(shape);+ }+ grid.set_cursor_blink(new.cursor.blink);+ }+ self.config = new;+ self.frames.clear();+ self.resize_grid();+ self.needs_draw = true;+ tracing::info!("config reloaded");+ }++ /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.+ fn change_font_size(&mut self, new_size: u32) {+ let new_size = new_size.clamp(6, 200);+ if new_size == self.font_size {+ return;+ }+ self.font_size = new_size;+ self.rescale_render();+ self.frames.clear();+ self.resize_grid();+ self.needs_draw = true;+ }++ /// Enter or leave incremental search mode.+ fn toggle_search(&mut self) {+ self.searching = !self.searching;+ if let Some(session) = self.session.as_mut() {+ if self.searching {+ session.term.grid_mut().set_search("");+ } else {+ session.term.grid_mut().clear_search();+ }+ }+ self.needs_draw = true;+ }++ /// Handle a key while search mode is active: edit the query incrementally,+ /// step between matches, or exit.+ fn search_key(&mut self, event: &KeyEvent) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let grid = session.term.grid_mut();+ match event.keysym {+ Keysym::Escape => {+ grid.clear_search();+ self.searching = false;+ }+ Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {+ grid.search_step(false);+ }+ Keysym::Down | Keysym::Page_Down => grid.search_step(true),+ Keysym::BackSpace => {+ let mut query = grid.search_query().unwrap_or("").to_string();+ query.pop();+ grid.set_search(&query);+ }+ _ => {+ // Append typed text, ignoring control characters.+ if let Some(text) = event.utf8.as_ref() {+ let printable: String = text.chars().filter(|c| !c.is_control()).collect();+ if !printable.is_empty() {+ let mut query = grid.search_query().unwrap_or("").to_string();+ query.push_str(&printable);+ grid.set_search(&query);+ }+ }+ }+ }+ self.needs_draw = true;+ }++ /// The OSC 8 hyperlink id under the pointer, if any.+ fn link_under_pointer(&self) -> Option<NonZeroU16> {+ let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;+ self.session.as_ref()?.term.grid().link_at(row, col)+ }++ /// Recompute the hyperlink under the pointer; when it changes, repaint to+ /// move the hover underline and update the pointer to a hand over a link.+ fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {+ let link = self.link_under_pointer();+ if link == self.hovered_link {+ return;+ }+ self.hovered_link = link;+ // The hover underline lives in every buffer's snapshot; drop the ring so+ // the affected rows repaint with (or without) it.+ self.frames.clear();+ self.needs_draw = true;+ let shape = if link.is_some() {+ Shape::Pointer+ } else {+ Shape::Text+ };+ if let Some(device) = self+ .seats+ .iter()+ .find(|s| s.pointer.as_ref() == Some(pointer))+ .and_then(|s| s.cursor_shape_device.as_ref())+ {+ device.set_shape(self.pointer_enter_serial, shape);+ }+ }++ /// If the left button was pressed and released on the same hyperlinked cell+ /// (a click, not a drag), open the link.+ fn maybe_open_clicked_link(&mut self) {+ let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+ let Some((row, col)) = release else { return };+ if self.press_cell != Some((row, col)) {+ return;+ }+ let uri = self+ .session+ .as_ref()+ .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))+ .and_then(|(s, id)| s.term.grid().link_uri(id))+ .map(str::to_owned);+ if let Some(uri) = uri {+ self.open_url(&uri);+ }+ }++ /// Launch the configured opener (default `xdg-open`) on a URL.+ fn open_url(&self, url: &str) {+ let Some((program, args)) = self.config.url.launch.split_first() else {+ tracing::warn!("open url: no [url] launch command configured");+ return;+ };+ let mut cmd = std::process::Command::new(program);+ cmd.args(args)+ .arg(url)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Err(err) = cmd.spawn() {+ tracing::warn!("open url {url:?}: {err}");+ }+ }++ /// Scale a logical pixel length to physical (buffer) pixels at the current+ /// fractional scale, rounding to nearest.+ fn to_phys(&self, v: u32) -> u32 {+ ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32+ }++ /// Physical (buffer) pixel size at the current scale.+ fn phys_dims(&self) -> (u32, u32) {+ (+ self.to_phys(self.width).max(1),+ self.to_phys(self.height).max(1),+ )+ }++ /// Columns and rows for the current physical size, metrics, and padding.+ /// Scale-invariant: every term scales together so the cell count is stable.+ fn grid_dims(&self) -> (u16, u16) {+ let (pw, ph) = self.phys_dims();+ grid_size(+ self.renderer.metrics(),+ pw,+ ph,+ (+ self.to_phys(self.config.main.pad_x),+ self.to_phys(self.config.main.pad_y),+ ),+ )+ }++ /// Re-rasterize the font and padding at the current scale × the logical+ /// font size, so glyphs are crisp at fractional scales.+ fn rescale_render(&mut self) {+ self.renderer.set_padding(+ self.to_phys(self.config.main.pad_x),+ self.to_phys(self.config.main.pad_y),+ );+ let px = self.to_phys(self.font_size).max(1);+ if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {+ tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);+ }+ }++ /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid+ /// geometry, and update the viewport so the logical size stays put.+ fn set_scale(&mut self, scale120: u32) {+ let scale120 = scale120.max(1);+ if scale120 == self.scale120 {+ return;+ }+ self.scale120 = scale120;+ self.rescale_render();+ self.frames.clear();+ self.buf_dims = (0, 0);+ if let Some(vp) = &self.viewport {+ vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+ }+ self.resize_grid();+ self.needs_draw = true;+ }++ /// Inner padding `(x, y)` in physical pixels (pointer coords are converted+ /// to physical before use).+ fn padding(&self) -> (f64, f64) {+ (+ f64::from(self.to_phys(self.config.main.pad_x)),+ f64::from(self.to_phys(self.config.main.pad_y)),+ )+ }++ /// Convert a logical surface coordinate to a physical buffer coordinate.+ fn to_phys_f(&self, v: f64) -> f64 {+ v * f64::from(self.scale120) / 120.0+ }++ /// The active seat (the one that most recently produced input).+ fn active(&self) -> Option<&SeatData> {+ self.seats.get(self.active_seat)+ }++ /// Attach the seat-scoped clipboard and primary-selection devices to seat+ /// `i` if not already present. Idempotent, so it can run from either+ /// `new_seat` or `new_capability` - some compositors surface a pre-existing+ /// seat's capabilities without ever firing `new_seat`.+ fn ensure_clipboard_devices(+ &mut self,+ qh: &QueueHandle<Self>,+ seat: &wl_seat::WlSeat,+ i: usize,+ ) {+ if self.seats[i].data_device.is_none() {+ let dd = self.data_device_manager.get_data_device(qh, seat);+ self.seats[i].data_device = Some(dd);+ }+ if self.seats[i].primary_device.is_none()+ && let Some(m) = self.primary_manager.as_ref()+ {+ let pd = m.get_selection_device(qh, seat);+ self.seats[i].primary_device = Some(pd);+ }+ }++ /// The active seat's clipboard device, if any.+ fn data_device(&self) -> Option<&DataDevice> {+ self.active().and_then(|s| s.data_device.as_ref())+ }++ /// The active seat's primary-selection device, if any.+ fn primary_device(&self) -> Option<&PrimarySelectionDevice> {+ self.active().and_then(|s| s.primary_device.as_ref())+ }++ /// Find (or create) the per-seat entry for `seat`, returning its index.+ fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {+ if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {+ return i;+ }+ self.seats.push(SeatData {+ seat: seat.clone(),+ keyboard: None,+ pointer: None,+ cursor_shape_device: None,+ data_device: None,+ primary_device: None,+ text_input: None,+ touch: None,+ });+ self.seats.len() - 1+ }++ /// Mark the seat owning `keyboard` as active for clipboard ownership.+ fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {+ if let Some(i) = self+ .seats+ .iter()+ .position(|s| s.keyboard.as_ref() == Some(keyboard))+ {+ self.active_seat = i;+ }+ }++ /// Mark the seat owning `pointer` as active for clipboard ownership.+ fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {+ if let Some(i) = self+ .seats+ .iter()+ .position(|s| s.pointer.as_ref() == Some(pointer))+ {+ self.active_seat = i;+ }+ }++ /// Map window pixel coordinates to an absolute `(row, col)` grid point.+ fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {+ let session = self.session.as_ref()?;+ let m = self.renderer.metrics();+ let (pad_x, pad_y) = self.padding();+ let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));+ let grid = session.term.grid();+ let col =+ ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+ let vrow =+ ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));+ Some((grid.view_to_abs(vrow), col))+ }++ /// Left-button press: start (or word/line-extend) a selection.+ fn pointer_press(&mut self, time: u32) {+ let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+ return;+ };+ let count = match self.last_click {+ Some((t, r, c, n))+ if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>+ {+ n % 3 + 1+ }+ _ => 1,+ };+ self.last_click = Some((time, row, col, count));+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let ctrl = self.modifiers.ctrl;+ let grid = session.term.grid_mut();+ match count {+ 2 => grid.select_word(row, col),+ 3 => grid.select_line(row),+ // Holding Ctrl starts a rectangular (block) selection.+ _ if ctrl => grid.start_block_selection(row, col),+ _ => grid.start_selection(row, col),+ }+ self.selecting = true;+ self.needs_draw = true;+ }++ /// Pointer motion during a drag: extend the selection head and, if the+ /// pointer has left the top/bottom edge, start autoscrolling.+ fn pointer_drag(&mut self) {+ if !self.selecting {+ return;+ }+ let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+ return;+ };+ if let Some(session) = self.session.as_mut() {+ session.term.grid_mut().extend_selection(row, col);+ self.needs_draw = true;+ }+ self.update_autoscroll();+ }++ /// Arm or disarm edge autoscroll based on the pointer's vertical position.+ fn update_autoscroll(&mut self) {+ let dir = if self.pointer_pos.1 < 0.0 {+ 1 // above the top: reveal older lines+ } else if self.pointer_pos.1 >= f64::from(self.height) {+ -1 // below the bottom: advance toward the live screen+ } else {+ 0+ };+ self.autoscroll = dir;+ if dir != 0 && self.autoscroll_timer.is_none() {+ let timer = Timer::immediate();+ self.autoscroll_timer = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())+ .ok();+ }+ }++ /// One autoscroll step: scroll the viewport and drag the selection head to+ /// the edge cell under the pointer. Reschedules until the drag ends or the+ /// pointer returns inside the window.+ fn autoscroll_step(&mut self) -> TimeoutAction {+ if !self.selecting || self.autoscroll == 0 {+ self.autoscroll_timer = None;+ return TimeoutAction::Drop;+ }+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(self.autoscroll);+ }+ let edge_y = if self.autoscroll > 0 {+ 0.0+ } else {+ f64::from(self.height) - 1.0+ };+ if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)+ && let Some(session) = self.session.as_mut()+ {+ session.term.grid_mut().extend_selection(row, col);+ }+ self.needs_draw = true;+ TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))+ }++ /// Left-button release: stop autoscrolling and publish the primary selection.+ fn pointer_release(&mut self, qh: &QueueHandle<App>) {+ if !self.selecting {+ return;+ }+ self.selecting = false;+ self.autoscroll = 0;+ if let Some(token) = self.autoscroll_timer.take() {+ self.loop_handle.remove(token);+ }+ self.set_primary(qh);+ }++ /// Whether the application wants mouse reports and the user is not holding+ /// Shift (which forces local selection regardless of mode).+ fn mouse_reporting(&self) -> bool {+ self.session+ .as_ref()+ .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)+ && !self.modifiers.shift+ }++ /// The viewport cell `(col, row)` under the pointer, clamped to the screen.+ fn report_screen_cell(&self) -> Option<(usize, usize)> {+ let session = self.session.as_ref()?;+ let m = self.renderer.metrics();+ let (pad_x, pad_y) = self.padding();+ let (ppx, ppy) = (+ self.to_phys_f(self.pointer_pos.0),+ self.to_phys_f(self.pointer_pos.1),+ );+ let grid = session.term.grid();+ let col =+ ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+ let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)+ .min(grid.rows().saturating_sub(1));+ Some((col, row))+ }++ /// Report a button press/release to the application, if reporting is active.+ /// Returns whether the event was consumed (so local handling is skipped).+ fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {+ let Some(session) = self.session.as_ref() else {+ return false;+ };+ let grid = session.term.grid();+ let proto = grid.mouse_protocol();+ if proto == MouseProtocol::Off || self.modifiers.shift {+ return false;+ }+ let enc = grid.mouse_encoding();+ // X10 (mode 9) reports presses only; a release is swallowed, not sent.+ if (pressed || proto != MouseProtocol::X10)+ && let Some((col, row)) = self.report_screen_cell()+ {+ let bytes = beer_protocols::mouse::encode_mouse(+ enc,+ code,+ col,+ row,+ pressed,+ false,+ self.modifiers,+ );+ self.write_to_pty(&bytes);+ self.last_report_cell = Some((col, row));+ }+ true+ }++ /// Report pointer motion to the application when the active mode wants it.+ /// Returns whether reporting consumed the motion (suppressing local drag).+ fn try_report_motion(&mut self) -> bool {+ let Some(session) = self.session.as_ref() else {+ return false;+ };+ let grid = session.term.grid();+ let proto = grid.mouse_protocol();+ if proto == MouseProtocol::Off || self.modifiers.shift {+ return false;+ }+ let enc = grid.mouse_encoding();+ let wants = match proto {+ MouseProtocol::Any => true,+ MouseProtocol::Button => self.pressed_button.is_some(),+ _ => false,+ };+ if wants+ && let Some((col, row)) = self.report_screen_cell()+ && self.last_report_cell != Some((col, row))+ {+ // Any-event motion with no button held uses the "no button" code 3.+ let code = self.pressed_button.unwrap_or(3);+ let bytes = beer_protocols::mouse::encode_mouse(+ enc,+ code,+ col,+ row,+ true,+ true,+ self.modifiers,+ );+ self.write_to_pty(&bytes);+ self.last_report_cell = Some((col, row));+ }+ true+ }++ /// Send focus in/out (DECSET 1004) to the application when it asked for it.+ fn report_focus(&mut self, focused: bool) {+ if self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().focus_events())+ {+ self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });+ }+ }++ /// Create or drop the idle inhibitor to match `[main] idle-inhibit` and the+ /// current focus: inhibit only while focused, so a backgrounded terminal+ /// still lets the screen blank. Idempotent; called on every focus change.+ fn sync_idle_inhibit(&mut self) {+ let want = self.config.main.idle_inhibit && self.focused;+ if want && self.idle_inhibitor.is_none() {+ if let Some(mgr) = &self.idle_inhibit_manager {+ self.idle_inhibitor =+ Some(mgr.create_inhibitor(self.window.wl_surface(), &self.qh, ()));+ }+ } else if !want && let Some(inhibitor) = self.idle_inhibitor.take() {+ inhibitor.destroy();+ }+ }++ /// Write bytes to the PTY master, logging on failure.+ fn write_to_pty(&mut self, bytes: &[u8]) {+ if let Some(session) = self.session.as_mut()+ && let Err(err) = write_all(session.pty.master(), bytes)+ {+ tracing::warn!("write to pty: {err}");+ }+ }++ /// The current selection text, if any and non-empty.+ fn selection_text(&self) -> Option<String> {+ let text = self.session.as_ref()?.term.grid().selection_text()?;+ (!text.is_empty()).then_some(text)+ }++ /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.+ fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {+ let Some(device) = self.data_device() else {+ return;+ };+ let source = self+ .data_device_manager+ .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());+ source.set_selection(device, self.serial);+ self.clipboard = text;+ self.copy_source = Some(source);+ }++ /// Take ownership of the primary selection, serving `text` to pasters.+ fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {+ let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())+ else {+ return;+ };+ let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());+ source.set_selection(device, self.serial);+ self.primary_clip = text;+ self.primary_source = Some(source);+ }++ /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).+ fn set_clipboard(&mut self, qh: &QueueHandle<App>) {+ if let Some(text) = self.selection_text() {+ self.claim_clipboard(text, qh);+ }+ }++ /// Claim the primary selection with the current selection (select-to-copy).+ fn set_primary(&mut self, qh: &QueueHandle<App>) {+ if let Some(text) = self.selection_text() {+ self.claim_primary(text, qh);+ }+ }++ /// Point the IME's candidate popup at the terminal cursor, in logical+ /// surface coordinates (the renderer works in physical pixels, so divide+ /// the physical cell rectangle back down by the scale).+ fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {+ let Some(session) = self.session.as_ref() else {+ return;+ };+ let (cx, cy) = session.term.grid().cursor();+ let m = self.renderer.metrics();+ let s = f64::from(self.scale120) / 120.0;+ let pad_x = f64::from(self.to_phys(self.config.main.pad_x));+ let pad_y = f64::from(self.to_phys(self.config.main.pad_y));+ let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;+ let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;+ let w = (f64::from(m.width) / s) as i32;+ let h = (f64::from(m.height) / s) as i32;+ ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));+ }++ /// Apply one IME transaction: commit any committed text to the shell, adopt+ /// the new preedit, then re-commit our state (cursor rectangle) to the IME.+ fn ime_done(&mut self, ti: &ZwpTextInputV3) {+ let commit = std::mem::take(&mut self.ime_commit_pending);+ // Preedit is replaced wholesale each cycle; an absent preedit clears it.+ self.preedit = std::mem::take(&mut self.ime_preedit_pending);+ if !commit.is_empty() {+ self.send_to_shell(commit.as_bytes());+ }+ self.ime_set_cursor_rect(ti);+ ti.commit();+ self.needs_draw = true;+ }++ /// Act on the OSC 52 clipboard requests an application made: take ownership+ /// of the selection it set, or answer a query with what we currently hold.+ fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {+ use crate::vt::ClipboardOp;+ let qh = self.qh.clone();+ for op in ops {+ match op {+ ClipboardOp::Set {+ primary: true,+ text,+ } => self.claim_primary(text, &qh),+ ClipboardOp::Set {+ primary: false,+ text,+ } => self.claim_clipboard(text, &qh),+ ClipboardOp::Query { primary } => {+ let text = if primary {+ &self.primary_clip+ } else {+ &self.clipboard+ };+ let kind = if primary { 'p' } else { 'c' };+ let reply = format!(+ "\x1b]52;{kind};{}\x07",+ beer_protocols::codec::base64_encode(text.as_bytes())+ );+ self.write_to_pty(reply.as_bytes());+ }+ }+ }+ }++ /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).+ fn paste_clipboard(&mut self) {+ let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {+ tracing::debug!("paste: no clipboard selection offer");+ return;+ };+ let Some(mime) = offer.with_mime_types(pick_mime) else {+ tracing::debug!("paste: no acceptable text mime type offered");+ return;+ };+ if let Ok(pipe) = offer.receive(mime) {+ self.read_paste(pipe);+ }+ }++ /// Paste the primary selection into the shell (middle click).+ fn paste_primary(&mut self) {+ let Some(offer) = self+ .primary_device()+ .and_then(|d| d.data().selection_offer())+ else {+ return;+ };+ let Some(mime) = offer.with_mime_types(pick_mime) else {+ return;+ };+ if let Ok(pipe) = offer.receive(mime) {+ self.read_paste(pipe);+ }+ }++ /// Drain a clipboard read-pipe on the event loop, writing the bytes to the+ /// PTY once the source closes its end.+ fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {+ let mut data: Vec<u8> = Vec::new();+ let registered = self+ .loop_handle+ .insert_source(pipe, move |_, file, app: &mut App| {+ // SAFETY: the file is owned by the source and not closed while read.+ let f: &mut File = unsafe { file.get_mut() };+ let mut tmp = [0u8; 4096];+ match f.read(&mut tmp) {+ Ok(0) => {+ tracing::debug!("paste: read {} bytes from clipboard", data.len());+ app.paste_bytes(&data);+ PostAction::Remove+ }+ Ok(n) => {+ data.extend_from_slice(&tmp[..n]);+ PostAction::Continue+ }+ Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {+ PostAction::Continue+ }+ Err(e) => {+ tracing::warn!("read paste pipe: {e}");+ PostAction::Remove+ }+ }+ });+ if let Err(err) = registered {+ tracing::warn!("register paste pipe: {err}");+ }+ }++ /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and+ /// snapping the viewport to the live screen.+ fn paste_bytes(&mut self, data: &[u8]) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ session.term.scroll_to_bottom();+ self.needs_draw = true;+ let bracketed = session.term.grid().bracketed_paste();+ // Strip control bytes a terminal must never receive raw from a paste;+ // keep tab and newlines (CR is what the shell expects for Enter).+ let mut clean: Vec<u8> = Vec::with_capacity(data.len());+ for &b in data {+ match b {+ b'\n' => clean.push(b'\r'),+ b'\t' | b'\r' => clean.push(b),+ 0x20..=0xff => clean.push(b),+ _ => {}+ }+ }+ let fd = session.pty.master();+ if bracketed {+ let _ = write_all(fd, b"\x1b[200~");+ let _ = write_all(fd, &clean);+ let _ = write_all(fd, b"\x1b[201~");+ } else if let Err(err) = write_all(fd, &clean) {+ tracing::warn!("write paste to pty: {err}");+ }+ }++ /// After parsing child output: send any replies, sync the title, repaint.+ fn after_feed(&mut self) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let reply = session.term.take_response();+ if !reply.is_empty()+ && let Err(err) = write_all(session.pty.master(), &reply)+ {+ tracing::warn!("write to pty: {err}");+ }++ let new_title = session.term.title().map(str::to_owned);+ if new_title.as_deref() != self.title.as_deref() {+ self.title = new_title;+ self.window+ .set_title(self.title.clone().unwrap_or_default());+ }+ let rang = session.term.take_bell();+ let ops = session.term.take_clipboard_ops();+ let notifications = session.term.take_notifications();+ if !ops.is_empty() {+ self.handle_clipboard_ops(ops);+ }+ for note in notifications {+ self.send_notification(¬e);+ }+ if rang {+ self.ring_bell();+ }+ self.needs_draw = true;+ }++ /// React to a `BEL`: optionally flash, run the configured bell command, and+ /// request the compositor's attention when unfocused.+ fn ring_bell(&mut self) {+ if self.config.bell.visual {+ self.start_flash();+ }+ if let Some((program, args)) = self.config.bell.command.split_first() {+ let _ = std::process::Command::new(program)+ .args(args)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .spawn()+ .inspect_err(|err| tracing::warn!("bell command: {err}"));+ }+ if self.config.bell.urgent && !self.focused {+ self.request_attention();+ }+ }++ /// Deliver a desktop notification through the configured notifier (default+ /// `notify-send`), appending the title and body as the final arguments.+ fn send_notification(&self, note: &crate::vt::Notification) {+ let Some((program, args)) = self.config.notify.command.split_first() else {+ return;+ };+ let title = note+ .title+ .clone()+ .or_else(|| self.title.clone())+ .unwrap_or_else(|| "beer".to_string());+ let _ = std::process::Command::new(program)+ .args(args)+ .arg(title)+ .arg(¬e.body)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .spawn()+ .inspect_err(|err| tracing::warn!("notify command: {err}"));+ }++ /// Ask the compositor to draw attention to the window (xdg-activation).+ fn request_attention(&mut self) {+ let Some(activation) = self.activation.as_ref() else {+ return;+ };+ let seat_and_serial = self+ .seats+ .get(self.active_seat)+ .map(|s| (s.seat.clone(), self.serial));+ let data = smithay_client_toolkit::activation::RequestData {+ app_id: Some("dev.notashelf.beer".to_string()),+ seat_and_serial,+ surface: Some(self.window.wl_surface().clone()),+ };+ activation.request_token::<App>(&self.qh, data);+ }++ /// Begin a visual-bell flash: invert the screen for a moment. Clearing the+ /// buffer ring forces a full repaint with the inverted theme.+ fn start_flash(&mut self) {+ self.flashing = true;+ self.frames.clear();+ self.needs_draw = true;+ if self.flash_timer.is_none() {+ let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));+ self.flash_timer = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| {+ app.flashing = false;+ app.frames.clear();+ app.needs_draw = true;+ app.flash_timer = None;+ TimeoutAction::Drop+ })+ .ok();+ }+ }++ /// Recompute the grid size for the current window and tell the grid and the+ /// PTY about it if it changed.+ fn resize_grid(&mut self) {+ let (cols, rows) = self.grid_dims();+ let Some(session) = self.session.as_mut() else {+ return;+ };+ if (cols as usize, rows as usize)+ == (session.term.grid().cols(), session.term.grid().rows())+ {+ return;+ }+ session.term.resize(cols as usize, rows as usize);+ if let Err(err) = session.pty.resize(cols, rows) {+ tracing::warn!("resize pty: {err}");+ }+ }++ /// The child shell has gone away; reap it, capture its code, and tear the+ /// window down.+ fn child_exited(&mut self) {+ if let Some(session) = self.session.as_mut() {+ match session.pty.wait() {+ Ok(status) => {+ tracing::info!("shell exited: {status}");+ // Mirror the shell's status: its code, or 128+signal if killed.+ let code = status+ .code()+ .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));+ self.exit_code = ExitCode::from(code as u8);+ }+ Err(err) => tracing::warn!("reap shell: {err}"),+ }+ }+ self.exit = true;+ }++ /// Present a frame if one is wanted and the compositor is ready for it.+ /// Called after every event-loop wake; the frame-callback gate keeps draws+ /// paced to the display instead of one per PTY read. While the app holds+ /// synchronized output (DECSET 2026) we withhold the frame, but arm a+ /// timeout so a stuck `2026h` cannot freeze the window.+ fn flush(&mut self) {+ let sync = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().sync_active());+ if sync {+ if self.sync_timeout.is_none() {+ let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));+ self.sync_timeout = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| {+ if let Some(session) = app.session.as_mut() {+ session.term.grid_mut().set_sync(false);+ }+ app.sync_timeout = None;+ app.needs_draw = true;+ TimeoutAction::Drop+ })+ .ok();+ }+ return;+ }+ if let Some(token) = self.sync_timeout.take() {+ self.loop_handle.remove(token);+ }+ if self.needs_draw && !self.frame_pending && self.session.is_some() {+ self.present();+ }+ }+}@@ -0,0 +1,231 @@+use super::*;++/// What determines one rendered row's pixels: its cells, the cursor on it, the+/// selection span over it, and the blink phase. Two equal `RowSnap`s render+/// identically, so a buffer holding an equal snapshot needs no repaint.+#[derive(Clone, PartialEq, Debug)]+struct RowSnap {+ cells: Vec<Cell>,+ /// `(col, shape, focused)` when the cursor is drawn on this row.+ cursor: Option<(usize, CursorShape, bool)>,+ /// Inclusive selected column span on this row.+ sel: Option<(usize, usize)>,+ /// Search-match spans `(lo, hi, is_current)` highlighted on this row.+ search: Vec<(usize, usize, bool)>,+ /// Search-prompt text drawn over this row (only the bottom row, when active).+ overlay: Option<String>,+ /// IME preedit `(start_col, text)` drawn inline over this row (cursor row).+ preedit: Option<(usize, String)>,+ /// Blink phase, but only varied when the row actually has blinking ink, so+ /// non-blinking rows stay equal across phase toggles.+ blink: bool,+}++/// One shm buffer plus the per-row snapshot of what it currently displays.+#[derive(Debug)]+pub(super) struct FrameBuf {+ buffer: Buffer,+ rows: Vec<RowSnap>,+}++/// Snapshot the determinants of viewport row `y`'s pixels.+fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {+ let abs = grid.view_to_abs(y);+ let cells = grid.view_row(y).to_vec();+ let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {+ let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);+ visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))+ } else {+ None+ };+ let has_blink = cells+ .iter()+ .any(|c| c.flags.contains(crate::grid::Flags::BLINK));+ RowSnap {+ cells,+ cursor,+ sel: grid.selection_span_on(abs),+ search: grid.search_spans_on(abs),+ overlay: None,+ preedit: None,+ blink: if has_blink { blink_on } else { true },+ }+}+impl App {+ /// Render only the rows that changed since the chosen buffer last displayed+ /// them, damage just those rows, and commit with a frame-callback request.+ pub(super) fn present(&mut self) {+ self.needs_draw = false;+ // URL hint labels overlay the grid but are not part of the row snapshot,+ // so force a full redraw while the labels are showing.+ if self.url_mode {+ self.frames.clear();+ }+ // Render into a buffer sized in physical pixels (logical × scale); the+ // viewport presents it back at the logical surface size.+ let (w, h) = self.phys_dims();+ let m = self.renderer.metrics();+ let (focused, blink_on) = (self.focused, self.blink_on);++ // A resize invalidates every buffer's contents and size.+ if self.buf_dims != (w, h) {+ self.frames.clear();+ self.buf_dims = (w, h);+ }++ let Some(session) = self.session.as_ref() else {+ return;+ };+ let grid = session.term.grid();+ // The visual bell inverts fg/bg for the duration of the flash.+ let flashed = self.flashing.then(|| session.term.theme().inverted());+ let theme = flashed.as_ref().unwrap_or(session.term.theme());+ let rows = grid.rows();+ let mut cur: Vec<RowSnap> = (0..rows)+ .map(|y| row_snap(grid, y, focused, blink_on))+ .collect();++ // The search prompt occupies the bottom row while search mode is active.+ // Recording it in the snapshot keeps the row's damage/diff correct.+ let bar_text = if let Some(hex) = &self.unicode_input {+ Some(format!("unicode: U+{}", hex.to_uppercase()))+ } else {+ self.searching.then(|| {+ let (n, total) = grid.search_count();+ format!(+ "search: {} [{n}/{total}]",+ grid.search_query().unwrap_or("")+ )+ })+ };+ if let Some(text) = &bar_text+ && rows > 0+ {+ cur[rows - 1].overlay = Some(text.clone());+ }++ // The IME preedit is drawn inline at the cursor while composing.+ if !self.preedit.is_empty() && grid.view_at_bottom() {+ let (cx, cy) = grid.cursor();+ if cy < rows {+ cur[cy].preedit = Some((cx, self.preedit.clone()));+ }+ }++ // Reuse a buffer the compositor has released, else grow the ring.+ let stride = w as i32 * 4;+ let mut idx = None;+ for i in 0..self.frames.len() {+ if self.pool.canvas(&self.frames[i].buffer).is_some() {+ idx = Some(i);+ break;+ }+ }+ let idx = match idx {+ Some(i) => i,+ None if self.frames.len() < MAX_BUFFERS => {+ match self+ .pool+ .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)+ {+ Ok((buffer, _)) => {+ self.frames.push(FrameBuf {+ buffer,+ rows: Vec::new(),+ });+ self.frames.len() - 1+ }+ Err(err) => {+ tracing::error!("allocate shm buffer: {err}");+ return;+ }+ }+ }+ // All buffers are still held by the compositor; a release event will+ // wake us and `needs_draw` (re-set below) retries then.+ None => {+ self.needs_draw = true;+ return;+ }+ };++ // Rows that differ from what this buffer last showed (all, if fresh).+ let prev = &self.frames[idx].rows;+ let dirty: Vec<usize> = (0..rows)+ .filter(|&y| prev.get(y) != Some(&cur[y]))+ .collect();+ if dirty.is_empty() {+ return;+ }++ // A buffer used for the first time has uninitialized margins; paint the+ // whole thing (background + padding) once, then damage it in full below.+ let fresh = self.frames[idx].rows.is_empty();+ let pad_y = self.to_phys(self.config.main.pad_y) as i32;+ let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {+ return;+ };+ let dims = (w as usize, h as usize);+ let frame = crate::render::Frame {+ theme,+ focused,+ blink_on,+ hovered_link: self.hovered_link,+ };+ if fresh {+ self.renderer.clear(canvas, dims, theme);+ }+ for &y in &dirty {+ self.renderer.render_row(canvas, dims, grid, &frame, y);+ }+ // Draw the search prompt over the (now repainted) bottom row.+ if let Some(text) = &bar_text+ && dirty.contains(&(rows - 1))+ {+ self.renderer+ .render_search_bar(canvas, dims, theme, rows - 1, text);+ }+ // Draw the IME preedit inline over its (repainted) cursor row.+ for &y in &dirty {+ if let Some((col, text)) = &cur[y].preedit {+ self.renderer+ .render_preedit(canvas, dims, theme, y, *col, text);+ }+ }+ // Draw URL hint labels on top, narrowing to those matching the input.+ if self.url_mode {+ for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {+ if label.starts_with(&self.url_input) {+ self.renderer+ .render_label(canvas, dims, theme, hit.row, hit.col, label);+ }+ }+ }+ self.frames[idx].rows = cur;++ let surface = self.window.wl_surface();+ if let Err(err) = self.frames[idx].buffer.attach_to(surface) {+ tracing::error!("attach buffer: {err}");+ return;+ }+ // With a viewport the buffer is presented at the logical destination, so+ // its own scale stays 1; without one, fall back to integer buffer scale.+ if let Some(vp) = &self.viewport {+ surface.set_buffer_scale(1);+ vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+ } else {+ surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);+ }+ if fresh {+ surface.damage_buffer(0, 0, w as i32, h as i32);+ } else {+ for &y in &dirty {+ let top = pad_y + y as i32 * m.height as i32;+ surface.damage_buffer(0, top, w as i32, m.height as i32);+ }+ }+ surface.frame(&self.qh, surface.clone());+ self.window.commit();+ self.frame_pending = true;+ }+}@@ -12,11 +12,11 @@ fontconfig, harfbuzz, }: let- cargoTOML = (lib.importTOML ../Cargo.toml).package.version;+ cargoTOML = lib.importTOML ../Cargo.toml; in rustPlatform.buildRustPackage (finalAttrs: { pname = "beer";- version = cargoTOML.package.version;+ version = cargoTOML.workspace.package.version; src = let fs = lib.fileset;@@ -25,7 +25,7 @@ in fs.toSource { root = s; fileset = fs.unions [- (s + /src)+ (s + /crates) (s + /Cargo.lock) (s + /Cargo.toml) (s + /terminfo)@@ -1,457 +0,0 @@-//! Configurable key bindings: chord strings (e.g. `Ctrl+Shift+C`) mapped to-//! editor actions, plus text bindings that send a literal string.--use std::collections::HashMap;--use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};--/// An action a key binding can trigger.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum Action {- Copy,- Paste,- PastePrimary,- ScrollPageUp,- ScrollPageDown,- ScrollTop,- ScrollBottom,- SearchStart,- FontIncrease,- FontDecrease,- FontReset,- Fullscreen,- NewWindow,- JumpPromptUp,- JumpPromptDown,- PipeCommandOutput,- UrlMode,- UnicodeInput,-}--impl Action {- fn parse(name: &str) -> Option<Self> {- Some(match name {- "copy" => Self::Copy,- "paste" => Self::Paste,- "paste-primary" => Self::PastePrimary,- "scrollback-up" => Self::ScrollPageUp,- "scrollback-down" => Self::ScrollPageDown,- "scrollback-top" => Self::ScrollTop,- "scrollback-bottom" => Self::ScrollBottom,- "search" => Self::SearchStart,- "font-increase" => Self::FontIncrease,- "font-decrease" => Self::FontDecrease,- "font-reset" => Self::FontReset,- "fullscreen" => Self::Fullscreen,- "new-window" => Self::NewWindow,- "jump-prompt-up" => Self::JumpPromptUp,- "jump-prompt-down" => Self::JumpPromptDown,- "pipe-command-output" => Self::PipeCommandOutput,- "url-mode" => Self::UrlMode,- "unicode-input" => Self::UnicodeInput,- _ => return None,- })- }-}--/// A parsed chord: a key plus the modifiers that must be held exactly.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct Chord {- key: Keysym,- ctrl: bool,- shift: bool,- alt: bool,- logo: bool,-}--impl Chord {- /// Parse a chord like `Ctrl+Shift+C`. Modifier order is irrelevant; the- /// final token is the key. Returns `None` if no key is recognized.- fn parse(spec: &str) -> Option<Self> {- let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);- let mut key = None;- for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {- match token.to_ascii_lowercase().as_str() {- "ctrl" | "control" => ctrl = true,- "shift" => shift = true,- "alt" | "mod1" | "meta" => alt = true,- "super" | "logo" | "cmd" | "mod4" => logo = true,- _ => key = keysym_from_token(token),- }- }- Some(Self {- key: key?,- ctrl,- shift,- alt,- logo,- })- }-- /// Whether `event`/`mods` match this chord. Letters compare case-insensitively- /// (Shift is matched via the modifier, not the keysym case).- fn matches(&self, event: &KeyEvent, mods: Modifiers) -> bool {- if mods.ctrl != self.ctrl- || mods.shift != self.shift- || mods.alt != self.alt- || mods.logo != self.logo- {- return false;- }- event.keysym == self.key- || matches!(- (self.key.key_char(), event.keysym.key_char()),- (Some(a), Some(b)) if a.eq_ignore_ascii_case(&b)- )- }-}--/// A mouse button that a `[mouse-bindings]` chord can name.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum MouseButton {- Left,- Middle,- Right,-}--/// A parsed mouse chord: a button plus the modifiers that must be held exactly.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct MouseChord {- button: MouseButton,- ctrl: bool,- shift: bool,- alt: bool,- logo: bool,-}--impl MouseChord {- /// Parse a chord like `Shift+Middle`; the final non-modifier token is the- /// button. Returns `None` if no button is recognized.- fn parse(spec: &str) -> Option<Self> {- let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);- let mut button = None;- for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {- match token.to_ascii_lowercase().as_str() {- "ctrl" | "control" => ctrl = true,- "shift" => shift = true,- "alt" | "mod1" | "meta" => alt = true,- "super" | "logo" | "cmd" | "mod4" => logo = true,- "left" | "button1" => button = Some(MouseButton::Left),- "middle" | "button2" => button = Some(MouseButton::Middle),- "right" | "button3" => button = Some(MouseButton::Right),- other => {- tracing::warn!("unknown mouse button {other:?} in binding");- return None;- }- }- }- Some(Self {- button: button?,- ctrl,- shift,- alt,- logo,- })- }-- fn matches(&self, button: MouseButton, mods: Modifiers) -> bool {- self.button == button- && mods.ctrl == self.ctrl- && mods.shift == self.shift- && mods.alt == self.alt- && mods.logo == self.logo- }-}--/// The resolved binding tables for a session.-#[derive(Clone, Debug, Default)]-pub struct Bindings {- keys: Vec<(Chord, Action)>,- text: Vec<(Chord, Vec<u8>)>,- mouse: Vec<(MouseChord, Action)>,-}--impl Bindings {- /// Build the tables from config, starting from the built-in defaults and- /// applying user overrides (a value of `none` unbinds a default chord).- pub fn from_config(- key_bindings: &HashMap<String, String>,- text_bindings: &HashMap<String, String>,- mouse_bindings: &HashMap<String, String>,- ) -> Self {- let mut keys: Vec<(Chord, Action)> = Vec::new();- let mut add = |chord: &str, action: Action| {- if let Some(c) = Chord::parse(chord) {- keys.retain(|(existing, _)| *existing != c);- keys.push((c, action));- }- };- for (chord, action) in DEFAULT_BINDINGS {- if let Some(a) = Action::parse(action) {- add(chord, a);- }- }- for (chord, action) in key_bindings {- if let Some(c) = Chord::parse(chord) {- keys.retain(|(existing, _)| *existing != c);- if let Some(a) = Action::parse(action) {- keys.push((c, a));- } else if action != "none" {- tracing::warn!("unknown key-binding action {action:?}");- }- }- }-- let mut text = Vec::new();- for (chord, value) in text_bindings {- if let Some(c) = Chord::parse(chord) {- text.push((c, unescape(value)));- }- }-- let mut mouse: Vec<(MouseChord, Action)> = Vec::new();- let mut add_mouse = |chord: &str, action: Option<Action>| {- if let Some(c) = MouseChord::parse(chord) {- mouse.retain(|(existing, _)| *existing != c);- if let Some(a) = action {- mouse.push((c, a));- }- }- };- for (chord, action) in DEFAULT_MOUSE_BINDINGS {- add_mouse(chord, Action::parse(action));- }- for (chord, action) in mouse_bindings {- if action == "none" {- add_mouse(chord, None);- } else if let Some(a) = Action::parse(action) {- add_mouse(chord, Some(a));- } else {- tracing::warn!("unknown mouse-binding action {action:?}");- }- }-- Self { keys, text, mouse }- }-- /// The action bound to this key event, if any.- pub fn action(&self, event: &KeyEvent, mods: Modifiers) -> Option<Action> {- self.keys- .iter()- .find(|(c, _)| c.matches(event, mods))- .map(|(_, a)| *a)- }-- /// The literal bytes bound to this key event, if any.- pub fn text(&self, event: &KeyEvent, mods: Modifiers) -> Option<&[u8]> {- self.text- .iter()- .find(|(c, _)| c.matches(event, mods))- .map(|(_, t)| t.as_slice())- }-- /// The action bound to this mouse button + modifiers, if any.- pub fn mouse_action(&self, button: MouseButton, mods: Modifiers) -> Option<Action> {- self.mouse- .iter()- .find(|(c, _)| c.matches(button, mods))- .map(|(_, a)| *a)- }-}--/// Built-in default key bindings (chord, action name).-const DEFAULT_BINDINGS: &[(&str, &str)] = &[- ("Ctrl+Shift+C", "copy"),- ("Ctrl+Shift+V", "paste"),- ("Ctrl+Shift+F", "search"),- ("Shift+Page_Up", "scrollback-up"),- ("Shift+Page_Down", "scrollback-down"),- ("Ctrl+Shift+Home", "scrollback-top"),- ("Ctrl+Shift+End", "scrollback-bottom"),- ("Ctrl+plus", "font-increase"),- ("Ctrl+equal", "font-increase"),- ("Ctrl+minus", "font-decrease"),- ("Ctrl+0", "font-reset"),- ("F11", "fullscreen"),- ("Ctrl+Shift+N", "new-window"),- ("Ctrl+Shift+Up", "jump-prompt-up"),- ("Ctrl+Shift+Down", "jump-prompt-down"),- ("Ctrl+Shift+O", "url-mode"),- ("Ctrl+Shift+U", "unicode-input"),-];--/// Built-in default mouse bindings (chord, action name). Left-button select and-/// drag are built-in gestures, not bindings, so only the other buttons appear.-const DEFAULT_MOUSE_BINDINGS: &[(&str, &str)] = &[("Middle", "paste-primary")];--/// Map a key token to a keysym: a single character, or a named special key.-fn keysym_from_token(token: &str) -> Option<Keysym> {- let mut chars = token.chars();- if let (Some(c), None) = (chars.next(), chars.clone().next()) {- // A single character; bind case-insensitively via the lowercase keysym.- return Some(Keysym::from_char(c.to_ascii_lowercase()));- }- Some(match token {- "Return" | "Enter" => Keysym::Return,- "Tab" => Keysym::Tab,- "Escape" | "Esc" => Keysym::Escape,- "Space" => Keysym::space,- "BackSpace" => Keysym::BackSpace,- "Delete" => Keysym::Delete,- "Insert" => Keysym::Insert,- "Home" => Keysym::Home,- "End" => Keysym::End,- "Page_Up" | "PageUp" | "Prior" => Keysym::Page_Up,- "Page_Down" | "PageDown" | "Next" => Keysym::Page_Down,- "Up" => Keysym::Up,- "Down" => Keysym::Down,- "Left" => Keysym::Left,- "Right" => Keysym::Right,- "plus" => Keysym::plus,- "minus" => Keysym::minus,- "equal" => Keysym::equal,- "F1" => Keysym::F1,- "F2" => Keysym::F2,- "F3" => Keysym::F3,- "F4" => Keysym::F4,- "F5" => Keysym::F5,- "F6" => Keysym::F6,- "F7" => Keysym::F7,- "F8" => Keysym::F8,- "F9" => Keysym::F9,- "F10" => Keysym::F10,- "F11" => Keysym::F11,- "F12" => Keysym::F12,- other => {- tracing::warn!("unknown key name {other:?} in binding");- return None;- }- })-}--/// Decode the escapes a text binding may contain: `\e \n \r \t \\` and `\xNN`.-fn unescape(s: &str) -> Vec<u8> {- let mut out = Vec::with_capacity(s.len());- let mut chars = s.chars().peekable();- while let Some(c) = chars.next() {- if c != '\\' {- let mut buf = [0u8; 4];- out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());- continue;- }- match chars.next() {- Some('e') => out.push(0x1b),- Some('n') => out.push(b'\n'),- Some('r') => out.push(b'\r'),- Some('t') => out.push(b'\t'),- Some('\\') => out.push(b'\\'),- Some('x') => {- let hex: String = (0..2).filter_map(|_| chars.next()).collect();- if let Ok(byte) = u8::from_str_radix(&hex, 16) {- out.push(byte);- }- }- Some(other) => {- out.push(b'\\');- let mut buf = [0u8; 4];- out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());- }- None => out.push(b'\\'),- }- }- out-}--#[cfg(test)]-mod tests {- use super::*;-- const NONE: Modifiers = Modifiers {- ctrl: false,- alt: false,- shift: false,- caps_lock: false,- logo: false,- num_lock: false,- };-- fn key(keysym: Keysym) -> KeyEvent {- KeyEvent {- time: 0,- raw_code: 0,- keysym,- utf8: None,- }- }-- #[test]- fn default_copy_binding_matches_case_insensitively() {- let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());- let mods = Modifiers {- ctrl: true,- shift: true,- ..NONE- };- // Shift yields the uppercase keysym; the binding still matches.- assert_eq!(b.action(&key(Keysym::C), mods), Some(Action::Copy));- // Without the right modifiers, no match.- assert_eq!(b.action(&key(Keysym::C), NONE), None);- }-- #[test]- fn config_overrides_and_unbinds() {- let mut kb = HashMap::new();- kb.insert("Ctrl+Shift+C".to_string(), "none".to_string());- kb.insert("Ctrl+y".to_string(), "copy".to_string());- let b = Bindings::from_config(&kb, &HashMap::new(), &HashMap::new());- let cs = Modifiers {- ctrl: true,- shift: true,- ..NONE- };- assert_eq!(b.action(&key(Keysym::C), cs), None); // unbound- let c = Modifiers { ctrl: true, ..NONE };- assert_eq!(b.action(&key(Keysym::y), c), Some(Action::Copy));- }-- #[test]- fn text_binding_unescapes() {- let mut tb = HashMap::new();- tb.insert("Ctrl+Shift+Return".to_string(), "\\x1b\\r".to_string());- let b = Bindings::from_config(&HashMap::new(), &tb, &HashMap::new());- let mods = Modifiers {- ctrl: true,- shift: true,- ..NONE- };- assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..]));- }-- #[test]- fn mouse_bindings_default_override_and_unbind() {- // Default: middle button pastes the primary selection.- let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());- assert_eq!(- b.mouse_action(MouseButton::Middle, NONE),- Some(Action::PastePrimary)- );- assert_eq!(b.mouse_action(MouseButton::Right, NONE), None);-- // Config rebinds right to paste and unbinds the middle default.- let mut mb = HashMap::new();- mb.insert("Right".to_string(), "paste".to_string());- mb.insert("Middle".to_string(), "none".to_string());- let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &mb);- assert_eq!(- b.mouse_action(MouseButton::Right, NONE),- Some(Action::Paste)- );- assert_eq!(b.mouse_action(MouseButton::Middle, NONE), None);- // Modifiers must match exactly.- let shift = Modifiers {- shift: true,- ..NONE- };- assert_eq!(b.mouse_action(MouseButton::Right, shift), None);- }-}@@ -1,297 +0,0 @@-//! User configuration: a TOML file at `$XDG_CONFIG_HOME/beer/beer.toml`-//! deserialized into a typed [`Config`]. A missing file uses defaults; a-//! malformed one warns and falls back to defaults rather than failing to start.--use std::path::{Path, PathBuf};--use serde::Deserialize;--/// Top-level configuration. Unknown keys are ignored so a config written for a-/// newer beer still loads.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Config {- pub main: Main,- pub colors: Colors,- pub cursor: Cursor,- pub scrollback: Scrollback,- pub bell: Bell,- pub mouse: Mouse,- pub shell_integration: ShellIntegration,- pub url: Url,- pub notify: Notify,- /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;- /// a value of `"none"` unbinds.- pub key_bindings: std::collections::HashMap<String, String>,- /// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).- pub text_bindings: std::collections::HashMap<String, String>,- /// Mouse chord (e.g. `"Middle"`, `"Shift+Right"`) → action. Merged over the- /// defaults; `"none"` unbinds. Left-button select/drag stays built in.- pub mouse_bindings: std::collections::HashMap<String, String>,-}--/// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Cursor {- /// `block`, `beam`/`bar`, or `underline`.- pub style: Option<String>,- /// Whether the cursor blinks by default.- pub blink: bool,-}--/// `[bell]`: what happens on `BEL` (0x07).-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Bell {- /// Briefly flash the screen.- pub visual: bool,- /// Command (argv) to run on the bell, e.g. `["paplay", "/usr/share/.../bell.oga"]`.- pub command: Vec<String>,- /// Request the compositor's attention (xdg-activation) when the bell rings- /// while the window is unfocused.- pub urgent: bool,-}--/// `[notify]`: how desktop notifications (OSC 9/777/99) are delivered.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Notify {- /// Notifier argv; the title and body are appended as the last two arguments.- pub command: Vec<String>,-}--impl Default for Notify {- fn default() -> Self {- Self {- command: vec!["notify-send".to_string()],- }- }-}--/// `[mouse]`: pointer and wheel behaviour.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Mouse {- /// Multiplier applied to the lines scrolled per wheel notch.- pub scroll_multiplier: f64,- /// On the alternate screen, translate the wheel into arrow-key presses so- /// full-screen apps that did not request mouse reporting (less, man, …)- /// still scroll.- pub alternate_scroll: bool,-}--impl Default for Mouse {- fn default() -> Self {- Self {- scroll_multiplier: 1.0,- alternate_scroll: true,- }- }-}--/// `[shell-integration]`: behaviour driven by OSC 7 / OSC 133 marks.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct ShellIntegration {- /// Command the `pipe-command-output` binding feeds the last command's- /// output to on stdin (argv form, e.g. `["less"]`). Empty disables it.- pub pipe_command: Vec<String>,-}--/// `[url]`: opening OSC 8 hyperlinks and detected URLs.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Url {- /// Launcher argv the URL is appended to (e.g. `["xdg-open"]`).- pub launch: Vec<String>,-}--impl Default for Url {- fn default() -> Self {- Self {- launch: vec!["xdg-open".to_string()],- }- }-}--/// `[colors]`: foreground/background, the 16 base palette entries, and accents.-/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries-/// keep the built-in default.-#[derive(Debug, Clone, Default, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Colors {- pub foreground: Option<String>,- pub background: Option<String>,- pub cursor: Option<String>,- pub selection_foreground: Option<String>,- pub selection_background: Option<String>,- /// The eight regular palette entries (indices 0-7).- pub regular: Option<Vec<String>>,- /// The eight bright palette entries (indices 8-15).- pub bright: Option<Vec<String>>,- pub match_background: Option<String>,- pub match_current_background: Option<String>,- /// Background opacity, 0.0 (transparent) - 1.0 (opaque).- pub alpha: Option<f32>,- /// Render bold text with the bright palette variant.- pub bold_as_bright: Option<bool>,-}--/// `[main]`: fonts, window geometry, padding, and the terminal name.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Main {- /// Primary font family, resolved via fontconfig.- pub font: String,- /// Font size in pixels.- pub font_size: u32,- /// `TERM` value exported to the child shell.- pub term: String,- /// Initial size in character cells.- pub initial_cols: u16,- pub initial_rows: u16,- /// Inner padding in pixels between the window edge and the cell grid.- pub pad_x: u32,- pub pad_y: u32,- /// Characters that break a word for double-click selection. Empty/unset- /// keeps the built-in default.- pub word_delimiters: Option<String>,- /// Hold an idle inhibitor while the window is focused, so the compositor- /// does not blank the screen or start the screensaver. Default off.- pub idle_inhibit: bool,-}--impl Default for Main {- fn default() -> Self {- Self {- font: "monospace".to_string(),- font_size: 16,- term: "beer".to_string(),- initial_cols: 80,- initial_rows: 24,- pad_x: 2,- pad_y: 2,- word_delimiters: None,- idle_inhibit: false,- }- }-}--/// `[scrollback]`: history retention.-#[derive(Debug, Clone, Deserialize)]-#[serde(default, rename_all = "kebab-case")]-pub struct Scrollback {- /// Lines of history retained for the main screen.- pub lines: usize,-}--impl Default for Scrollback {- fn default() -> Self {- Self { lines: 10_000 }- }-}--impl Config {- /// Load configuration from `explicit` if given, else the default path.- /// Any read/parse failure logs a warning and returns defaults.- pub fn load(explicit: Option<&Path>) -> Self {- let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else {- return Self::default();- };- if !path.exists() {- return Self::default();- }- let text = match std::fs::read_to_string(&path) {- Ok(text) => text,- Err(err) => {- tracing::warn!("read config {}: {err}; using defaults", path.display());- return Self::default();- }- };- // Deserialize through serde_ignored so a typo'd key (`font-sze`) is- // reported instead of silently dropped, while still loading: unknown- // keys stay tolerated, keeping forward-compatibility with newer configs.- let de = match toml::Deserializer::parse(&text) {- Ok(de) => de,- Err(err) => {- tracing::warn!("config {}: {err}; using defaults", path.display());- return Self::default();- }- };- match serde_ignored::deserialize(de, |key| {- tracing::warn!("config {}: unknown key `{key}` ignored", path.display());- }) {- Ok(config) => {- tracing::info!("loaded config from {}", path.display());- config- }- Err(err) => {- tracing::warn!("config {}: {err}; using defaults", path.display());- Self::default()- }- }- }-}--/// `$XDG_CONFIG_HOME/beer/beer.toml`, or `~/.config/beer/beer.toml`.-fn default_path() -> Option<PathBuf> {- if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {- return Some(PathBuf::from(dir).join("beer/beer.toml"));- }- let home = std::env::var_os("HOME")?;- Some(PathBuf::from(home).join(".config/beer/beer.toml"))-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn defaults_are_sane() {- let c = Config::default();- assert_eq!(c.main.font, "monospace");- assert_eq!(c.main.font_size, 16);- assert_eq!(c.scrollback.lines, 10_000);- }-- #[test]- fn parses_partial_config_and_ignores_unknown() {- let toml = r##"- [main]- font = "JetBrains Mono"- font-size = 14- unknown-key = "tolerated"-- [colors]- background = "#000000"- "##;- let c: Config = toml::from_str(toml).unwrap();- assert_eq!(c.main.font, "JetBrains Mono");- assert_eq!(c.main.font_size, 14);- // Unset keys keep defaults; unknown tables/keys are ignored.- assert_eq!(c.main.term, "beer");- assert_eq!(c.scrollback.lines, 10_000);- }-- #[test]- fn unknown_keys_are_reported_but_still_load() {- let toml = r##"- [main]- font-sze = 14- font = "JetBrains Mono"-- [made-up]- x = 1- "##;- let mut unknown = Vec::new();- let de = toml::Deserializer::parse(toml).unwrap();- let c: Config =- serde_ignored::deserialize(de, |key| unknown.push(key.to_string())).unwrap();- // The typo and the bogus table are both surfaced...- assert!(unknown.iter().any(|k| k == "main.font-sze"), "{unknown:?}");- assert!(unknown.iter().any(|k| k == "made-up"), "{unknown:?}");- // ...yet the valid key still loaded.- assert_eq!(c.main.font, "JetBrains Mono");- }-}@@ -1,664 +0,0 @@-//! Font discovery, rasterization, and glyph caching.-//!-//! fontconfig resolves family names and performs per-codepoint fallback;-//! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour-//! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own-//! advance is never consulted - only the [`CellMetrics`] taken from the primary-//! face. C interop goes through the `freetype`/`fontconfig` safe wrappers; the-//! sole `unsafe` is reading a face's fixed-strike array (see `nearest_strike`).--use std::collections::HashMap;-use std::fmt;-use std::num::NonZeroUsize;-use std::path::PathBuf;--use fontconfig::{CharSet, Fontconfig, Pattern};-use freetype::bitmap::PixelMode;-use freetype::face::{LoadFlag, StyleFlag};-use freetype::{Face, Library, Matrix, Vector};-use harfbuzz_rs_now as harfbuzz;-use lru::LruCache;-use thiserror::Error;--/// Upper bound on cached glyphs; the working set of a terminal is far smaller,-/// but this caps memory under adversarial all-of-Unicode output.-const GLYPH_CACHE_CAP: usize = 4096;--/// Upper bound on cached shaped clusters (base char + combining marks).-const SHAPE_CACHE_CAP: usize = 1024;--#[derive(Debug, Error)]-pub enum FontError {- #[error("FreeType: {0}")]- FreeType(#[from] freetype::Error),- #[error("could not initialize fontconfig")]- FontconfigInit,- #[error("fontconfig: {0}")]- Fontconfig(#[from] fontconfig::FontconfigError),- #[error("no font matched family {0:?}")]- NoFamily(String),- #[error("font {0:?} reports no size metrics")]- NoMetrics(String),-}--/// Bold/italic selection, used both to pick a face and to key the glyph cache.-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]-pub struct Style {- pub bold: bool,- pub italic: bool,-}--impl Style {- /// Dense index in `0..4` for array storage.- fn index(self) -> usize {- usize::from(self.bold) | (usize::from(self.italic) << 1)- }-- fn fontconfig_style(self) -> &'static str {- match (self.bold, self.italic) {- (false, false) => "Regular",- (true, false) => "Bold",- (false, true) => "Italic",- (true, true) => "Bold Italic",- }- }-}--/// Fixed cell geometry in pixels, derived from the primary face.-#[derive(Clone, Copy, Debug)]-pub struct CellMetrics {- pub width: u32,- pub height: u32,- /// Baseline offset from the top of the cell.- pub ascent: u32,-}--/// A rasterized glyph: its bitmap plus the offsets to place it on the baseline.-#[derive(Clone, Debug)]-pub struct Glyph {- /// Horizontal offset from the pen position to the bitmap's left edge.- pub left: i32,- /// Vertical offset from the baseline up to the bitmap's top edge.- pub top: i32,- pub width: u32,- pub height: u32,- pub data: GlyphData,-}--/// Glyph pixel data. A `Mask` is tinted with the cell's foreground colour; a-/// `Color` bitmap (emoji) is composited directly.-#[derive(Clone, Debug)]-pub enum GlyphData {- /// One coverage byte per pixel.- Mask(Vec<u8>),- /// Pre-multiplied BGRA, four bytes per pixel.- Color(Vec<u8>),-}--/// One shaped glyph in a cluster: a glyph index into a specific face plus the-/// pixel offset, relative to the cell origin and baseline, that HarfBuzz placed-/// it at. `x` grows rightward, `y` upward (away from the baseline).-#[derive(Clone, Copy, Debug)]-pub struct Placed {- pub gid: u32,- pub x: i32,- pub y: i32,-}--/// The result of shaping a base char plus its combining marks: the face the-/// cluster was shaped against and the positioned glyphs to draw, in order.-#[derive(Clone, Debug)]-pub struct ShapedCluster {- pub face_idx: usize,- pub glyphs: Vec<Placed>,-}--/// A loaded face plus where it came from, so HarfBuzz can be handed the same-/// font bytes that FreeType rasterizes from.-struct FaceEntry {- face: Face,- path: PathBuf,- index: u32,- /// HarfBuzz font for this face, built on first shape against it.- hb: Option<harfbuzz::Owned<harfbuzz::Font<'static>>>,-}--/// The font set for one terminal: a primary family with lazily-loaded-/// bold/italic variants and per-codepoint fallback faces, plus glyph caches.-pub struct Fonts {- library: Library,- fontconfig: Fontconfig,- family: String,- size_px: u32,- metrics: CellMetrics,- /// All loaded faces; indices into this vector are stable.- faces: Vec<FaceEntry>,- /// Index of each style variant, by [`Style::index`]; filled on demand.- styled: [Option<usize>; 4],- /// Fallback faces resolved by coverage, deduplicated by file path.- fallbacks: HashMap<PathBuf, usize>,- /// Glyphs keyed by `char` (the common, unshaped path).- cache: LruCache<(char, usize), Glyph>,- /// Glyphs keyed by `(glyph index, face, style)` (the shaped path).- gcache: LruCache<(u32, usize, usize), Glyph>,- /// Shaped clusters keyed by `(cluster string, style)`.- shape_cache: LruCache<(Box<str>, usize), Option<ShapedCluster>>,-}--impl fmt::Debug for Fonts {- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {- f.debug_struct("Fonts")- .field("family", &self.family)- .field("size_px", &self.size_px)- .field("metrics", &self.metrics)- .field("faces", &self.faces.len())- .field("cached", &self.cache.len())- .finish()- }-}--impl Fonts {- /// Resolve `family` at `size_px` and compute the cell metrics.- pub fn new(family: &str, size_px: u32) -> Result<Self, FontError> {- let library = Library::init()?;- let fontconfig = Fontconfig::new().ok_or(FontError::FontconfigInit)?;-- let regular = resolve_face(&library, &fontconfig, family, Style::default(), size_px)?;- let metrics = cell_metrics(®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");- }-}@@ -1,136 +0,0 @@-use std::num::NonZeroU16;--use super::*;--/// A URL detected in the visible viewport, with the `(row, col)` of its first-/// character in viewport coordinates.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct UrlHit {- pub url: String,- pub row: usize,- pub col: usize,-}--/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the-/// delimiters that conventionally bound a URL in flowing text).-fn is_url_char(c: char) -> bool {- !c.is_whitespace()- && !c.is_control()- && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')-}--/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.-/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs-/// to the first non-URL character, with trailing sentence punctuation trimmed.-fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {- let mut out = Vec::new();- let mut i = 0;- while i + 2 < chars.len() {- if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {- // Backtrack over the scheme.- let mut start = i;- while start > 0 {- let c = chars[start - 1];- if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {- start -= 1;- } else {- break;- }- }- if start < i && chars[start].is_ascii_alphabetic() {- let mut end = i + 3;- while end < chars.len() && is_url_char(chars[end]) {- end += 1;- }- // Trim trailing punctuation that is usually sentence-level.- while end > i + 3- && matches!(- chars[end - 1],- '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'- )- {- end -= 1;- }- if end > i + 3 {- out.push((start, end));- i = end;- continue;- }- }- }- i += 1;- }- out-}--impl Grid {- /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An- /// empty/`None` URI ends the current link.- pub fn set_link(&mut self, uri: Option<&str>) {- self.pen.link = match uri {- Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),- _ => None,- };- }-- /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table- /// is capped so a pathological stream cannot grow it without bound).- fn intern_link(&mut self, uri: &str) -> NonZeroU16 {- if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {- return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");- }- // u16::MAX distinct links is far past any real document; reuse the last- // slot once saturated rather than overflow the id space.- if self.links.len() < usize::from(u16::MAX) - 1 {- self.links.push(uri.into());- } else {- *self.links.last_mut().expect("table is non-empty when full") = uri.into();- }- NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")- }-- /// The URI for a hyperlink id, if it is still in the table.- pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {- self.links- .get(usize::from(id.get()) - 1)- .map(|s| s.as_ref())- }-- /// The hyperlink id of the cell at an absolute `(row, col)`, if any.- pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {- self.abs_row(abs_row).get(col).and_then(|c| c.link)- }- /// Detect plain-text URLs across the visible viewport, returning each with- /// the viewport `(row, col)` of its first character. Soft-wrapped rows are- /// joined so a URL split across a wrap is found whole; hard line breaks end- /// a URL.- pub fn visible_urls(&self) -> Vec<UrlHit> {- let mut chars: Vec<char> = Vec::new();- let mut pos: Vec<(usize, usize)> = Vec::new();- for y in 0..self.rows {- let line = self.line_at_abs(self.view_to_abs(y));- for (x, cell) in line.cells.iter().enumerate() {- if cell.flags.contains(Flags::WIDE_CONT) {- continue;- }- chars.push(cell.c);- pos.push((y, x));- }- if !line.wrapped {- chars.push('\n'); // a hard break terminates any URL- pos.push((y, usize::MAX));- }- }- find_urls(&chars)- .into_iter()- .map(|(s, e)| {- let (row, col) = pos[s];- UrlHit {- url: chars[s..e].iter().collect(),- row,- col,- }- })- .collect()- }-}@@ -1,1515 +0,0 @@-//! The terminal screen: a grid of styled cells, a cursor, and the editing-//! operations the VT parser drives.--mod links;-mod search;-mod selection;--use std::collections::VecDeque;-use std::num::NonZeroU16;--use unicode_width::UnicodeWidthChar;--pub use links::UrlHit;-use search::SearchState;--/// Maximum scrollback lines retained for the main screen.-const SCROLLBACK_CAP: usize = 10_000;--/// A cell colour: terminal default, a palette index, or direct RGB.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Color {- #[default]- Default,- Indexed(u8),- Rgb(u8, u8, u8),-}--/// Per-cell style flags, packed into a `u16`.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub struct Flags(u16);--impl Flags {- pub const BOLD: Self = Self(1 << 0);- pub const DIM: Self = Self(1 << 1);- pub const ITALIC: Self = Self(1 << 2);- pub const BLINK: Self = Self(1 << 4);- pub const REVERSE: Self = Self(1 << 5);- pub const HIDDEN: Self = Self(1 << 6);- pub const STRIKE: Self = Self(1 << 7);- /// Trailing column of a double-width glyph; holds no character of its own.- pub const WIDE_CONT: Self = Self(1 << 8);- pub const OVERLINE: Self = Self(1 << 9);-- pub const fn empty() -> Self {- Self(0)- }-- pub const fn union(self, other: Self) -> Self {- Self(self.0 | other.0)- }-- pub fn contains(self, other: Self) -> bool {- self.0 & other.0 == other.0- }-- pub fn insert(&mut self, other: Self) {- self.0 |= other.0;- }-- pub fn remove(&mut self, other: Self) {- self.0 &= !other.0;- }-}--/// Underline style (SGR 4 / 4:x / 21).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Underline {- #[default]- None,- Single,- Double,- Curly,- Dotted,- Dashed,-}--/// Cursor shape (DECSCUSR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum CursorShape {- #[default]- Block,- Underline,- Beam,-}--/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseProtocol {- /// No reporting; the pointer drives local selection/scroll.- #[default]- Off,- /// X10 (9): button presses only.- X10,- /// Normal (1000): button press and release.- Normal,- /// Button-event (1002): press, release, and motion while a button is held.- Button,- /// Any-event (1003): press, release, and all pointer motion.- Any,-}--/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseEncoding {- /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).- #[default]- X10,- /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).- Utf8,- /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).- Sgr,-}--/// One grid cell: a character plus its rendering style.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct Cell {- pub c: char,- pub fg: Color,- pub bg: Color,- pub flags: Flags,- pub underline: Underline,- /// Underline colour; `Default` means "follow the foreground".- pub underline_color: Color,- /// Zero-width combining marks attached to `c`, in arrival order. `None` for- /// the common case; the renderer stacks each over the base glyph. This is- /// the grapheme cluster a future shaper (HarfBuzz) would consume.- pub combining: Option<Box<str>>,- /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.- pub link: Option<NonZeroU16>,-}--impl Default for Cell {- fn default() -> Self {- Self {- c: ' ',- fg: Color::Default,- bg: Color::Default,- flags: Flags::empty(),- underline: Underline::None,- underline_color: Color::Default,- combining: None,- link: None,- }- }-}--#[derive(Clone, Copy, Debug, Default)]-struct Cursor {- x: usize,- y: usize,-}--/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,-/// the start of typed command input, the start of command output, or the line-/// where the command finished.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum PromptKind {- PromptStart,- CmdStart,- OutputStart,- CmdEnd,-}--/// One screen/scrollback row: its cells plus whether it soft-wrapped into the-/// next row (autowrap continuation, as opposed to a hard line break). The flag-/// is what lets resize rejoin and rewrap paragraphs.-#[derive(Clone, PartialEq, Eq, Debug)]-struct Line {- cells: Vec<Cell>,- wrapped: bool,- /// OSC 133 mark attached to this (logical) line, if any.- prompt: Option<PromptKind>,-}--impl Line {- fn blank(cols: usize) -> Self {- Self {- cells: vec![Cell::default(); cols],- wrapped: false,- prompt: None,- }- }-}--/// A point in the combined scrollback+live coordinate space: `row` indexes-/// scrollback lines first (oldest at 0), then the live screen.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub struct Point {- pub row: usize,- pub col: usize,-}--/// The active screen plus cursor, scroll region, and current pen.-#[derive(Debug)]-pub struct Grid {- cols: usize,- rows: usize,- lines: Vec<Line>,- cursor: Cursor,- saved: Cursor,- /// Inclusive top/bottom rows of the scroll region.- top: usize,- bottom: usize,- /// Template cell carrying the current SGR colours/flags.- pen: Cell,- autowrap: bool,- origin: bool,- insert: bool,- /// Cursor parked past the last column, awaiting the next print to wrap.- wrap_pending: bool,- tabs: Vec<bool>,- /// Saved primary screen while the alternate screen is active.- alt_saved: Option<Vec<Line>>,- /// Lines that have scrolled off the top of the main screen, newest last.- scrollback: VecDeque<Line>,- /// How many lines the viewport is scrolled back from the live bottom.- view_offset: usize,- cursor_shape: CursorShape,- /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).- cursor_blink: bool,- cursor_visible: bool,- /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.- app_cursor: bool,- /// Cursor colour from OSC 12; `None` follows the cell under the cursor.- cursor_color: Option<(u8, u8, u8)>,- /// Active mouse selection as (anchor, head) in absolute coordinates.- selection: Option<(Point, Point)>,- /// Whether the selection is a rectangular block rather than linear flow.- selection_block: bool,- /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.- bracketed_paste: bool,- /// Synchronized output (DECSET 2026): hold presentation while a frame is- /// being assembled, so the screen never shows a half-drawn update.- sync: bool,- /// Which mouse events the application wants reported.- mouse_protocol: MouseProtocol,- /// Wire framing for those reports.- mouse_encoding: MouseEncoding,- /// Focus in/out reporting (DECSET 1004).- focus_events: bool,- /// Active incremental scrollback search, if any.- search: Option<SearchState>,- /// Characters that break a word for double-click selection.- word_delimiters: String,- /// History retention cap for the main screen.- scrollback_cap: usize,- /// Position of the last printed base cell, so a following zero-width- /// combining mark can attach to it.- last_base: Option<(usize, usize)>,- /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.- links: Vec<Box<str>>,- /// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode).- kitty_current: u8,- /// Saved flag values for the kitty push/pop stack.- kitty_stack: Vec<u8>,-}--fn default_tabs(cols: usize) -> Vec<bool> {- (0..cols).map(|i| i % 8 == 0 && i != 0).collect()-}--/// Default characters that terminate a word for double-click selection (the-/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`-/// are deliberately *not* delimiters so paths, URLs, and option flags select as-/// one unit.-const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";--/// Mask of the kitty-keyboard flags we implement (disambiguate, report events,-/// alternate keys, all-keys-as-escapes, associated text).-const KITTY_ALL: u8 = 0b1_1111;--/// Whether `c` is part of a word (not whitespace, not in `delims`).-fn is_word(c: char, delims: &str) -> bool {- !c.is_whitespace() && !delims.contains(c)-}--impl Grid {- pub fn new(cols: usize, rows: usize) -> Self {- let cols = cols.max(1);- let rows = rows.max(1);- Self {- cols,- rows,- lines: vec![Line::blank(cols); rows],- cursor: Cursor::default(),- saved: Cursor::default(),- top: 0,- bottom: rows - 1,- pen: Cell::default(),- autowrap: true,- origin: false,- insert: false,- wrap_pending: false,- tabs: default_tabs(cols),- alt_saved: None,- scrollback: VecDeque::new(),- view_offset: 0,- cursor_shape: CursorShape::default(),- cursor_blink: false,- cursor_visible: true,- cursor_color: None,- app_cursor: false,- selection: None,- selection_block: false,- bracketed_paste: false,- sync: false,- mouse_protocol: MouseProtocol::Off,- mouse_encoding: MouseEncoding::X10,- focus_events: false,- search: None,- word_delimiters: WORD_DELIMITERS.to_string(),- scrollback_cap: SCROLLBACK_CAP,- last_base: None,- links: Vec::new(),- kitty_current: 0,- kitty_stack: Vec::new(),- }- }-- /// The active kitty-keyboard flags (0 means legacy encoding).- pub fn kitty_flags(&self) -> u8 {- self.kitty_current- }-- /// Apply `CSI = flags ; mode u`: mode 1 replaces, 2 sets bits, 3 clears bits.- pub fn kitty_set(&mut self, flags: u8, mode: u8) {- self.kitty_current = match mode {- 2 => self.kitty_current | flags,- 3 => self.kitty_current & !flags,- _ => flags,- } & KITTY_ALL;- }-- /// Apply `CSI > flags u`: push the current flags and switch to `flags`.- pub fn kitty_push(&mut self, flags: u8) {- if self.kitty_stack.len() >= 32 {- self.kitty_stack.remove(0);- }- self.kitty_stack.push(self.kitty_current);- self.kitty_current = flags & KITTY_ALL;- }-- /// Apply `CSI < n u`: pop `n` saved flag values, restoring the last one.- pub fn kitty_pop(&mut self, n: usize) {- for _ in 0..n.max(1) {- self.kitty_current = self.kitty_stack.pop().unwrap_or(0);- }- }-- /// Override the word-delimiter set; `None` keeps the built-in default.- pub fn set_word_delimiters(&mut self, delims: Option<String>) {- if let Some(d) = delims {- self.word_delimiters = d;- }- }-- /// Set the scrollback retention cap, trimming history if it shrank.- pub fn set_scrollback_cap(&mut self, cap: usize) {- self.scrollback_cap = cap;- while self.scrollback.len() > cap {- self.scrollback.pop_front();- }- self.view_offset = self.view_offset.min(self.scrollback.len());- }-- pub fn cols(&self) -> usize {- self.cols- }-- pub fn rows(&self) -> usize {- self.rows- }-- /// Resize the screen. On the main screen this reflows: soft-wrapped runs are- /// rejoined into logical lines and rewrapped to the new width, across both- /// scrollback and the live screen, keeping the cursor on its content. The- /// alternate screen just clips, since its apps repaint on resize.- pub fn resize(&mut self, cols: usize, rows: usize) {- let cols = cols.max(1);- let rows = rows.max(1);- if self.alt_saved.is_some() {- self.clip_resize(cols, rows);- } else {- self.reflow_resize(cols, rows);- }- self.cols = cols;- self.rows = rows;- self.top = 0;- self.bottom = rows - 1;- self.tabs = default_tabs(cols);- self.cursor.x = self.cursor.x.min(cols - 1);- self.cursor.y = self.cursor.y.min(rows - 1);- self.wrap_pending = false;- self.view_offset = 0;- }-- /// Clip-resize the live screen and the saved primary (alternate screen).- fn clip_resize(&mut self, cols: usize, rows: usize) {- for line in &mut self.lines {- line.cells.resize(cols, Cell::default());- }- self.lines.resize(rows, Line::blank(cols));- if let Some(saved) = self.alt_saved.as_mut() {- for line in saved.iter_mut() {- line.cells.resize(cols, Cell::default());- }- saved.resize(rows, Line::blank(cols));- }- self.clear_selection();- self.clear_search();- }-- /// Reflow scrollback + live content to a new width, rewrapping soft-wrapped- /// paragraphs and repositioning the cursor onto its character.- fn reflow_resize(&mut self, cols: usize, rows: usize) {- let cursor_abs = self.scrollback.len() + self.cursor.y;- let total = self.scrollback.len() + self.lines.len();-- // 1. Rejoin soft-wrapped rows into logical lines. Track which logical- // line the cursor falls in and its offset within that line.- let mut logicals: Vec<Vec<Cell>> = Vec::new();- let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();- let mut acc: Vec<Cell> = Vec::new();- let mut acc_mark: Option<PromptKind> = None;- let mut cur_logical = 0usize;- let mut cur_off = 0usize;- for abs in 0..total {- if abs == cursor_abs {- cur_logical = logicals.len();- cur_off = acc.len() + self.cursor.x;- }- let line = if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- };- // The mark on the first physical row of a logical line carries over.- if acc.is_empty() {- acc_mark = line.prompt;- }- acc.extend_from_slice(&line.cells);- if !line.wrapped {- logicals.push(std::mem::take(&mut acc));- logical_marks.push(acc_mark.take());- }- }- if !acc.is_empty() {- logicals.push(acc);- logical_marks.push(acc_mark);- }- // Drop trailing all-blank lines (empty screen below the content), but- // never above the cursor's line, so the cursor keeps its row.- let last_content = logicals- .iter()- .rposition(|l| l.iter().any(|c| *c != Cell::default()))- .unwrap_or(0);- logicals.truncate(last_content.max(cur_logical) + 1);- logical_marks.truncate(last_content.max(cur_logical) + 1);-- // 2. Rewrap each logical line to the new width, recording where the- // cursor lands. Trailing blanks are dropped so a hard line does not- // rewrap its padding onto extra rows.- let mut new_lines: Vec<Line> = Vec::new();- let mut new_cursor_abs = 0usize;- let mut new_cursor_col = 0usize;- for (li, mut logical) in logicals.into_iter().enumerate() {- let trim = logical- .iter()- .rposition(|c| *c != Cell::default())- .map_or(0, |p| p + 1);- logical.truncate(trim);- let first = new_lines.len();- let chunks = logical.len().div_ceil(cols).max(1);- for ci in 0..chunks {- let start = ci * cols;- let end = (start + cols).min(logical.len());- let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();- cells.resize(cols, Cell::default());- new_lines.push(Line {- cells,- wrapped: ci + 1 < chunks,- // The mark belongs to the first physical row of the line.- prompt: if ci == 0 { logical_marks[li] } else { None },- });- }- if li == cur_logical {- let off = cur_off.min(logical.len());- let chunk = (off / cols).min(chunks - 1);- new_cursor_abs = first + chunk;- new_cursor_col = (off - chunk * cols).min(cols - 1);- }- }-- // 3. The last `rows` lines are the live screen; the rest is scrollback.- let live_start = new_lines.len().saturating_sub(rows);- let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();- let mut live = new_lines;- while live.len() < rows {- live.push(Line::blank(cols));- }- while scrollback.len() > self.scrollback_cap {- scrollback.pop_front();- }-- self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);- self.cursor.x = new_cursor_col;- self.lines = live;- self.scrollback = scrollback;- self.clear_selection();- self.clear_search();- }-- pub fn cursor(&self) -> (usize, usize) {- (self.cursor.x, self.cursor.y)- }-- // --- pen / attributes ----- pub fn pen_mut(&mut self) -> &mut Cell {- &mut self.pen- }-- pub fn reset_pen(&mut self) {- self.pen = Cell::default();- }-- pub fn set_autowrap(&mut self, on: bool) {- self.autowrap = on;- }-- pub fn set_origin(&mut self, on: bool) {- self.origin = on;- self.move_to(0, 0);- }-- pub fn set_insert(&mut self, on: bool) {- self.insert = on;- }-- pub fn autowrap(&self) -> bool {- self.autowrap- }-- pub fn origin(&self) -> bool {- self.origin- }-- pub fn insert(&self) -> bool {- self.insert- }-- pub fn alt_active(&self) -> bool {- self.alt_saved.is_some()- }-- pub fn set_cursor_shape(&mut self, shape: CursorShape) {- self.cursor_shape = shape;- }-- pub fn cursor_shape(&self) -> CursorShape {- self.cursor_shape- }-- pub fn set_cursor_blink(&mut self, blink: bool) {- self.cursor_blink = blink;- }-- pub fn cursor_blink(&self) -> bool {- self.cursor_blink- }-- pub fn set_cursor_visible(&mut self, visible: bool) {- self.cursor_visible = visible;- }-- pub fn cursor_visible(&self) -> bool {- self.cursor_visible- }-- pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {- self.cursor_color = color;- }-- pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {- self.cursor_color- }-- pub fn set_app_cursor(&mut self, on: bool) {- self.app_cursor = on;- }-- pub fn app_cursor(&self) -> bool {- self.app_cursor- }-- // --- printing ----- /// Place a printable character at the cursor, honouring width and autowrap.- pub fn print(&mut self, c: char) {- let width = c.width().unwrap_or(0);- if width == 0 {- // A zero-width combining mark attaches to the last base cell.- self.add_combining(c);- return;- }- if self.wrap_pending {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- self.wrap_pending = false;- }- if width == 2 && self.cursor.x + 1 >= self.cols {- // A double-width glyph cannot straddle the right edge: wrap first.- if self.autowrap {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- } else {- return;- }- }- if self.insert {- self.shift_right(width);- }-- let (x, y) = (self.cursor.x, self.cursor.y);- let mut cell = self.pen.clone();- cell.c = c;- cell.combining = None;- cell.flags.remove(Flags::WIDE_CONT);- self.lines[y].cells[x] = cell;- self.last_base = Some((x, y));- if width == 2 && x + 1 < self.cols {- let mut cont = self.pen.clone();- cont.c = ' ';- cont.combining = None;- cont.flags.insert(Flags::WIDE_CONT);- self.lines[y].cells[x + 1] = cont;- }-- let advance = width;- if self.cursor.x + advance >= self.cols {- if self.autowrap {- self.cursor.x = self.cols - 1;- self.wrap_pending = true;- } else {- self.cursor.x = self.cols - 1;- }- } else {- self.cursor.x += advance;- }- }-- /// Attach a zero-width combining mark to the most recently printed base- /// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.- fn add_combining(&mut self, mark: char) {- const MAX_MARKS: usize = 8;- let Some((x, y)) = self.last_base else {- return;- };- let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {- return;- };- let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();- if s.chars().count() < MAX_MARKS {- s.push(mark);- }- cell.combining = Some(s.into_boxed_str());- }-- fn shift_right(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let end = self.cols;- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in (x + n..end).rev() {- row[i] = row[i - n].clone();- }- for cell in &mut row[x..(x + n).min(end)] {- *cell = blank.clone();- }- }-- fn pen_blank(&self) -> Cell {- // A space carrying only the current background (back-colour erase).- Cell {- bg: self.pen.bg,- ..Cell::default()- }- }-- // --- cursor movement ----- fn region(&self) -> (usize, usize) {- if self.origin {- (self.top, self.bottom)- } else {- (0, self.rows - 1)- }- }-- pub fn move_to(&mut self, x: usize, y: usize) {- let (rt, rb) = self.region();- self.cursor.x = x.min(self.cols - 1);- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn move_to_col(&mut self, x: usize) {- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn move_to_row(&mut self, y: usize) {- let (rt, rb) = self.region();- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_up(&mut self, n: usize) {- let (rt, _) = self.region();- self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_down(&mut self, n: usize) {- let (_, rb) = self.region();- self.cursor.y = (self.cursor.y + n).min(rb);- self.wrap_pending = false;- }-- pub fn cursor_fwd(&mut self, n: usize) {- self.cursor.x = (self.cursor.x + n).min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn cursor_back(&mut self, n: usize) {- self.cursor.x = self.cursor.x.saturating_sub(n);- self.wrap_pending = false;- }-- pub fn save_cursor(&mut self) {- self.saved = self.cursor;- }-- pub fn restore_cursor(&mut self) {- self.cursor = self.saved;- self.cursor.x = self.cursor.x.min(self.cols - 1);- self.cursor.y = self.cursor.y.min(self.rows - 1);- self.wrap_pending = false;- }-- // --- line discipline ----- pub fn carriage_return(&mut self) {- self.cursor.x = 0;- self.wrap_pending = false;- }-- pub fn backspace(&mut self) {- self.cursor.x = self.cursor.x.saturating_sub(1);- self.wrap_pending = false;- }-- /// LF/VT/FF: move down one row, scrolling at the region bottom.- pub fn line_feed(&mut self) {- if self.cursor.y == self.bottom {- self.scroll_up(1);- } else if self.cursor.y < self.rows - 1 {- self.cursor.y += 1;- }- self.wrap_pending = false;- }-- /// RI: move up one row, scrolling down at the region top.- pub fn reverse_index(&mut self) {- if self.cursor.y == self.top {- self.scroll_down(1);- } else if self.cursor.y > 0 {- self.cursor.y -= 1;- }- self.wrap_pending = false;- }-- /// NEL: carriage return plus line feed.- pub fn next_line(&mut self) {- self.carriage_return();- self.line_feed();- }-- // --- tab stops ----- pub fn tab(&mut self) {- let mut x = self.cursor.x + 1;- while x < self.cols && !self.tabs[x] {- x += 1;- }- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn set_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = true;- }- }-- pub fn clear_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = false;- }- }-- pub fn clear_all_tabs(&mut self) {- self.tabs.iter_mut().for_each(|t| *t = false);- }-- // --- scrolling within the region ----- pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {- if top < bottom && bottom < self.rows {- self.top = top;- self.bottom = bottom;- } else {- self.top = 0;- self.bottom = self.rows - 1;- }- self.move_to(0, 0);- }-- pub fn scroll_up(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- // Lines leaving the top of the *whole* main screen become scrollback;- // a DECSTBM region scroll (top > 0) or the alt screen does not.- if self.top == 0 && self.alt_saved.is_none() {- for y in 0..n {- let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));- self.scrollback.push_back(line);- }- let mut evicted = 0;- while self.scrollback.len() > self.scrollback_cap {- self.scrollback.pop_front();- evicted += 1;- }- if evicted > 0 {- self.shift_selection(evicted);- self.shift_search(evicted);- }- // Keep a scrolled-back viewport anchored to the same content.- if self.view_offset > 0 {- self.view_offset = (self.view_offset + n).min(self.scrollback.len());- }- }- for y in self.top..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- pub fn scroll_down(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- for y in (self.top..=self.bottom).rev() {- if y >= self.top + n {- self.lines.swap(y, y - n);- }- }- for y in self.top..(self.top + n) {- self.blank_row(y);- }- }-- fn blank_row(&mut self, y: usize) {- let blank = self.pen_blank();- let line = &mut self.lines[y];- for cell in &mut line.cells {- *cell = blank.clone();- }- line.wrapped = false;- }-- // --- erase ----- /// ED: 0=below, 1=above, 2/3=all.- pub fn erase_display(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => {- self.erase_in_row(y, x, self.cols);- for r in (y + 1)..self.rows {- self.blank_row(r);- }- }- 1 => {- for r in 0..y {- self.blank_row(r);- }- self.erase_in_row(y, 0, x + 1);- }- _ => {- for r in 0..self.rows {- self.blank_row(r);- }- }- }- self.wrap_pending = false;- }-- /// EL: 0=right, 1=left, 2=line.- pub fn erase_line(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => self.erase_in_row(y, x, self.cols),- 1 => self.erase_in_row(y, 0, x + 1),- _ => self.erase_in_row(y, 0, self.cols),- }- self.wrap_pending = false;- }-- /// ECH: erase n characters from the cursor without moving it.- pub fn erase_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- self.erase_in_row(y, x, (x + n).min(self.cols));- }-- fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {- let blank = self.pen_blank();- for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {- *cell = blank.clone();- }- }-- // --- intra-line editing ----- /// ICH: insert n blanks at the cursor, shifting the rest right.- pub fn insert_chars(&mut self, n: usize) {- let saved = self.insert;- self.insert = true;- self.shift_right(n.min(self.cols));- self.insert = saved;- }-- /// DCH: delete n characters at the cursor, shifting the rest left.- pub fn delete_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let n = n.min(self.cols - x);- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in x..self.cols {- row[i] = if i + n < self.cols {- row[i + n].clone()- } else {- blank.clone()- };- }- }-- /// IL: insert n blank lines at the cursor row, within the scroll region.- pub fn insert_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in (self.cursor.y..=self.bottom).rev() {- if y >= self.cursor.y + n {- self.lines.swap(y, y - n);- }- }- for y in self.cursor.y..(self.cursor.y + n) {- self.blank_row(y);- }- }-- /// DL: delete n lines at the cursor row, within the scroll region.- pub fn delete_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in self.cursor.y..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- // --- alternate screen ----- pub fn enter_alt_screen(&mut self) {- if self.alt_saved.is_some() {- return;- }- self.view_offset = 0;- let blank = vec![Line::blank(self.cols); self.rows];- self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));- }-- pub fn leave_alt_screen(&mut self) {- if let Some(main) = self.alt_saved.take() {- self.lines = main;- }- }-- // --- scrollback viewport ----- /// Scroll the viewport by `delta` lines: positive = back into history,- /// negative = toward the live screen. No-op on the alternate screen.- pub fn scroll_view(&mut self, delta: isize) {- if self.alt_saved.is_some() {- return;- }- let max = self.scrollback.len() as isize;- self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;- }-- pub fn scroll_to_bottom(&mut self) {- self.view_offset = 0;- }-- /// Whether the viewport is showing the live screen (not scrolled back).- pub fn view_at_bottom(&self) -> bool {- self.view_offset == 0- }-- /// One page (a screenful) of lines, for page-scroll bindings.- pub fn page(&self) -> usize {- self.rows.max(1)- }-- /// The cells shown at viewport row `y` (0 = top of the window), accounting- /// for the scrollback offset. May differ from `cols` in width if the line- /// predates a resize (no reflow yet), so callers must not assume length.- pub fn view_row(&self, y: usize) -> &[Cell] {- let start = self.scrollback.len() - self.view_offset;- let idx = start + y;- if idx < self.scrollback.len() {- &self.scrollback[idx].cells- } else {- &self.lines[idx - self.scrollback.len()].cells- }- }-- // --- selection ----- /// The absolute row currently shown at viewport row `y`.- pub fn view_to_abs(&self, y: usize) -> usize {- self.scrollback.len() - self.view_offset + y- }-- /// The line at an absolute row (scrollback first, then the live screen).- fn line_at_abs(&self, abs: usize) -> &Line {- if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- }- }-- /// Cells of an absolute row (scrollback first, then the live screen).- fn abs_row(&self, row: usize) -> &[Cell] {- if row < self.scrollback.len() {- &self.scrollback[row].cells- } else {- &self.lines[row - self.scrollback.len()].cells- }- }-- /// Total rows across scrollback and the live screen.- fn total_lines(&self) -> usize {- self.scrollback.len() + self.lines.len()- }-- /// The OSC 133 mark on an absolute row, if any.- fn abs_prompt(&self, row: usize) -> Option<PromptKind> {- if row < self.scrollback.len() {- self.scrollback[row].prompt- } else {- self.lines- .get(row - self.scrollback.len())- .and_then(|l| l.prompt)- }- }-- // --- shell integration (OSC 133) ----- /// Attach an OSC 133 prompt mark to the live line under the cursor.- pub fn set_prompt_mark(&mut self, kind: PromptKind) {- let y = self.cursor.y;- if let Some(line) = self.lines.get_mut(y) {- line.prompt = Some(kind);- }- }-- /// Scroll the viewport to the previous (`up`) or next prompt, placing that- /// prompt line at the top of the window. No-op on the alternate screen or- /// when there is no prompt in that direction.- pub fn jump_prompt(&mut self, up: bool) {- if self.alt_saved.is_some() {- return;- }- let top = self.scrollback.len().saturating_sub(self.view_offset);- let total = self.total_lines();- let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);- let target = if up {- (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))- } else {- ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))- };- if let Some(t) = target {- let offset = self.scrollback.len() as isize - t as isize;- self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;- }- }-- /// Text of the most recent command's output: the rows from the last- /// output-start (OSC 133 C) up to the command-end (D) or next prompt.- pub fn last_command_output(&self) -> Option<String> {- let total = self.total_lines();- let start = (0..total)- .rev()- .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;- let mut lines: Vec<String> = Vec::new();- for r in start..total {- if r > start- && matches!(- self.abs_prompt(r),- Some(PromptKind::CmdEnd | PromptKind::PromptStart)- )- {- break;- }- lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());- }- // Drop trailing blank rows (e.g. the empty live screen below the output).- while lines.last().is_some_and(|l| l.is_empty()) {- lines.pop();- }- let mut out = lines.join("\n");- out.push('\n');- Some(out)- }-- pub fn set_bracketed_paste(&mut self, on: bool) {- self.bracketed_paste = on;- }-- pub fn bracketed_paste(&self) -> bool {- self.bracketed_paste- }-- pub fn set_sync(&mut self, on: bool) {- self.sync = on;- }-- pub fn sync_active(&self) -> bool {- self.sync- }-- pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {- self.mouse_protocol = protocol;- }-- pub fn mouse_protocol(&self) -> MouseProtocol {- self.mouse_protocol- }-- pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {- self.mouse_encoding = encoding;- }-- pub fn mouse_encoding(&self) -> MouseEncoding {- self.mouse_encoding- }-- pub fn set_focus_events(&mut self, on: bool) {- self.focus_events = on;- }-- pub fn focus_events(&self) -> bool {- self.focus_events- }-- // --- inspection (logging + tests) ----- /// The visible text of one row, trailing blanks trimmed.- #[cfg(test)]- pub fn row_text(&self, y: usize) -> String {- self.lines[y]- .cells- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- .map(|c| c.c)- .collect::<String>()- .trim_end()- .to_string()- }-- pub fn cell(&self, x: usize, y: usize) -> &Cell {- &self.lines[y].cells[x]- }-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn kitty_flag_stack() {- let mut g = Grid::new(4, 2);- assert_eq!(g.kitty_flags(), 0);- g.kitty_set(0b1, 1); // replace- assert_eq!(g.kitty_flags(), 0b1);- g.kitty_set(0b100, 2); // set bits- assert_eq!(g.kitty_flags(), 0b101);- g.kitty_set(0b1, 3); // clear bits- assert_eq!(g.kitty_flags(), 0b100);- g.kitty_push(0b11); // push current, switch- assert_eq!(g.kitty_flags(), 0b11);- g.kitty_pop(1); // restore- assert_eq!(g.kitty_flags(), 0b100);- g.kitty_pop(5); // underflow is harmless- assert_eq!(g.kitty_flags(), 0);- }-- #[test]- fn prints_and_wraps() {- let mut g = Grid::new(4, 2);- for c in "abcde".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "e");- assert_eq!(g.cursor(), (1, 1));- }-- #[test]- fn combining_marks_attach_to_base_cell() {- let mut g = Grid::new(8, 1);- // "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the- // 'x' lands in the next cell (the mark advanced nothing).- for c in "e\u{0301}x".chars() {- g.print(c);- }- assert_eq!(g.cell(0, 0).c, 'e');- assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));- assert_eq!(g.cell(1, 0).c, 'x');- assert_eq!(g.cursor(), (2, 0));- // Copied text round-trips the full grapheme cluster.- g.start_selection(0, 0);- g.extend_selection(0, 1);- assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));- }-- #[test]- fn detects_urls_trimming_trailing_punctuation() {- let mut g = Grid::new(60, 2);- for c in "see https://example.com/p?q=1, ok".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/p?q=1");- assert_eq!((hits[0].row, hits[0].col), (0, 4));- }-- #[test]- fn detects_url_across_a_soft_wrap() {- // 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.- let mut g = Grid::new(20, 3);- for c in "x https://example.com/averylongpath".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/averylongpath");- }-- #[test]- fn prompt_mark_survives_reflow() {- let mut g = Grid::new(10, 4);- g.set_prompt_mark(PromptKind::OutputStart); // marks line 0- for c in "hello".chars() {- g.print(c);- }- g.resize(6, 4); // rewrap to a narrower width- // The mark followed its logical line, so the output is still found.- assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));- }-- #[test]- fn carriage_return_and_line_feed() {- let mut g = Grid::new(8, 4);- for c in "hi".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "yo".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "hi");- assert_eq!(g.row_text(1), "yo");- }-- #[test]- fn line_feed_scrolls_at_bottom() {- let mut g = Grid::new(4, 2);- g.print('a');- g.next_line();- g.print('b');- g.next_line(); // scrolls: row0 <- "b", row1 blank- assert_eq!(g.row_text(0), "b");- assert_eq!(g.row_text(1), "");- }-- #[test]- fn erase_line_to_right() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(2, 0);- g.erase_line(0);- assert_eq!(g.row_text(0), "ab");- }-- #[test]- fn delete_and_insert_chars() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(1, 0);- g.delete_chars(2);- assert_eq!(g.row_text(0), "adef");- g.move_to(1, 0);- g.insert_chars(2);- assert_eq!(g.row_text(0), "a def");- }-- #[test]- fn scrollback_captures_scrolled_lines() {- let mut g = Grid::new(8, 2);- for c in ['1', '2', '3'] {- g.print(c);- g.next_line();- }- // Live screen: newest line on top, cleared line below.- assert_eq!(g.view_row(0)[0].c, '3');- assert!(g.view_at_bottom());- // Scroll back to reveal the two captured lines.- g.scroll_view(2);- assert!(!g.view_at_bottom());- assert_eq!(g.view_row(0)[0].c, '1');- assert_eq!(g.view_row(1)[0].c, '2');- g.scroll_to_bottom();- assert_eq!(g.view_row(0)[0].c, '3');- }-- #[test]- fn wide_char_occupies_two_columns() {- let mut g = Grid::new(6, 1);- g.print('世');- g.print('x');- assert_eq!(g.cell(0, 0).c, '世');- assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));- assert_eq!(g.cell(2, 0).c, 'x');- }-- #[test]- fn selection_extracts_text_across_rows() {- let mut g = Grid::new(8, 2);- for c in "abcd".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "efgh".chars() {- g.print(c);- }- // Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).- g.start_selection(0, 2);- g.extend_selection(1, 1);- assert!(g.is_selected(0, 3));- assert!(g.is_selected(1, 0));- assert!(!g.is_selected(1, 2));- assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));- }-- #[test]- fn select_word_spans_one_word() {- let mut g = Grid::new(16, 1);- for c in "foo bar baz".chars() {- g.print(c);- }- g.select_word(0, 5); // inside "bar"- assert_eq!(g.selection_text().as_deref(), Some("bar"));- }-- #[test]- fn select_word_breaks_on_delimiters_but_keeps_paths() {- let mut g = Grid::new(32, 1);- for c in "run /usr/bin:next".chars() {- g.print(c);- }- // '/' and ':' are not delimiters, so the path selects whole.- g.select_word(0, 7); // inside "/usr/bin:next"- assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));- // '(' is a delimiter.- let mut g = Grid::new(16, 1);- for c in "f(arg)".chars() {- g.print(c);- }- g.select_word(0, 2); // inside "arg"- assert_eq!(g.selection_text().as_deref(), Some("arg"));- }-- #[test]- fn block_selection_is_rectangular() {- let mut g = Grid::new(8, 3);- for line in ["abcd", "efgh", "ijkl"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.- g.start_block_selection(0, 1);- g.extend_selection(2, 2);- assert!(g.is_selected(0, 1));- assert!(g.is_selected(2, 2));- assert!(!g.is_selected(1, 3));- assert!(!g.is_selected(1, 0));- assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));- }-- #[test]- fn search_finds_matches_across_history() {- let mut g = Grid::new(16, 2);- for line in ["alpha", "beta", "ALPHA", "gamma"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // Case-insensitive (no uppercase in query) matches both alphas.- g.set_search("alpha");- assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit- let spans0 = g.search_spans_on(0);- assert_eq!(spans0, vec![(0, 4, false)]);- // Smart case: an uppercase letter restricts to the exact-case hit.- g.set_search("ALPHA");- assert_eq!(g.search_count(), (1, 1));- assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);- // Stepping wraps and the no-match query reports zero.- g.set_search("zzz");- assert_eq!(g.search_count(), (0, 0));- assert_eq!(g.search_query(), Some("zzz"));- g.clear_search();- assert_eq!(g.search_query(), None);- }-- #[test]- fn reflow_rewraps_a_wrapped_paragraph() {- let mut g = Grid::new(4, 3);- for c in "abcdefgh".chars() {- g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)- }- // Widen: the two wrapped rows rejoin into one logical line.- g.resize(8, 3);- assert_eq!(g.row_text(0), "abcdefgh");- assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end- // Narrow back: it rewraps to the smaller width without losing text.- g.resize(4, 3);- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "efgh");- }-- #[test]- fn reflow_preserves_hard_breaks() {- let mut g = Grid::new(10, 3);- for c in "one".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "two".chars() {- g.print(c);- }- // A hard newline must not be rejoined when the width changes.- g.resize(6, 3);- assert_eq!(g.row_text(0), "one");- assert_eq!(g.row_text(1), "two");- }-- #[test]- fn scroll_region_limits_line_feed() {- let mut g = Grid::new(4, 4);- g.set_scroll_region(1, 2);- g.move_to(0, 2); // bottom of region (origin off: absolute row 2)- g.print('x');- g.move_to(0, 2);- g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1- assert_eq!(g.row_text(1), "x");- assert_eq!(g.row_text(2), "");- }-}@@ -1,148 +0,0 @@-use super::*;--/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct Match {- row: usize,- col: usize,- len: usize,-}--/// Incremental scrollback search: the query, every hit, and the focused one.-#[derive(Clone, Debug, Default)]-pub(super) struct SearchState {- query: String,- matches: Vec<Match>,- current: usize,-}--impl Grid {- // --- scrollback search ----- /// Set the search query and recompute matches over scrollback + the live- /// screen, focusing the most recent hit and scrolling it into view. An- /// empty query keeps search mode active but clears the hit list.- pub fn set_search(&mut self, query: &str) {- let matches = if query.is_empty() {- Vec::new()- } else {- self.compute_matches(query)- };- let current = matches.len().saturating_sub(1);- self.search = Some(SearchState {- query: query.to_string(),- matches,- current,- });- self.jump_to_current();- }-- /// Move the focused match `forward` (toward newer) or back (toward older),- /// wrapping, and scroll it into view.- pub fn search_step(&mut self, forward: bool) {- if let Some(s) = self.search.as_mut()- && !s.matches.is_empty()- {- let n = s.matches.len();- s.current = if forward {- (s.current + 1) % n- } else {- (s.current + n - 1) % n- };- }- self.jump_to_current();- }-- pub fn clear_search(&mut self) {- self.search = None;- }-- /// The current query, while search mode is active.- pub fn search_query(&self) -> Option<&str> {- self.search.as_ref().map(|s| s.query.as_str())- }-- /// `(focused match index 1-based, total matches)` for the search prompt.- pub fn search_count(&self) -> (usize, usize) {- self.search.as_ref().map_or((0, 0), |s| {- let total = s.matches.len();- (if total == 0 { 0 } else { s.current + 1 }, total)- })- }-- /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.- pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {- let Some(s) = self.search.as_ref() else {- return Vec::new();- };- s.matches- .iter()- .enumerate()- .filter(|(_, m)| m.row == row && m.len > 0)- .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))- .collect()- }-- fn compute_matches(&self, query: &str) -> Vec<Match> {- // Smart case: an uppercase letter in the query forces case sensitivity.- let sensitive = query.chars().any(|c| c.is_ascii_uppercase());- let fold = |c: char| {- if sensitive { c } else { c.to_ascii_lowercase() }- };- let needle: Vec<char> = query.chars().map(fold).collect();- if needle.is_empty() {- return Vec::new();- }- let total = self.scrollback.len() + self.rows;- let mut matches = Vec::new();- for row in 0..total {- let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();- let mut i = 0;- while i + needle.len() <= hay.len() {- if hay[i..i + needle.len()] == needle[..] {- matches.push(Match {- row,- col: i,- len: needle.len(),- });- i += needle.len();- } else {- i += 1;- }- }- }- matches- }-- /// Scroll the viewport so the focused match is on screen, centering it only- /// when it would otherwise be off the visible range.- fn jump_to_current(&mut self) {- let Some(abs) = self- .search- .as_ref()- .and_then(|s| s.matches.get(s.current))- .map(|m| m.row)- else {- return;- };- let sb = self.scrollback.len();- let top = sb - self.view_offset;- let bottom = top + self.rows - 1;- if abs < top || abs > bottom {- let target = sb as isize + self.rows as isize / 2 - abs as isize;- self.view_offset = target.clamp(0, sb as isize) as usize;- }- }-- /// Slide search hits up by `n` rows after scrollback eviction, dropping any- /// that scrolled off the top.- pub(super) fn shift_search(&mut self, n: usize) {- if let Some(s) = self.search.as_mut() {- s.matches.retain(|m| m.row >= n);- for m in &mut s.matches {- m.row -= n;- }- s.current = s.current.min(s.matches.len().saturating_sub(1));- }- }-}@@ -1,190 +0,0 @@-use super::*;--impl Grid {- /// Slide an active selection up by `n` rows after scrollback eviction,- /// dropping it if either endpoint scrolled off the top.- pub(super) fn shift_selection(&mut self, n: usize) {- if let Some((a, b)) = self.selection {- if a.row < n || b.row < n {- self.selection = None;- } else {- self.selection = Some((- Point {- row: a.row - n,- ..a- },- Point {- row: b.row - n,- ..b- },- ));- }- }- }-- pub fn clear_selection(&mut self) {- self.selection = None;- }-- /// Begin a linear selection at an absolute point (drag anchor).- pub fn start_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = false;- }-- /// Begin a rectangular (block) selection at an absolute point.- pub fn start_block_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = true;- }-- /// Move the selection head (drag), keeping the anchor fixed.- pub fn extend_selection(&mut self, row: usize, col: usize) {- if let Some((_, head)) = self.selection.as_mut() {- *head = Point { row, col };- }- }-- /// Select the word at an absolute point, breaking on whitespace and the- /// default delimiter set.- pub fn select_word(&mut self, row: usize, col: usize) {- let delims = &self.word_delimiters;- let line = self.abs_row(row);- if col >= line.len() || !is_word(line[col].c, delims) {- self.start_selection(row, col);- return;- }- let mut lo = col;- while lo > 0 && is_word(line[lo - 1].c, delims) {- lo -= 1;- }- let mut hi = col;- while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {- hi += 1;- }- self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));- self.selection_block = false;- }-- /// Select the whole line at an absolute row.- pub fn select_line(&mut self, row: usize) {- let last = self.abs_row(row).len().saturating_sub(1);- self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));- self.selection_block = false;- }-- /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block- /// selection.- fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {- let (a, b) = self.selection?;- Some((- a.row.min(b.row),- a.row.max(b.row),- a.col.min(b.col),- a.col.max(b.col),- ))- }-- /// Normalized selection (start <= end in reading order), if any.- fn ordered_selection(&self) -> Option<(Point, Point)> {- self.selection.map(|(a, b)| {- if (a.row, a.col) <= (b.row, b.col) {- (a, b)- } else {- (b, a)- }- })- }-- /// Whether the cell at an absolute `(row, col)` falls inside the selection.- pub fn is_selected(&self, row: usize, col: usize) -> bool {- if self.selection_block {- let Some((r0, r1, c0, c1)) = self.block_rect() else {- return false;- };- return row >= r0 && row <= r1 && col >= c0 && col <= c1;- }- let Some((start, end)) = self.ordered_selection() else {- return false;- };- if row < start.row || row > end.row {- return false;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row { end.col } else { usize::MAX };- col >= lo && col <= hi- }-- /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if- /// any part of that row is selected.- pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- return (row >= r0 && row <= r1).then_some((c0, c1));- }- let (start, end) = self.ordered_selection()?;- if row < start.row || row > end.row {- return None;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- end.col- } else {- self.abs_row(row).len().saturating_sub(1)- };- Some((lo, hi))- }-- /// The selected text, with trailing blanks trimmed per line and rows joined- /// by newlines. `None` if there is no selection.- pub fn selection_text(&self) -> Option<String> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- let mut out = String::new();- for row in r0..=r1 {- out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());- if row != r1 {- out.push('\n');- }- }- return Some(out);- }- let (start, end) = self.ordered_selection()?;- let mut out = String::new();- for row in start.row..=end.row {- let line = self.abs_row(row);- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- (end.col + 1).min(line.len())- } else {- line.len()- };- out.push_str(self.row_slice_text(row, lo, hi).trim_end());- if row != end.row {- out.push('\n');- }- }- Some(out)- }-- /// The characters of an absolute row in `[from, to)`, skipping wide- /// continuation cells.- pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {- let mut out = String::new();- for cell in self- .abs_row(row)- .get(from..to.min(self.abs_row(row).len()))- .unwrap_or(&[])- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- {- out.push(cell.c);- if let Some(marks) = &cell.combining {- out.push_str(marks);- }- }- out- }-}@@ -1,663 +0,0 @@-//! Keyboard encoding: translate decoded key events into the byte sequences a-//! terminal application expects (xterm/VT-style).--use std::io::Write as _;--use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};--use crate::grid::MouseEncoding;--/// Encode a key press into bytes for the PTY, or `None` if it produces no input.-pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {- let seq = match event.keysym {- Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),- Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),- Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),- Keysym::Tab => b"\t".to_vec(),- Keysym::Escape => b"\x1b".to_vec(),-- Keysym::Up => csi_letter(b'A', mods, app_cursor),- Keysym::Down => csi_letter(b'B', mods, app_cursor),- Keysym::Right => csi_letter(b'C', mods, app_cursor),- Keysym::Left => csi_letter(b'D', mods, app_cursor),- Keysym::Home => csi_letter(b'H', mods, app_cursor),- Keysym::End => csi_letter(b'F', mods, app_cursor),-- Keysym::Insert => csi_tilde(2, mods),- Keysym::Delete => csi_tilde(3, mods),- Keysym::Page_Up => csi_tilde(5, mods),- Keysym::Page_Down => csi_tilde(6, mods),-- // F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.- Keysym::F1 => fkey(b'P', mods),- Keysym::F2 => fkey(b'Q', mods),- Keysym::F3 => fkey(b'R', mods),- Keysym::F4 => fkey(b'S', mods),- Keysym::F5 => csi_tilde(15, mods),- Keysym::F6 => csi_tilde(17, mods),- Keysym::F7 => csi_tilde(18, mods),- Keysym::F8 => csi_tilde(19, mods),- Keysym::F9 => csi_tilde(20, mods),- Keysym::F10 => csi_tilde(21, mods),- Keysym::F11 => csi_tilde(23, mods),- Keysym::F12 => csi_tilde(24, mods),-- // Everything else: the xkb-composed text (which already folds in Ctrl),- // with Alt sending an ESC prefix (meta).- _ => {- let text = event.utf8.as_ref()?;- if text.is_empty() {- return None;- }- prefix_alt(text.as_bytes().to_vec(), mods)- }- };- Some(seq)-}--/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.-#[derive(Clone, Copy, PartialEq, Eq)]-pub enum KeyKind {- Press = 1,- Repeat = 2,- Release = 3,-}--/// How a functional (non-text) key is encoded in the kitty protocol.-enum Func {- /// `CSI number ... u`.- Number(u32),- /// `CSI number ... ~`.- Tilde(u32),- /// `CSI 1 ... LETTER`.- Letter(u8),-}--/// Map a keysym to its kitty functional encoding, or `None` for a text key-/// (whose code is its Unicode codepoint).-fn functional(keysym: Keysym) -> Option<Func> {- Some(match keysym {- Keysym::Escape => Func::Number(27),- Keysym::Return | Keysym::KP_Enter => Func::Number(13),- Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),- Keysym::BackSpace => Func::Number(127),- Keysym::Insert => Func::Tilde(2),- Keysym::Delete => Func::Tilde(3),- Keysym::Page_Up => Func::Tilde(5),- Keysym::Page_Down => Func::Tilde(6),- Keysym::Up => Func::Letter(b'A'),- Keysym::Down => Func::Letter(b'B'),- Keysym::Right => Func::Letter(b'C'),- Keysym::Left => Func::Letter(b'D'),- Keysym::Home => Func::Letter(b'H'),- Keysym::End => Func::Letter(b'F'),- Keysym::F1 => Func::Letter(b'P'),- Keysym::F2 => Func::Letter(b'Q'),- Keysym::F3 => Func::Tilde(13),- Keysym::F4 => Func::Letter(b'S'),- Keysym::F5 => Func::Tilde(15),- Keysym::F6 => Func::Tilde(17),- Keysym::F7 => Func::Tilde(18),- Keysym::F8 => Func::Tilde(19),- Keysym::F9 => Func::Tilde(20),- Keysym::F10 => Func::Tilde(21),- Keysym::F11 => Func::Tilde(23),- Keysym::F12 => Func::Tilde(24),- _ => return None,- })-}--/// The kitty key code for a lone modifier key, reported only in report-all mode.-fn modifier_key(keysym: Keysym) -> Option<u32> {- Some(match keysym {- Keysym::Caps_Lock => 57358,- Keysym::Num_Lock => 57360,- Keysym::Shift_L => 57441,- Keysym::Control_L => 57442,- Keysym::Alt_L => 57443,- Keysym::Super_L => 57444,- Keysym::Shift_R => 57447,- Keysym::Control_R => 57448,- Keysym::Alt_R => 57449,- Keysym::Super_R => 57450,- _ => return None,- })-}--/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8); the wire-/// parameter is `1 + this`.-fn kitty_mod_bits(mods: Modifiers) -> u32 {- u32::from(mods.shift)- | (u32::from(mods.alt) << 1)- | (u32::from(mods.ctrl) << 2)- | (u32::from(mods.logo) << 3)-}--/// The un-shifted Unicode codepoint of a text key (always the lowercase form,-/// per the protocol). `None` if the key produces no character.-fn base_codepoint(keysym: Keysym) -> Option<u32> {- let c = keysym.key_char()?;- Some(u32::from(if c.is_ascii_uppercase() {- c.to_ascii_lowercase()- } else {- c- }))-}--/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier-/// section is emitted when modifiers/event/text require it; the event sub-field-/// only when the event is not a plain press.-fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {- let mut s = String::from("\x1b[");- s.push_str(&field);- let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();- if needs_mods {- s.push(';');- s.push_str(&mod_param.to_string());- if event != KeyKind::Press {- s.push(':');- s.push_str(&(event as u8).to_string());- }- }- if let Some(text) = text {- s.push(';');- let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();- s.push_str(&codes.join(":"));- }- s.push(char::from(term));- s.into_bytes()-}--/// Encode a key event under the kitty keyboard protocol with the given active-/// `flags`. Returns `None` when the event produces nothing (e.g. a text key-/// release without report-all mode). `app_cursor` only affects unmodified-/// cursor keys, matching legacy behaviour.-pub fn kitty_encode(- event: &KeyEvent,- mods: Modifiers,- flags: u8,- kind: KeyKind,- app_cursor: bool,-) -> Option<Vec<u8>> {- let report_all = flags & 0b1000 != 0;- let report_events = flags & 0b10 != 0;- let report_alt = flags & 0b100 != 0;- let report_text = flags & 0b1_0000 != 0;-- let bits = kitty_mod_bits(mods);- let mod_param = bits + 1;- // Events other than a press are only reported when asked for.- let events_ok = report_events || report_all;- let kind = if events_ok { kind } else { KeyKind::Press };-- // Lone modifier keys: only in report-all mode.- if let Some(code) = modifier_key(event.keysym) {- if !report_all {- return None;- }- return Some(csi(code.to_string(), mod_param, kind, None, b'u'));- }-- match functional(event.keysym) {- Some(func) => {- // Enter/Tab/Backspace keep legacy bytes when unmodified and not in- // report-all mode, so a shell stays usable.- let legacy_special = matches!(- event.keysym,- Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace- );- if legacy_special && !report_all && bits == 0 {- if kind != KeyKind::Press {- return None;- }- return Some(match event.keysym {- Keysym::BackSpace => b"\x7f".to_vec(),- Keysym::Tab => b"\t".to_vec(),- _ => b"\r".to_vec(),- });- }- if kind != KeyKind::Press && !events_ok {- return None;- }- Some(match func {- Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),- Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),- Func::Letter(l) => {- if mod_param == 1 && kind == KeyKind::Press {- // Unmodified: legacy CSI/SS3 form, honouring app-cursor.- vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]- } else {- csi("1".to_string(), mod_param, kind, None, l)- }- }- })- }- None => {- let cp = base_codepoint(event.keysym)?;- if report_all {- if cp == 0 {- return None;- }- let text = if report_text {- event- .utf8- .as_deref()- .filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))- } else {- None- };- let field = alt_field(cp, event.keysym, mods, report_alt);- return Some(csi(field, mod_param, kind, text, b'u'));- }- // Without report-all, text keys only report presses.- if kind != KeyKind::Press {- return None;- }- // Plain or shift-only keys send their text; ctrl/alt/super → CSI u.- if bits & !1 == 0 {- let text = event.utf8.as_ref()?;- if text.is_empty() {- return None;- }- return Some(text.as_bytes().to_vec());- }- let field = alt_field(cp, event.keysym, mods, report_alt);- Some(csi(field, mod_param, KeyKind::Press, None, b'u'))- }- }-}--/// The key-code field, with the shifted alternate appended (`code:shifted`) when-/// alternate-key reporting is on and shift is held.-fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {- if report_alt- && mods.shift- && let Some(shifted) = keysym.key_char().map(u32::from)- && shifted != cp- {- return format!("{cp}:{shifted}");- }- cp.to_string()-}--/// Encode a mouse event for the application. `button` is the base code (0/1/2-/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based-/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.-pub fn encode_mouse(- encoding: MouseEncoding,- button: u8,- col: usize,- row: usize,- pressed: bool,- motion: bool,- mods: Modifiers,-) -> Vec<u8> {- let mod_bits =- (u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);- let motion_bit = if motion { 32 } else { 0 };- let mut out = Vec::new();- match encoding {- MouseEncoding::Sgr => {- let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);- let final_byte = if pressed { 'M' } else { 'm' };- let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);- }- enc => {- // Legacy form collapses every button release to code 3.- let base = if !pressed && button <= 2 { 3 } else { button };- let cb = 32 + base + motion_bit + mod_bits;- out.extend_from_slice(b"\x1b[M");- push_coord(&mut out, u32::from(cb), false);- let utf8 = enc == MouseEncoding::Utf8;- push_coord(&mut out, col as u32 + 33, utf8);- push_coord(&mut out, row as u32 + 33, utf8);- }- }- out-}--/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when-/// the extended (1005) mode is active, and clamping otherwise.-fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {- if utf8 {- let v = value.min(0x7ff);- if v >= 0x80 {- out.push(0xc0 | (v >> 6) as u8);- out.push(0x80 | (v & 0x3f) as u8);- } else {- out.push(v as u8);- }- } else {- out.push(value.min(255) as u8);- }-}--fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {- if mods.alt {- let mut out = Vec::with_capacity(bytes.len() + 1);- out.push(0x1b);- out.extend_from_slice(&bytes);- out- } else {- bytes- }-}--/// xterm modifier parameter: 1 + a bitfield of the held modifiers.-fn modifier_param(mods: Modifiers) -> u8 {- 1 + u8::from(mods.shift)- + (u8::from(mods.alt) << 1)- + (u8::from(mods.ctrl) << 2)- + (u8::from(mods.logo) << 3)-}--/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and-/// `ESC [ 1 ; m X` when modifiers are held.-fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {- let m = modifier_param(mods);- if m == 1 {- vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]- } else {- let mut v = format!("\x1b[1;{m}").into_bytes();- v.push(final_byte);- v- }-}--/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.-fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {- let m = modifier_param(mods);- if m == 1 {- format!("\x1b[{n}~").into_bytes()- } else {- format!("\x1b[{n};{m}~").into_bytes()- }-}--fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {- let m = modifier_param(mods);- if m == 1 {- vec![0x1b, b'O', final_byte]- } else {- let mut v = format!("\x1b[1;{m}").into_bytes();- v.push(final_byte);- v- }-}--#[cfg(test)]-mod tests {- use super::*;-- fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {- KeyEvent {- time: 0,- raw_code: 0,- keysym,- utf8: utf8.map(str::to_owned),- }- }-- const NONE: Modifiers = Modifiers {- ctrl: false,- alt: false,- shift: false,- caps_lock: false,- logo: false,- num_lock: false,- };-- #[test]- fn plain_text_passes_through() {- assert_eq!(- encode(&key(Keysym::a, Some("a")), NONE, false),- Some(b"a".to_vec())- );- }-- #[test]- fn alt_prefixes_escape() {- let mods = Modifiers { alt: true, ..NONE };- assert_eq!(- encode(&key(Keysym::a, Some("a")), mods, false),- Some(b"\x1ba".to_vec())- );- }-- #[test]- fn arrows_respect_application_mode() {- assert_eq!(- encode(&key(Keysym::Up, None), NONE, false),- Some(b"\x1b[A".to_vec())- );- assert_eq!(- encode(&key(Keysym::Up, None), NONE, true),- Some(b"\x1bOA".to_vec())- );- }-- #[test]- fn modified_arrow_uses_csi_param() {- let mods = Modifiers { ctrl: true, ..NONE };- assert_eq!(- encode(&key(Keysym::Right, None), mods, false),- Some(b"\x1b[1;5C".to_vec())- );- }-- #[test]- fn mouse_sgr_press_and_release() {- // Left press at column 3, row 5 (0-based) → SGR with 1-based coords.- assert_eq!(- encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE),- b"\x1b[<0;4;6M".to_vec()- );- assert_eq!(- encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE),- b"\x1b[<0;4;6m".to_vec()- );- }-- #[test]- fn mouse_legacy_release_collapses_to_three() {- // Legacy release: button bits become 3; values offset by 32.- assert_eq!(- encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE),- vec![0x1b, b'[', b'M', 32 + 3, 33, 33]- );- }-- #[test]- fn mouse_motion_and_modifiers_set_bits() {- let mods = Modifiers { ctrl: true, ..NONE };- // Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl).- assert_eq!(- encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods),- b"\x1b[<48;1;1M".to_vec()- );- }-- fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {- Modifiers {- ctrl,- shift,- alt,- ..NONE- }- }-- #[test]- fn kitty_plain_text_key_stays_text() {- // Disambiguate only: an unmodified letter is still sent as text.- assert_eq!(- kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),- Some(b"a".to_vec())- );- }-- #[test]- fn kitty_ctrl_letter_is_csi_u() {- // ctrl+a → CSI 97 ; 5 u (5 = 1 + ctrl).- assert_eq!(- kitty_encode(- &key(Keysym::a, None),- mods(true, false, false),- 0b1,- KeyKind::Press,- false- ),- Some(b"\x1b[97;5u".to_vec())- );- }-- #[test]- fn kitty_escape_disambiguated() {- assert_eq!(- kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),- Some(b"\x1b[27u".to_vec())- );- }-- #[test]- fn kitty_associated_text_and_alternate() {- // shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.- let flags = 0b1_1001; // disambiguate | report-all | associated-text- assert_eq!(- kitty_encode(- &key(Keysym::A, Some("A")),- mods(false, true, false),- flags,- KeyKind::Press,- false- ),- Some(b"\x1b[97;2;65u".to_vec())- );- // ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.- assert_eq!(- kitty_encode(- &key(Keysym::A, None),- mods(true, true, false),- 0b101,- KeyKind::Press,- false- ),- Some(b"\x1b[97:65;6u".to_vec())- );- }-- #[test]- fn kitty_release_only_with_report_all() {- let release = |flags| {- kitty_encode(- &key(Keysym::a, None),- mods(true, false, false),- flags,- KeyKind::Release,- false,- )- };- // Event reporting alone does not report text-key release.- assert_eq!(release(0b11), None);- // Report-all does: CSI 97 ; 5 : 3 u.- assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));- }-- #[test]- fn kitty_lock_keys_do_not_leak() {- let numlock = Modifiers {- num_lock: true,- ..NONE- };- // Num lock on must not turn unmodified Enter into a CSI u sequence.- assert_eq!(- kitty_encode(- &key(Keysym::Return, None),- numlock,- 0b1,- KeyKind::Press,- false- ),- Some(b"\r".to_vec())- );- // Nor pollute the modifier parameter of a real chord.- let ctrl_numlock = Modifiers {- ctrl: true,- num_lock: true,- ..NONE- };- assert_eq!(- kitty_encode(- &key(Keysym::a, None),- ctrl_numlock,- 0b1,- KeyKind::Press,- false- ),- Some(b"\x1b[97;5u".to_vec())- );- }-- #[test]- fn kitty_functional_keys() {- // Unmodified arrow keeps the legacy form (app-cursor honoured).- assert_eq!(- kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),- Some(b"\x1b[A".to_vec())- );- assert_eq!(- kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),- Some(b"\x1bOA".to_vec())- );- // Modified arrow → CSI 1 ; mods LETTER.- assert_eq!(- kitty_encode(- &key(Keysym::Up, None),- mods(true, false, false),- 0b1,- KeyKind::Press,- false- ),- Some(b"\x1b[1;5A".to_vec())- );- // F5 keeps its tilde form.- assert_eq!(- kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),- Some(b"\x1b[15~".to_vec())- );- // Unmodified Enter is still a carriage return; report-all makes it CSI u.- assert_eq!(- kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),- Some(b"\r".to_vec())- );- assert_eq!(- kitty_encode(- &key(Keysym::Return, None),- NONE,- 0b1001,- KeyKind::Press,- false- ),- Some(b"\x1b[13u".to_vec())- );- }-- #[test]- fn special_keys() {- assert_eq!(- encode(&key(Keysym::Return, None), NONE, false),- Some(b"\r".to_vec())- );- assert_eq!(- encode(&key(Keysym::BackSpace, None), NONE, false),- Some(b"\x7f".to_vec())- );- assert_eq!(- encode(&key(Keysym::Delete, None), NONE, false),- Some(b"\x1b[3~".to_vec())- );- assert_eq!(- encode(&key(Keysym::F5, None), NONE, false),- Some(b"\x1b[15~".to_vec())- );- }-}@@ -1,66 +0,0 @@-//! beer, a fast, software-rendered, Wayland-native terminal emulator.--mod bindings;-mod config;-mod font;-mod grid;-mod input;-mod pty;-mod render;-mod theme;-mod vt;-mod wayland;--use std::path::PathBuf;-use std::process::ExitCode;--use pound::Parse;--use crate::config::Config;--/// A fast, software-rendered, Wayland-native terminal emulator.-#[derive(Parse)]-#[pound(name = "beer", version = "0.2.0")]-struct Cli {- /// Run as a daemon hosting multiple windows.- #[pound(long)]- server: bool,- /// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml).- #[pound(long)]- config: Option<PathBuf>,-}--fn main() -> ExitCode {- init_logging();- match run(Cli::parse()) {- Ok(code) => code,- Err(err) => {- tracing::error!("{err:#}");- eprintln!("beer: {err:#}");- ExitCode::FAILURE- }- }-}--fn init_logging() {- use tracing_subscriber::{EnvFilter, fmt};-- let filter = EnvFilter::try_from_env("BEER_LOG")- .or_else(|_| EnvFilter::try_from_default_env())- .unwrap_or_else(|_| EnvFilter::new("warn"));-- fmt()- .with_env_filter(filter)- .with_writer(std::io::stderr)- .init();-}--fn run(cli: Cli) -> anyhow::Result<ExitCode> {- if cli.server {- anyhow::bail!("server mode is not implemented yet");- }-- let config = Config::load(cli.config.as_deref());- tracing::info!("starting beer");- wayland::run(config, cli.config)-}@@ -1,117 +0,0 @@-//! Pseudo-terminal: open a master/slave pair and run the user's shell on it.--use std::ffi::OsStr;-use std::io;-use std::os::fd::{AsFd, OwnedFd};-use std::os::unix::process::CommandExt;-use std::path::Path;-use std::process::{Child, Command, ExitStatus, Stdio};--use anyhow::Context;-use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};-use rustix::termios::{Winsize, tcsetwinsize};--/// A running child process attached to a PTY master.-#[derive(Debug)]-pub struct Pty {- master: OwnedFd,- child: Child,-}--impl Pty {- /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on- /// the slave end with `TERM=term`.- pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {- let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)- .context("open pty master")?;- grantpt(&master).context("grantpt")?;- unlockpt(&master).context("unlockpt")?;- let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY)- .context("open pty slave")?;-- set_winsize(&master, cols, rows)?;-- let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());- let argv0 = login_argv0(&shell);-- // Hand the slave to the child's stdio. try_clone gives O_CLOEXEC dups, so- // the parent's copies and the controlling-tty handle vanish at exec.- let ctty = slave.try_clone().context("dup slave")?;- let (stdin, stdout, stderr) = (- slave.try_clone().context("dup slave")?,- slave.try_clone().context("dup slave")?,- slave,- );-- let mut cmd = Command::new(&shell);- cmd.arg0(&argv0)- .env("TERM", term)- .env_remove("COLUMNS")- .env_remove("LINES")- .env_remove("TERMCAP")- .stdin(Stdio::from(stdin))- .stdout(Stdio::from(stdout))- .stderr(Stdio::from(stderr));-- // SAFETY: setsid and the TIOCSCTTY ioctl are async-signal-safe raw- // syscalls; ctty is a valid fd captured by move. We touch no parent- // heap state, satisfying pre_exec's contract.- unsafe {- cmd.pre_exec(move || {- rustix::process::setsid()?;- rustix::process::ioctl_tiocsctty(&ctty)?;- Ok(())- });- }-- let child = cmd.spawn().context("spawn shell")?;- Ok(Self { master, child })- }-- /// The PTY master, for reading child output and writing input.- pub fn master(&self) -> &OwnedFd {- &self.master- }-- /// Inform the kernel (and thus the child) of a new terminal size.- pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {- set_winsize(&self.master, cols, rows)- }-- /// Reap the child if it has exited.- pub fn wait(&mut self) -> io::Result<ExitStatus> {- self.child.wait()- }-}--fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> {- let ws = Winsize {- ws_row: rows,- ws_col: cols,- ws_xpixel: 0,- ws_ypixel: 0,- };- tcsetwinsize(master.as_fd(), ws).context("set pty winsize")-}--/// Login-shell argv[0] is the shell's basename with a leading '-'.-fn login_argv0(shell: &OsStr) -> std::ffi::OsString {- let name = Path::new(shell)- .file_name()- .unwrap_or_else(|| OsStr::new("sh"));- let mut argv0 = std::ffi::OsString::from("-");- argv0.push(name);- argv0-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn argv0_is_dash_prefixed_basename() {- assert_eq!(login_argv0(OsStr::new("/usr/bin/bash")), "-bash");- assert_eq!(login_argv0(OsStr::new("zsh")), "-zsh");- assert_eq!(login_argv0(OsStr::new("")), "-sh");- }-}@@ -1,683 +0,0 @@-//! Software renderer: compose the grid into an ARGB8888 buffer.-//!-//! The target is a `wl_shm` buffer in `Argb8888`, which on little-endian is-//! `[B, G, R, A]` per pixel. Rendering is two passes per frame - backgrounds-//! then glyphs - so a wide glyph that overflows its cell is not clipped by the-//! neighbouring cell's background fill.--use std::num::NonZeroU16;--use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};-use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};-use crate::theme::{Plane, Rgb, Theme};--/// A mutable view over a BGRA pixel buffer.-struct Canvas<'a> {- pixels: &'a mut [u8],- width: usize,- height: usize,-}--impl Canvas<'_> {- fn index(&self, x: i32, y: i32) -> Option<usize> {- if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height {- return None;- }- Some((y as usize * self.width + x as usize) * 4)- }-- fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {- self.fill_rect_a(x0, y0, w, h, c, 0xff);- }-- /// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is- /// premultiplied ARGB, so a translucent fill stores `rgb * alpha`.- fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) {- let x_start = x0.max(0) as usize;- let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);- let y_start = y0.max(0) as usize;- let y_end = ((y0 + h as i32).max(0) as usize).min(self.height);- if x_start >= x_end {- return;- }- let a = u32::from(alpha);- let pm = |v: u8| ((u32::from(v) * a) / 255) as u8;- let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha];- for y in y_start..y_end {- let row =- &mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4];- for px in row.chunks_exact_mut(4) {- px.copy_from_slice(&bytes);- }- }- }-- /// Alpha-blend `fg` over the existing pixel with coverage `a`.- fn blend(&mut self, x: i32, y: i32, fg: Rgb, a: u8) {- let Some(i) = self.index(x, y) else { return };- let (a, inv) = (u32::from(a), u32::from(255 - a));- let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8;- self.pixels[i] = mix(fg.2, self.pixels[i]);- self.pixels[i + 1] = mix(fg.1, self.pixels[i + 1]);- self.pixels[i + 2] = mix(fg.0, self.pixels[i + 2]);- self.pixels[i + 3] = 0xff;- }-- /// Set a single opaque pixel.- fn put(&mut self, x: i32, y: i32, c: Rgb) {- if let Some(i) = self.index(x, y) {- self.pixels[i] = c.2;- self.pixels[i + 1] = c.1;- self.pixels[i + 2] = c.0;- self.pixels[i + 3] = 0xff;- }- }-- fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) {- self.fill_rect(x0, y, w, 1, c);- }-- /// Composite one pre-multiplied BGRA source pixel over the destination.- fn over(&mut self, x: i32, y: i32, src: &[u8]) {- let Some(i) = self.index(x, y) else { return };- let inv = u32::from(255 - src[3]);- let comp = |s: u8, dst: u8| (u32::from(s) + u32::from(dst) * inv / 255).min(255) as u8;- self.pixels[i] = comp(src[0], self.pixels[i]);- self.pixels[i + 1] = comp(src[1], self.pixels[i + 1]);- self.pixels[i + 2] = comp(src[2], self.pixels[i + 2]);- self.pixels[i + 3] = 0xff;- }-}--/// Per-frame constants shared by every row: the colour scheme, focus, and the-/// current blink phase.-#[derive(Clone, Copy, Debug)]-pub struct Frame<'a> {- pub theme: &'a Theme,- pub focused: bool,- pub blink_on: bool,- /// Hyperlink currently under the pointer; its cells get a hover underline.- pub hovered_link: Option<NonZeroU16>,-}--#[derive(Debug)]-pub struct Renderer {- fonts: Fonts,- /// Inner padding `(x, y)` in pixels between the window edge and the grid.- pad: (i32, i32),-}--impl Renderer {- pub fn new(fonts: Fonts) -> Self {- Self { fonts, pad: (0, 0) }- }-- pub fn metrics(&self) -> CellMetrics {- self.fonts.metrics()- }-- pub fn set_padding(&mut self, pad_x: u32, pad_y: u32) {- self.pad = (pad_x as i32, pad_y as i32);- }-- /// Rebuild the font set at a new size (font-resize bindings).- pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> {- self.fonts = Fonts::new(family, size_px)?;- Ok(())- }-- /// Fill the whole buffer (including the padding margins) with the background- /// colour. Called once per fresh shm buffer; per-row repaints then leave the- /// margins untouched.- pub fn clear(&self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme) {- let (width, height) = dims;- let mut canvas = Canvas {- pixels,- width,- height,- };- canvas.fill_rect_a(0, 0, width as u32, height as u32, theme.bg, theme.alpha);- }-- /// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):- /// clear the row band, fill backgrounds (and selection), draw glyphs and- /// decorations, then the cursor if it sits on this row. `blink_on` is the- /// current blink phase; blinking cells and a blinking cursor vanish when it- /// is `false`. Painting one row at a time is what lets the caller damage- /// only the rows that actually changed.- pub fn render_row(- &mut self,- pixels: &mut [u8],- dims: (usize, usize),- grid: &Grid,- frame: &Frame,- y: usize,- ) {- let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on);- let (width, height) = dims;- let mut canvas = Canvas {- pixels,- width,- height,- };- let m = self.fonts.metrics();- let (pad_x, pad_y) = self.pad;- let cols = grid.cols();- let row_top = pad_y + y as i32 * m.height as i32;- canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha);-- // Rows come through the scrollback viewport and may be shorter than- // `cols` after a resize, so clamp with `take`.- let abs = grid.view_to_abs(y);- let cells = grid.view_row(y);- let search = grid.search_spans_on(abs);- let match_at = |x: usize| -> Option<bool> {- search- .iter()- .find(|(lo, hi, _)| x >= *lo && x <= *hi)- .map(|(_, _, current)| *current)- };- for (x, cell) in cells.iter().take(cols).enumerate() {- // Focused match > selection > other match > the cell's own bg.- let bg = match match_at(x) {- Some(true) => theme.current_match_bg,- _ if grid.is_selected(abs, x) => theme.selection_bg,- Some(false) => theme.match_bg,- None => cell_colors(cell, theme).1,- };- if bg != theme.bg {- canvas.fill_rect(- pad_x + x as i32 * m.width as i32,- row_top,- m.width,- m.height,- bg,- );- }- }-- for (x, cell) in cells.iter().take(cols).enumerate() {- if cell.flags.contains(Flags::WIDE_CONT) {- continue;- }- if cell.flags.contains(Flags::BLINK) && !blink_on {- continue;- }- let (fg, _) = cell_colors(cell, theme);- let origin_x = pad_x + x as i32 * m.width as i32;- let style = cell_style(cell);- // A cell carrying combining marks is shaped as a cluster so the- // marks land where the font's GPOS table wants them. Shaping returns- // None for braille (drawn directly) and for clusters the face does- // not fully cover, both of which fall through to the legacy path.- let shaped = match &cell.combining {- Some(marks) if !is_braille(cell.c) => {- self.fonts.shape_cluster(cell.c, marks, style)- }- _ => None,- };- if let Some(shaped) = shaped {- for placed in &shaped.glyphs {- if let Ok(glyph) = self.fonts.glyph_indexed(shaped.face_idx, placed.gid, style)- {- blit_glyph(- &mut canvas,- glyph,- m,- origin_x + placed.x,- row_top,- placed.y,- fg,- );- }- }- } else if is_braille(cell.c) {- // Drawn directly so the dots are crisp and fill the cell, the- // way tools like btop expect, rather than however the fallback- // font happens to size its braille glyphs.- draw_braille(&mut canvas, cell.c, origin_x, row_top, m, fg);- } else {- if cell.c != ' ' {- self.draw_glyph(&mut canvas, cell.c, style, origin_x, row_top, fg);- }- // No shaper available for this cluster: stack the marks over the- // base using each mark glyph's own bearings.- if let Some(marks) = &cell.combining {- for mark in marks.chars() {- self.draw_glyph(&mut canvas, mark, style, origin_x, row_top, fg);- }- }- }- draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);- // Underline an OSC 8 hyperlink while the pointer hovers over it.- if cell.link.is_some() && cell.link == frame.hovered_link {- canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg);- }- }-- // The cursor belongs to the live screen; hide it while scrolled back.- if grid.view_at_bottom() && grid.cursor().1 == y {- self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on);- }- }-- /// Draw the incremental-search prompt across the bottom row, over whatever- /// grid content was there. The caller marks the bottom row dirty so this- /// repaints whenever the query or match count changes.- pub fn render_search_bar(- &mut self,- pixels: &mut [u8],- dims: (usize, usize),- theme: &Theme,- row: usize,- text: &str,- ) {- let (width, height) = dims;- let mut canvas = Canvas {- pixels,- width,- height,- };- let m = self.fonts.metrics();- let (pad_x, pad_y) = self.pad;- let row_top = pad_y + row as i32 * m.height as i32;- canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);- let style = Style {- bold: false,- italic: false,- };- let mut x = pad_x;- for c in text.chars() {- if x as usize + m.width as usize > width {- break;- }- if c != ' ' {- self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);- }- x += m.width as i32;- }- }-- /// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at- /// viewport cell `(row, col)`, over whatever was there.- pub fn render_label(- &mut self,- pixels: &mut [u8],- dims: (usize, usize),- theme: &Theme,- row: usize,- col: usize,- text: &str,- ) {- let (width, height) = dims;- let mut canvas = Canvas {- pixels,- width,- height,- };- let m = self.fonts.metrics();- let (pad_x, pad_y) = self.pad;- let row_top = pad_y + row as i32 * m.height as i32;- let style = Style {- bold: true,- italic: false,- };- let mut x = pad_x + col as i32 * m.width as i32;- for c in text.chars() {- if x as usize + m.width as usize > width {- break;- }- canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg);- if c != ' ' {- self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg);- }- x += m.width as i32;- }- }-- /// Draw the IME preedit string inline, starting at grid cell `start_col` of- /// row `row`, over whatever was there. The preedit sits on the selection- /// background and is underlined so it reads as uncommitted, in-flight text.- pub fn render_preedit(- &mut self,- pixels: &mut [u8],- dims: (usize, usize),- theme: &Theme,- row: usize,- start_col: usize,- text: &str,- ) {- let (width, height) = dims;- let mut canvas = Canvas {- pixels,- width,- height,- };- let m = self.fonts.metrics();- let (pad_x, pad_y) = self.pad;- let row_top = pad_y + row as i32 * m.height as i32;- let style = Style {- bold: false,- italic: false,- };- let mut x = pad_x + start_col as i32 * m.width as i32;- for c in text.chars() {- if x < 0 || x as usize + m.width as usize > width {- break;- }- canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg);- if c != ' ' {- self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);- }- // Underline the run a row above the cell bottom.- canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg);- x += m.width as i32;- }- }-- /// Draw the cursor: a solid block/underline/beam when focused, a hollow- /// outline when not. A blinking cursor shape is only drawn while `blink_on`.- fn draw_cursor(- &mut self,- canvas: &mut Canvas,- grid: &Grid,- theme: &Theme,- m: CellMetrics,- focused: bool,- blink_on: bool,- ) {- if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {- return;- }- let (cx, cy) = grid.cursor();- let x0 = self.pad.0 + cx as i32 * m.width as i32;- let top = self.pad.1 + cy as i32 * m.height as i32;- // OSC 12 cursor colour wins, then the configured cursor colour, then fg.- let color = grid- .cursor_color()- .map(|(r, g, b)| Rgb(r, g, b))- .or(theme.cursor)- .unwrap_or(theme.fg);-- if !focused {- let right = x0 + m.width as i32 - 1;- let bottom = top + m.height as i32 - 1;- canvas.hline(x0, top, m.width, color);- canvas.hline(x0, bottom, m.width, color);- canvas.fill_rect(x0, top, 1, m.height, color);- canvas.fill_rect(right, top, 1, m.height, color);- return;- }-- match grid.cursor_shape() {- CursorShape::Block => {- canvas.fill_rect(x0, top, m.width, m.height, color);- let cell = grid.cell(cx, cy);- if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) {- let (_, bg) = cell_colors(cell, theme);- self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg);- }- }- CursorShape::Underline => {- canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color);- }- CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color),- }- }-- fn draw_glyph(- &mut self,- canvas: &mut Canvas,- c: char,- style: Style,- origin_x: i32,- cell_top: i32,- fg: Rgb,- ) {- let m = self.fonts.metrics();- let glyph = match self.fonts.glyph(c, style) {- Ok(glyph) => glyph,- Err(err) => {- tracing::debug!("glyph {c:?}: {err}");- return;- }- };- blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);- }-}--/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the-/// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's-/// vertical offset, 0 for the unshaped path).-fn blit_glyph(- canvas: &mut Canvas,- glyph: &Glyph,- m: CellMetrics,- origin_x: i32,- cell_top: i32,- rise: i32,- fg: Rgb,-) {- let (gw, gh) = (glyph.width as i32, glyph.height as i32);- match &glyph.data {- GlyphData::Mask(mask) => {- let baseline = cell_top + m.ascent as i32 - rise;- for gy in 0..gh {- for gx in 0..gw {- let a = mask[(gy * gw + gx) as usize];- if a != 0 {- canvas.blend(origin_x + glyph.left + gx, baseline - glyph.top + gy, fg, a);- }- }- }- }- // Colour glyphs (emoji) come from a fixed strike at native size;- // scale them to the line height with nearest-neighbour sampling.- GlyphData::Color(bgra) if gh > 0 => {- let scale = m.height as f32 / gh as f32;- let target_w = (gw as f32 * scale) as i32;- for ty in 0..m.height as i32 {- let sy = ((ty as f32 / scale) as i32).min(gh - 1);- for tx in 0..target_w {- let sx = ((tx as f32 / scale) as i32).min(gw - 1);- let i = ((sy * gw + sx) * 4) as usize;- canvas.over(origin_x + tx, cell_top + ty, &bgra[i..i + 4]);- }- }- }- GlyphData::Color(_) => {}- }-}--fn cell_style(cell: &Cell) -> Style {- Style {- bold: cell.flags.contains(Flags::BOLD),- italic: cell.flags.contains(Flags::ITALIC),- }-}--/// Resolve a cell's (foreground, background) RGB, applying reverse video,-/// bold-as-bright, dim, and hidden.-fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) {- let bold = cell.flags.contains(Flags::BOLD);- let mut fg = theme.resolve(cell.fg, Plane::Fg, bold);- let mut bg = theme.resolve(cell.bg, Plane::Bg, false);- if cell.flags.contains(Flags::REVERSE) {- std::mem::swap(&mut fg, &mut bg);- }- if cell.flags.contains(Flags::DIM) {- fg = blend_rgb(fg, bg);- }- if cell.flags.contains(Flags::HIDDEN) {- fg = bg;- }- (fg, bg)-}--/// Mix `c` two-thirds of the way from `toward`, used for the dim attribute.-fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb {- let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8;- Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2))-}--/// Whether `c` is a Braille Patterns codepoint (U+2800-U+28FF).-fn is_braille(c: char) -> bool {- ('\u{2800}'..='\u{28ff}').contains(&c)-}--/// Braille dot geometry for a `width`×`height` cell: the square dot side `w`,-/// the two column origins, and the four row origins. Ported verbatim from-/// foot's `box-drawing.c` `draw_braille` - base size and spacing from the cell,-/// then leftover pixels distributed (dot → margin → spacing → margin → dot) so-/// dots land on exact pixels with no rounding drift.-fn braille_geometry(width: i32, height: i32) -> (u32, [i32; 2], [i32; 4]) {- let mut w = (width / 4).min(height / 8);- let mut x_spacing = width / 4;- let mut y_spacing = height / 8;- let mut x_margin = x_spacing / 2;- let mut y_margin = y_spacing / 2;-- let mut x_left = width - 2 * x_margin - x_spacing - 2 * w;- let mut y_left = height - 2 * y_margin - 3 * y_spacing - 4 * w;-- // First, try hard to ensure a non-zero dot width.- if x_left >= 2 && y_left >= 4 && w == 0 {- w += 1;- x_left -= 2;- y_left -= 4;- }- // Second, prefer a non-zero margin.- if x_left >= 2 && x_margin == 0 {- x_margin = 1;- x_left -= 2;- }- if y_left >= 2 && y_margin == 0 {- y_margin = 1;- y_left -= 2;- }- // Third, increase spacing.- if x_left >= 1 {- x_spacing += 1;- x_left -= 1;- }- if y_left >= 3 {- y_spacing += 1;- y_left -= 3;- }- // Fourth, the side margins.- if x_left >= 2 {- x_margin += 1;- x_left -= 2;- }- if y_left >= 2 {- y_margin += 1;- y_left -= 2;- }- // Last, increase the dot width.- if x_left >= 2 && y_left >= 4 {- w += 1;- }-- let xs = [x_margin, x_margin + w + x_spacing];- let ys = [- y_margin,- y_margin + w + y_spacing,- y_margin + 2 * (w + y_spacing),- y_margin + 3 * (w + y_spacing),- ];- (w.max(0) as u32, xs, ys)-}--/// Draw a braille pattern as a 2×4 grid of `w`×`w` square dots. Geometry ported-/// from foot's `box-drawing.c` `draw_braille`: a dot size and base spacing are-/// derived from the cell, then leftover pixels are distributed (dot width →-/// margins → spacing → …) so dots land on exact pixels with no rounding drift.-/// The low eight bits of the codepoint select dots: bits 0-2 are the left-/// column rows 0-2, bits 3-5 the right column rows 0-2, bits 6-7 the bottom row.-fn draw_braille(canvas: &mut Canvas, c: char, x0: i32, top: i32, m: CellMetrics, fg: Rgb) {- let (w, xs, ys) = braille_geometry(m.width as i32, m.height as i32);- let sym = ((c as u32) - 0x2800) as u8;- // (bit mask, column index, row index).- const DOTS: [(u8, usize, usize); 8] = [- (0x01, 0, 0),- (0x02, 0, 1),- (0x04, 0, 2),- (0x08, 1, 0),- (0x10, 1, 1),- (0x20, 1, 2),- (0x40, 0, 3),- (0x80, 1, 3),- ];- for (mask, col, row) in DOTS {- if sym & mask != 0 {- canvas.fill_rect(x0 + xs[col], top + ys[row], w, w, fg);- }- }-}--/// Draw underline, strikethrough, and overline for one cell.-fn draw_decorations(- canvas: &mut Canvas,- cell: &Cell,- theme: &Theme,- x0: i32,- top: i32,- m: CellMetrics,- fg: Rgb,-) {- let w = m.width;- let baseline = top + m.ascent as i32;- let uy = (baseline + 1).min(top + m.height as i32 - 1);- // A `Default` underline colour follows the cell's foreground.- let uc = match cell.underline_color {- crate::grid::Color::Default => fg,- other => theme.resolve(other, Plane::Fg, false),- };- match cell.underline {- Underline::None => {}- Underline::Single => canvas.hline(x0, uy, w, uc),- Underline::Double => {- canvas.hline(x0, uy, w, uc);- canvas.hline(x0, (uy - 2).max(top), w, uc);- }- Underline::Curly => {- for dx in 0..w as i32 {- let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 };- canvas.put(x0 + dx, uy - wobble, uc);- }- }- Underline::Dotted => {- for dx in (0..w as i32).step_by(2) {- canvas.put(x0 + dx, uy, uc);- }- }- Underline::Dashed => {- for dx in 0..w as i32 {- if (dx / 3) % 2 == 0 {- canvas.put(x0 + dx, uy, uc);- }- }- }- }- if cell.flags.contains(Flags::OVERLINE) {- canvas.hline(x0, top, w, fg);- }- if cell.flags.contains(Flags::STRIKE) {- canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);- }-}--#[cfg(test)]-mod tests {- use super::braille_geometry;-- // Pinned to foot box-drawing.c draw_braille output (cross-checked numerically- // identical across cell sizes 4..30 x 6..48); guards against drift.- #[test]- fn braille_geometry_matches_foot() {- assert_eq!(braille_geometry(8, 18), (2, [1, 5], [2, 6, 10, 14]));- assert_eq!(braille_geometry(10, 20), (2, [1, 6], [1, 6, 11, 16]));- assert_eq!(braille_geometry(12, 27), (3, [1, 8], [1, 8, 15, 22]));- assert_eq!(braille_geometry(7, 15), (1, [1, 4], [2, 5, 8, 11]));- }-}@@ -1,256 +0,0 @@-//! Runtime colours: the foreground/background, the 256-entry palette, and the-//! selection/search/cursor accents. Seeded from config and mutated at runtime-//! by OSC colour escapes (4/104, 10/11, 17/19, and their resets).--use crate::config::Colors as ColorConfig;--/// An 8-bit-per-channel RGB colour.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub struct Rgb(pub u8, pub u8, pub u8);--/// Where a `Color::Default` resolves to.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum Plane {- Fg,- Bg,-}--/// The active colour scheme.-#[derive(Clone, Debug)]-pub struct Theme {- pub fg: Rgb,- pub bg: Rgb,- /// Cursor body colour; `None` follows the cell under the cursor.- pub cursor: Option<Rgb>,- pub selection_fg: Option<Rgb>,- pub selection_bg: Rgb,- pub match_bg: Rgb,- pub current_match_bg: Rgb,- pub search_bar_bg: Rgb,- pub palette: [Rgb; 256],- pub bold_as_bright: bool,- /// Background opacity, 0 (transparent) - 255 (opaque).- pub alpha: u8,- /// Configured defaults, restored by OSC reset escapes (110/111/104).- default_fg: Rgb,- default_bg: Rgb,- default_palette: [Rgb; 256],-}--impl Default for Theme {- fn default() -> Self {- let palette = default_palette();- Self {- fg: DEFAULT_FG,- bg: DEFAULT_BG,- cursor: None,- selection_fg: None,- selection_bg: SELECTION_BG,- match_bg: MATCH_BG,- current_match_bg: CURRENT_MATCH_BG,- search_bar_bg: SEARCH_BAR_BG,- palette,- bold_as_bright: true,- alpha: 255,- default_fg: DEFAULT_FG,- default_bg: DEFAULT_BG,- default_palette: palette,- }- }-}--impl Theme {- /// Build a theme from the `[colors]` config table, falling back to the- /// built-in defaults for any unset entry.- pub fn from_config(c: &ColorConfig) -> Self {- let mut theme = Self::default();- let set = |dst: &mut Rgb, spec: &Option<String>| {- if let Some(rgb) = spec.as_deref().and_then(parse_color) {- *dst = rgb;- }- };- set(&mut theme.fg, &c.foreground);- set(&mut theme.bg, &c.background);- set(&mut theme.selection_bg, &c.selection_background);- set(&mut theme.match_bg, &c.match_background);- set(&mut theme.current_match_bg, &c.match_current_background);- theme.cursor = c.cursor.as_deref().and_then(parse_color);- theme.selection_fg = c.selection_foreground.as_deref().and_then(parse_color);- if let Some(regular) = &c.regular {- apply_palette(&mut theme.palette, 0, regular);- }- if let Some(bright) = &c.bright {- apply_palette(&mut theme.palette, 8, bright);- }- if let Some(a) = c.alpha {- theme.alpha = (a.clamp(0.0, 1.0) * 255.0).round() as u8;- }- if let Some(b) = c.bold_as_bright {- theme.bold_as_bright = b;- }- // Remember the resolved scheme so OSC resets return here, not to the- // compiled-in defaults.- theme.default_fg = theme.fg;- theme.default_bg = theme.bg;- theme.default_palette = theme.palette;- theme- }-- /// Resolve a cell colour to RGB on the given plane, applying bold-as-bright- /// to the low 8 palette indices.- pub fn resolve(&self, color: crate::grid::Color, plane: Plane, bold: bool) -> Rgb {- use crate::grid::Color;- match color {- Color::Default => match plane {- Plane::Fg => self.fg,- Plane::Bg => self.bg,- },- Color::Indexed(i) => {- let idx = if bold && self.bold_as_bright && i < 8 {- i + 8- } else {- i- };- self.palette[idx as usize]- }- Color::Rgb(r, g, b) => Rgb(r, g, b),- }- }-- pub fn set_palette(&mut self, index: u8, rgb: Rgb) {- self.palette[index as usize] = rgb;- }-- /// Reset palette entry `index` to its configured value.- pub fn reset_palette_index(&mut self, index: u8) {- self.palette[index as usize] = self.default_palette[index as usize];- }-- /// Reset the whole palette to its configured values.- pub fn reset_palette(&mut self) {- self.palette = self.default_palette;- }-- pub fn reset_fg(&mut self) {- self.fg = self.default_fg;- }-- pub fn reset_bg(&mut self) {- self.bg = self.default_bg;- }-- /// A copy with foreground and background swapped, for the visual bell flash.- pub fn inverted(&self) -> Self {- let mut t = self.clone();- std::mem::swap(&mut t.fg, &mut t.bg);- t- }-}--/// Foreground/background used for `Color::Default`.-const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);-const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);-const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);-const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);-const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);-const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);--/// Overwrite palette entries `[base, base+specs.len())` from hex specs.-fn apply_palette(palette: &mut [Rgb; 256], base: usize, specs: &[String]) {- for (i, spec) in specs.iter().take(8).enumerate() {- if let Some(rgb) = parse_color(spec) {- palette[base + i] = rgb;- }- }-}--/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.-fn default_palette() -> [Rgb; 256] {- const BASE: [Rgb; 16] = [- Rgb(0x00, 0x00, 0x00),- Rgb(0xcd, 0x00, 0x00),- Rgb(0x00, 0xcd, 0x00),- Rgb(0xcd, 0xcd, 0x00),- Rgb(0x00, 0x00, 0xee),- Rgb(0xcd, 0x00, 0xcd),- Rgb(0x00, 0xcd, 0xcd),- Rgb(0xe5, 0xe5, 0xe5),- Rgb(0x7f, 0x7f, 0x7f),- Rgb(0xff, 0x00, 0x00),- Rgb(0x00, 0xff, 0x00),- Rgb(0xff, 0xff, 0x00),- Rgb(0x5c, 0x5c, 0xff),- Rgb(0xff, 0x00, 0xff),- Rgb(0x00, 0xff, 0xff),- Rgb(0xff, 0xff, 0xff),- ];- let cube = |step: u8| -> u8 { if step == 0 { 0 } else { 55 + 40 * step } };- std::array::from_fn(|i| match i {- 0..=15 => BASE[i],- 16..=231 => {- let i = i as u8 - 16;- Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))- }- _ => {- let v = 8 + 10 * (i as u8 - 232);- Rgb(v, v, v)- }- })-}--/// Parse an X11 colour spec: `rgb:rr/gg/bb` (1-4 hex digits per channel) or-/// `#rrggbb`.-pub fn parse_color(spec: &str) -> Option<Rgb> {- if let Some(rest) = spec.strip_prefix("rgb:") {- let mut it = rest.split('/');- let chan = |s: &str| -> Option<u8> {- let v = u32::from_str_radix(s, 16).ok()?;- let max = (1u32 << (4 * s.len() as u32)) - 1;- Some((v * 255 / max) as u8)- };- return Some(Rgb(chan(it.next()?)?, chan(it.next()?)?, chan(it.next()?)?));- }- let hex = spec.strip_prefix('#')?;- if hex.len() == 6 {- let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();- return Some(Rgb(byte(0)?, byte(2)?, byte(4)?));- }- None-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn parses_color_forms() {- assert_eq!(parse_color("#ff0080"), Some(Rgb(255, 0, 128)));- assert_eq!(parse_color("rgb:ff/00/80"), Some(Rgb(255, 0, 128)));- assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(Rgb(255, 0, 128)));- assert_eq!(parse_color("nope"), None);- }-- #[test]- fn palette_cube_and_grey() {- let p = default_palette();- assert_eq!(p[16], Rgb(0, 0, 0));- assert_eq!(p[231], Rgb(255, 255, 255));- assert_eq!(p[232], Rgb(8, 8, 8));- }-- #[test]- fn config_overrides_and_resets() {- let cfg = ColorConfig {- background: Some("#000000".to_string()),- regular: Some(vec!["#111111".to_string()]),- ..ColorConfig::default()- };- let mut theme = Theme::from_config(&cfg);- assert_eq!(theme.bg, Rgb(0, 0, 0));- assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));- // OSC reset returns to the configured value, not the compiled default.- theme.set_palette(0, Rgb(9, 9, 9));- theme.reset_palette_index(0);- assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));- }-}@@ -1,1023 +0,0 @@-//! VT emulation: feed bytes through `vte` and drive the [`Grid`].--mod perform;--use std::io::Write as _;--use vte::Params;--use crate::grid::{- Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,-};-use crate::theme::{Rgb, Theme};--/// G0/G1 character set designation.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-enum Charset {- Ascii,- DecSpecial,-}--/// Which device-attributes query is being answered.-#[derive(Clone, Copy, Debug)]-enum DaLevel {- Primary,- Secondary,- Tertiary,-}--/// A clipboard request from the application (OSC 52), for the front-end to act-/// on since it owns the Wayland selections.-#[derive(Clone, Debug)]-pub enum ClipboardOp {- /// Set the clipboard (or primary) to `text`.- Set { primary: bool, text: String },- /// Report the current clipboard (or primary) contents back to the app.- Query { primary: bool },-}--/// A desktop notification an application requested (OSC 9 / 777 / 99), for the-/// front-end to deliver via the configured notifier.-#[derive(Clone, Debug, PartialEq, Eq)]-pub struct Notification {- pub title: Option<String>,- pub body: String,-}--/// Which dynamic colour an OSC 10/11/17/19 escape targets.-#[derive(Clone, Copy, Debug)]-enum Dynamic {- Fg,- Bg,- SelBg,- SelFg,-}--/// DECRQM mode-state code: 1 = set, 2 = reset.-fn set_reset(on: bool) -> u8 {- if on { 1 } else { 2 }-}--/// Parse an OSC colour spec into an [`Rgb`].-fn parse_spec(spec: &[u8]) -> Option<Rgb> {- std::str::from_utf8(spec)- .ok()- .and_then(crate::theme::parse_color)-}--/// Parse a decimal palette index (0-255).-fn parse_index(b: &[u8]) -> Option<u8> {- std::str::from_utf8(b).ok()?.parse().ok()-}--fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {- (rgb.0, rgb.1, rgb.2)-}--/// Select `protocol` when a mouse mode is set, else turn reporting off.-fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {- if on { protocol } else { MouseProtocol::Off }-}--/// Select `encoding` when its mode is set, else fall back to the default form.-fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {- if on { encoding } else { MouseEncoding::X10 }-}--/// Map an SGR 4 param (`4` or `4:x`) to an underline style.-fn underline_from(param: &[u16]) -> Underline {- match param.get(1).copied().unwrap_or(1) {- 0 => Underline::None,- 2 => Underline::Double,- 3 => Underline::Curly,- 4 => Underline::Dotted,- 5 => Underline::Dashed,- _ => Underline::Single,- }-}--/// The terminal model: a grid plus the escape-sequence state around it.-#[derive(Debug)]-pub struct Term {- grid: Grid,- title: Option<String>,- title_stack: Vec<Option<String>>,- response: Vec<u8>,- g0: Charset,- g1: Charset,- shift_out: bool,- /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.- xtgettcap: Option<Vec<u8>>,- /// Pending OSC 52 clipboard requests, drained by the front-end.- clipboard_ops: Vec<ClipboardOp>,- /// The active colour scheme (seeded from config, mutated by OSC escapes).- theme: Theme,- /// Set when the child rings the bell (`BEL`); cleared by the front-end.- bell: bool,- /// Working directory reported by the shell via OSC 7, for new windows.- cwd: Option<String>,- /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.- notifications: Vec<Notification>,-}--impl Term {- pub fn new(cols: usize, rows: usize) -> Self {- Self {- grid: Grid::new(cols, rows),- title: None,- title_stack: Vec::new(),- response: Vec::new(),- g0: Charset::Ascii,- g1: Charset::Ascii,- shift_out: false,- xtgettcap: None,- clipboard_ops: Vec::new(),- theme: Theme::default(),- bell: false,- cwd: None,- notifications: Vec::new(),- }- }-- /// The working directory last reported by the shell (OSC 7), if any.- pub fn cwd(&self) -> Option<&str> {- self.cwd.as_deref()- }-- /// Drain the desktop notifications requested since the last call.- pub fn take_notifications(&mut self) -> Vec<Notification> {- std::mem::take(&mut self.notifications)- }-- /// Take and clear the pending bell flag.- pub fn take_bell(&mut self) -> bool {- std::mem::take(&mut self.bell)- }-- /// Drain the OSC 52 clipboard requests accumulated since the last call.- pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {- std::mem::take(&mut self.clipboard_ops)- }-- pub fn theme(&self) -> &Theme {- &self.theme- }-- /// Replace the colour scheme (config load / reload).- pub fn set_theme(&mut self, theme: Theme) {- self.theme = theme;- }-- /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply- /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.- fn answer_xtgettcap(&mut self, payload: &[u8]) {- for name_hex in payload.split(|&b| b == b';') {- let value = decode_hex(name_hex).and_then(|name| cap_value(&name));- match value {- Some(value) => {- self.response.extend_from_slice(b"\x1bP1+r");- self.response.extend_from_slice(name_hex);- self.response.push(b'=');- for byte in value.bytes() {- let _ = write!(self.response, "{byte:02x}");- }- self.response.extend_from_slice(b"\x1b\\");- }- None => {- self.response.extend_from_slice(b"\x1bP0+r");- self.response.extend_from_slice(name_hex);- self.response.extend_from_slice(b"\x1b\\");- }- }- }- }-- pub fn grid(&self) -> &Grid {- &self.grid- }-- pub fn grid_mut(&mut self) -> &mut Grid {- &mut self.grid- }-- pub fn resize(&mut self, cols: usize, rows: usize) {- self.grid.resize(cols, rows);- }-- pub fn scroll_view(&mut self, delta: isize) {- self.grid.scroll_view(delta);- }-- pub fn scroll_to_bottom(&mut self) {- self.grid.scroll_to_bottom();- }-- /// Lines per page, for page-scroll bindings.- pub fn page(&self) -> usize {- self.grid.page()- }-- pub fn title(&self) -> Option<&str> {- self.title.as_deref()- }-- /// Bytes the terminal needs to send back to the application (DA, CPR, ...).- pub fn take_response(&mut self) -> Vec<u8> {- std::mem::take(&mut self.response)- }-- fn active_charset(&self) -> Charset {- if self.shift_out { self.g1 } else { self.g0 }- }-- fn set_mode(&mut self, params: &Params, private: bool, on: bool) {- for p in params.iter() {- let Some(&code) = p.first() else { continue };- match (private, code) {- (true, 6) => self.grid.set_origin(on),- (true, 7) => self.grid.set_autowrap(on),- (true, 1049) => {- if on {- self.grid.save_cursor();- self.grid.enter_alt_screen();- self.grid.erase_display(2);- } else {- self.grid.leave_alt_screen();- self.grid.restore_cursor();- }- }- (true, 47 | 1047) => {- if on {- self.grid.enter_alt_screen();- } else {- self.grid.leave_alt_screen();- }- }- (false, 4) => self.grid.set_insert(on),- (true, 1) => self.grid.set_app_cursor(on),- (true, 25) => self.grid.set_cursor_visible(on),- (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),- (true, 1000) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Normal)),- (true, 1002) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Button)),- (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),- (true, 1004) => self.grid.set_focus_events(on),- (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),- (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),- (true, 2004) => self.grid.set_bracketed_paste(on),- (true, 2026) => self.grid.set_sync(on),- _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),- }- }- }-- fn sgr(&mut self, params: &Params) {- let items: Vec<&[u16]> = params.iter().collect();- if items.is_empty() {- self.grid.reset_pen();- return;- }- let mut i = 0;- while i < items.len() {- let p = items[i];- let code = p.first().copied().unwrap_or(0);- let pen = self.grid.pen_mut();- let mut step = 1;- match code {- 0 => *pen = Default::default(),- 1 => pen.flags.insert(Flags::BOLD),- 2 => pen.flags.insert(Flags::DIM),- 3 => pen.flags.insert(Flags::ITALIC),- 4 => pen.underline = underline_from(p),- 5 | 6 => pen.flags.insert(Flags::BLINK),- 7 => pen.flags.insert(Flags::REVERSE),- 8 => pen.flags.insert(Flags::HIDDEN),- 9 => pen.flags.insert(Flags::STRIKE),- 21 => pen.underline = Underline::Double,- 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),- 23 => pen.flags.remove(Flags::ITALIC),- 24 => pen.underline = Underline::None,- 25 => pen.flags.remove(Flags::BLINK),- 27 => pen.flags.remove(Flags::REVERSE),- 28 => pen.flags.remove(Flags::HIDDEN),- 29 => pen.flags.remove(Flags::STRIKE),- 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),- 39 => pen.fg = Color::Default,- 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),- 49 => pen.bg = Color::Default,- 53 => pen.flags.insert(Flags::OVERLINE),- 55 => pen.flags.remove(Flags::OVERLINE),- 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),- 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),- 38 | 48 | 58 => {- let (color, consumed) = ext_color(&items, i);- if let Some(color) = color {- match code {- 38 => pen.fg = color,- 48 => pen.bg = color,- _ => pen.underline_color = color,- }- }- step = consumed;- }- 59 => pen.underline_color = Color::Default,- _ => {}- }- i += step;- }- }-- /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic- /// firmware level; DA3 a (zero) unit ID.- fn device_attrs(&mut self, level: DaLevel) {- match level {- DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),- DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),- DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),- }- }-- /// Reply to a kitty-keyboard flags query (`CSI ? u`) with `CSI ? flags u`.- fn report_kitty_flags(&mut self) {- let _ = write!(self.response, "\x1b[?{}u", self.grid.kitty_flags());- }-- /// XTVERSION (`CSI > q`): report the terminal name and version.- fn report_version(&mut self) {- let _ = write!(- self.response,- "\x1bP>|beer({})\x1b\\",- env!("CARGO_PKG_VERSION")- );- }-- /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).- fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {- let Rgb(r, g, b) = rgb;- let _ = write!(- self.response,- "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",- u16::from(r) * 0x101,- u16::from(g) * 0x101,- u16::from(b) * 0x101,- );- self.response- .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });- }-- /// OSC 4: set or query palette entries, given as `index;spec` pairs.- fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {- let mut rest = params[1..].iter();- while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {- let Some(idx) = parse_index(idx_raw) else {- continue;- };- if *spec == b"?" {- let rgb = self.theme.palette[idx as usize];- self.reply_color(&format!("4;{idx}"), rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- self.theme.set_palette(idx, rgb);- }- }- }-- /// OSC 10/11/17/19: set or query a dynamic colour.- fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {- let Some(spec) = spec else { return };- if **spec == b"?"[..] {- let rgb = match kind {- Dynamic::Fg => self.theme.fg,- Dynamic::Bg => self.theme.bg,- Dynamic::SelBg => self.theme.selection_bg,- Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),- };- let code = match kind {- Dynamic::Fg => "10",- Dynamic::Bg => "11",- Dynamic::SelBg => "17",- Dynamic::SelFg => "19",- };- self.reply_color(code, rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- match kind {- Dynamic::Fg => self.theme.fg = rgb,- Dynamic::Bg => self.theme.bg = rgb,- Dynamic::SelBg => self.theme.selection_bg = rgb,- Dynamic::SelFg => self.theme.selection_fg = Some(rgb),- }- }- }-- fn device_status(&mut self, params: &Params) {- match params.iter().next().and_then(|p| p.first().copied()) {- Some(5) => self.response.extend_from_slice(b"\x1b[0n"),- Some(6) => {- let (x, y) = self.grid.cursor();- let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);- }- _ => {}- }- }-- /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),- /// or unrecognized (0). Only the modes we actually track are reported.- fn report_mode(&mut self, params: &Params, private: bool) {- let code = raw(params, 0);- let state = match (private, code) {- (true, 6) => set_reset(self.grid.origin()),- (true, 7) => set_reset(self.grid.autowrap()),- (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),- (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),- (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),- (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),- (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),- (true, 1004) => set_reset(self.grid.focus_events()),- (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),- (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),- (true, 2004) => set_reset(self.grid.bracketed_paste()),- (true, 2026) => set_reset(self.grid.sync_active()),- (false, 4) => set_reset(self.grid.insert()),- _ => 0,- };- let prefix = if private { "?" } else { "" };- let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");- }-- /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.- fn title_stack_op(&mut self, params: &Params) {- match raw(params, 0) {- 22 => self.title_stack.push(self.title.clone()),- 23 => {- if let Some(title) = self.title_stack.pop() {- self.title = title;- }- }- _ => {}- }- }-}--/// First param value, with 0/absent folded to `default` (xterm convention for-/// cursor movement and counts).-fn n(params: &Params, idx: usize, default: usize) -> usize {- match params.iter().nth(idx).and_then(|p| p.first().copied()) {- Some(0) | None => default,- Some(v) => v as usize,- }-}--/// Raw first param value (0 is meaningful), defaulting to 0 when absent.-fn raw(params: &Params, idx: usize) -> u16 {- params- .iter()- .nth(idx)- .and_then(|p| p.first().copied())- .unwrap_or(0)-}--/// Parse an SGR 38/48 extended colour, returning the colour and how many-/// top-level params it consumed (1 for colon form, more for semicolon form).-fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {- let head = items[i];- if head.len() >= 2 {- return (color_from_subparams(&head[1..]), 1);- }- match items.get(i + 1).and_then(|s| s.first().copied()) {- Some(5) => {- let idx = items- .get(i + 2)- .and_then(|s| s.first().copied())- .unwrap_or(0);- (Some(Color::Indexed(idx as u8)), 3)- }- Some(2) => {- let get = |k: usize| {- items- .get(i + k)- .and_then(|s| s.first().copied())- .unwrap_or(0)- };- (- Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),- 5,- )- }- _ => (None, 1),- }-}--fn color_from_subparams(sub: &[u16]) -> Option<Color> {- match sub.first().copied() {- Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),- Some(2) => {- // Either `2:r:g:b` or `2:colorspace:r:g:b`.- let rgb = if sub.len() >= 5 {- &sub[2..5]- } else {- &sub[1..]- };- match rgb {- [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),- _ => None,- }- }- _ => None,- }-}--fn charset(byte: u8) -> Charset {- match byte {- b'0' => Charset::DecSpecial,- _ => Charset::Ascii,- }-}--/// Translate a byte under the DEC special graphics set (line drawing).-fn dec_special(c: char) -> char {- match c {- '`' => '◆',- 'a' => '▒',- 'f' => '°',- 'g' => '±',- 'j' => '┘',- 'k' => '┐',- 'l' => '┌',- 'm' => '└',- 'n' => '┼',- 'o' => '⎺',- 'p' => '⎻',- 'q' => '─',- 'r' => '⎼',- 's' => '⎽',- 't' => '├',- 'u' => '┤',- 'v' => '┴',- 'w' => '┬',- 'x' => '│',- 'y' => '≤',- 'z' => '≥',- '~' => '·',- _ => c,- }-}--/// Look up a terminfo capability beer reports via XTGETTCAP.-fn cap_value(name: &[u8]) -> Option<&'static str> {- match name {- b"TN" => Some("beer"),- b"Co" | b"colors" => Some("256"),- b"RGB" => Some("8/8/8"),- _ => None,- }-}--const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";--/// Standard base64 encode (used for OSC 52 query replies).-pub fn base64_encode(data: &[u8]) -> String {- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);- for chunk in data.chunks(3) {- let b = [- chunk[0],- *chunk.get(1).unwrap_or(&0),- *chunk.get(2).unwrap_or(&0),- ];- let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);- out.push(B64[(n >> 18 & 63) as usize] as char);- out.push(B64[(n >> 12 & 63) as usize] as char);- out.push(if chunk.len() > 1 {- B64[(n >> 6 & 63) as usize] as char- } else {- '='- });- out.push(if chunk.len() > 2 {- B64[(n & 63) as usize] as char- } else {- '='- });- }- out-}--/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad-/// character.-fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {- let val = |c: u8| -> Option<u32> {- match c {- b'A'..=b'Z' => Some(u32::from(c - b'A')),- b'a'..=b'z' => Some(u32::from(c - b'a') + 26),- b'0'..=b'9' => Some(u32::from(c - b'0') + 52),- b'+' => Some(62),- b'/' => Some(63),- _ => None,- }- };- let filtered: Vec<u8> = data- .iter()- .copied()- .filter(|&c| c != b'=' && !c.is_ascii_whitespace())- .collect();- let mut out = Vec::with_capacity(filtered.len() / 4 * 3);- for chunk in filtered.chunks(4) {- if chunk.len() == 1 {- return None; // a lone sextet cannot form a byte- }- let mut n = 0u32;- for &c in chunk {- n = (n << 6) | val(c)?;- }- n <<= 6 * (4 - chunk.len() as u32);- out.push((n >> 16) as u8);- if chunk.len() >= 3 {- out.push((n >> 8) as u8);- }- if chunk.len() >= 4 {- out.push(n as u8);- }- }- Some(out)-}--/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.-fn osc_text(field: Option<&&[u8]>) -> Option<String> {- field.map(|b| String::from_utf8_lossy(b).into_owned())-}--/// Map an OSC 133 mark letter to a [`PromptKind`].-fn prompt_kind(b: u8) -> Option<PromptKind> {- match b {- b'A' => Some(PromptKind::PromptStart),- b'B' => Some(PromptKind::CmdStart),- b'C' => Some(PromptKind::OutputStart),- b'D' => Some(PromptKind::CmdEnd),- _ => None,- }-}--/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding-/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns-/// `None` if it is not a usable absolute path.-fn file_uri_path(uri: &[u8]) -> Option<String> {- let rest = uri.strip_prefix(b"file://").unwrap_or(uri);- // Skip the authority (host) up to the first '/', which begins the path.- let slash = rest.iter().position(|&b| b == b'/')?;- let path_bytes = percent_decode(&rest[slash..]);- let path = String::from_utf8(path_bytes).ok()?;- path.starts_with('/').then_some(path)-}--/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.-fn percent_decode(s: &[u8]) -> Vec<u8> {- let mut out = Vec::with_capacity(s.len());- let mut i = 0;- while i < s.len() {- if s[i] == b'%' && i + 2 < s.len() {- let hi = (s[i + 1] as char).to_digit(16);- let lo = (s[i + 2] as char).to_digit(16);- if let (Some(hi), Some(lo)) = (hi, lo) {- out.push((hi * 16 + lo) as u8);- i += 3;- continue;- }- }- out.push(s[i]);- i += 1;- }- out-}--/// Decode an even-length lowercase/uppercase hex string into bytes.-fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {- if s.is_empty() || !s.len().is_multiple_of(2) {- return None;- }- let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);- s.chunks_exact(2)- .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))- .collect()-}--#[cfg(test)]-mod tests {- use super::*;- use crate::grid::{MouseEncoding, MouseProtocol};-- fn feed(term: &mut Term, bytes: &[u8]) {- let mut parser = vte::Parser::new();- parser.advance(term, bytes);- }-- #[test]- fn plain_text_lands_in_the_grid() {- let mut t = Term::new(20, 4);- feed(&mut t, b"hello");- assert_eq!(t.grid().row_text(0), "hello");- }-- #[test]- fn cursor_position_and_erase() {- let mut t = Term::new(20, 4);- feed(&mut t, b"abcde\x1b[Hxyz");- assert_eq!(t.grid().row_text(0), "xyzde");- }-- #[test]- fn newline_sequence() {- let mut t = Term::new(20, 4);- feed(&mut t, b"one\r\ntwo");- assert_eq!(t.grid().row_text(0), "one");- assert_eq!(t.grid().row_text(1), "two");- }-- #[test]- fn device_attributes_levels() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[c");- assert_eq!(t.take_response(), b"\x1b[?62;22c");- feed(&mut t, b"\x1b[>c");- assert_eq!(t.take_response(), b"\x1b[>0;276;0c");- feed(&mut t, b"\x1b[=c");- assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");- }-- #[test]- fn xtversion_reports_name() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[>q");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1bP>|beer("));- assert!(resp.ends_with(b")\x1b\\"));- }-- #[test]- fn decrqm_reports_known_modes() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default- assert_eq!(t.take_response(), b"\x1b[?7;1$y");- feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query- assert_eq!(t.take_response(), b"\x1b[?7;2$y");- feed(&mut t, b"\x1b[?9999$p"); // unknown mode- assert_eq!(t.take_response(), b"\x1b[?9999;0$y");- }-- #[test]- fn sgr_underline_styles_and_lines() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4:3;58;5;1;53mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.underline, Underline::Curly);- assert_eq!(cell.underline_color, Color::Indexed(1));- assert!(cell.flags.contains(Flags::OVERLINE));- // 4:0 turns the underline back off.- feed(&mut t, b"\x1b[4:0mY");- assert_eq!(t.grid().cell(1, 0).underline, Underline::None);- }-- #[test]- fn decscusr_and_cursor_visibility() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b[6 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[0 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Block);-- feed(&mut t, b"\x1b[?25l");- assert!(!t.grid().cursor_visible());- feed(&mut t, b"\x1b[?25h");- assert!(t.grid().cursor_visible());- }-- #[test]- fn osc12_sets_and_resets_cursor_color() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]12;#ff0000\x07");- assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));- feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");- assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- }-- #[test]- fn decscusr_and_cursor_color() {- use crate::grid::CursorShape;- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[5 q"); // blinking bar- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[4 q"); // steady underline- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b]12;#ff3030\x07");- assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- feed(&mut t, b"\x1b[?25l"); // hide cursor- assert!(!t.grid().cursor_visible());- }-- #[test]- fn osc_palette_and_dynamic_colors() {- use crate::theme::Rgb;- let mut t = Term::new(20, 2);- // Set palette index 1 and foreground via OSC, then query them back.- feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");- assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));- feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");- assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));- feed(&mut t, b"\x1b]11;?\x07");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1b]11;rgb:"));- // Reset returns the palette entry to its default.- feed(&mut t, b"\x1b]104;1\x1b\\");- assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));- }-- #[test]- fn xtgettcap_known_and_unknown() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"- assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"- feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown- assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");- }-- #[test]- fn bracketed_paste_and_sync_modes() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b[?2004h");- assert!(t.grid().bracketed_paste());- feed(&mut t, b"\x1b[?2004$p");- assert_eq!(t.take_response(), b"\x1b[?2004;1$y");- feed(&mut t, b"\x1b[?2026h");- assert!(t.grid().sync_active());- feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");- assert!(!t.grid().sync_active());- assert_eq!(t.take_response(), b"\x1b[?2026;2$y");- }-- #[test]- fn base64_round_trips() {- for s in [- "",- "f",- "fo",- "foo",- "foob",- "fooba",- "foobar",- "hi there\n",- ] {- let enc = base64_encode(s.as_bytes());- assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));- }- assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");- assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));- }-- #[test]- fn osc52_set_and_query() {- let mut t = Term::new(20, 2);- // Set clipboard to "hi" (base64 "aGk=").- feed(&mut t, b"\x1b]52;c;aGk=\x07");- let ops = t.take_clipboard_ops();- match ops.as_slice() {- [- ClipboardOp::Set {- primary: false,- text,- },- ] => assert_eq!(text, "hi"),- other => panic!("unexpected ops: {other:?}"),- }- // Query the primary selection.- feed(&mut t, b"\x1b]52;p;?\x07");- let ops = t.take_clipboard_ops();- assert!(matches!(- ops.as_slice(),- [ClipboardOp::Query { primary: true }]- ));- }-- #[test]- fn mouse_modes_track_protocol_and_encoding() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?1002h\x1b[?1006h");- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);- assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);- feed(&mut t, b"\x1b[?1002$p");- assert_eq!(t.take_response(), b"\x1b[?1002;1$y");- feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);- feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);- feed(&mut t, b"\x1b[?1004h");- assert!(t.grid().focus_events());- }-- #[test]- fn title_stack_push_pop() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b]0;first\x07");- feed(&mut t, b"\x1b[22t"); // push "first"- feed(&mut t, b"\x1b]0;second\x07");- assert_eq!(t.title(), Some("second"));- feed(&mut t, b"\x1b[23t"); // pop -> "first"- assert_eq!(t.title(), Some("first"));- }-- #[test]- fn sgr_sets_pen_colours() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[31;1mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.fg, Color::Indexed(1));- assert!(cell.flags.contains(Flags::BOLD));- }-- #[test]- fn truecolor_semicolon_and_colon() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[38;2;10;20;30mA");- assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));- feed(&mut t, b"\x1b[38:2:40:50:60mB");- assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));- }-- #[test]- fn device_status_reports_cursor() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[3;5H\x1b[6n");- assert_eq!(t.take_response(), b"\x1b[3;5R");- }-- #[test]- fn line_drawing_charset() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b(0qx\x1b(B");- assert_eq!(t.grid().row_text(0), "─│");- }-- #[test]- fn osc133_marks_capture_last_command_output() {- let mut t = Term::new(12, 6);- feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command- feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output- feed(&mut t, b"\x1b]133;D\x07"); // command finished- assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));- }-- #[test]- fn osc_notifications_collected() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b]9;hello\x07");- feed(&mut t, b"\x1b]777;notify;Title;Body\x07");- let n = t.take_notifications();- assert_eq!(- n,- vec![- Notification {- title: None,- body: "hello".into()- },- Notification {- title: Some("Title".into()),- body: "Body".into()- },- ]- );- assert!(t.take_notifications().is_empty());- }-- #[test]- fn osc7_tracks_cwd_and_decodes_percent() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");- assert_eq!(t.cwd(), Some("/home/user/my dir"));- // A non-file or relative URI leaves the previous value untouched? It- // simply does not match a path, so cwd stays None here.- let mut t2 = Term::new(20, 1);- feed(&mut t2, b"\x1b]7;file://host\x07");- assert_eq!(t2.cwd(), None);- }-- #[test]- fn title_via_osc() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]0;hello\x07");- assert_eq!(t.title(), Some("hello"));- }-- #[test]- fn alt_screen_preserves_primary() {- let mut t = Term::new(20, 2);- feed(&mut t, b"main");- feed(&mut t, b"\x1b[?1049h");- assert_eq!(t.grid().row_text(0), "");- feed(&mut t, b"\x1b[?1049l");- assert_eq!(t.grid().row_text(0), "main");- }-}@@ -1,250 +0,0 @@-use vte::Perform;--use super::*;--impl Perform for Term {- fn print(&mut self, c: char) {- let c = if self.active_charset() == Charset::DecSpecial {- dec_special(c)- } else {- c- };- self.grid.print(c);- }-- fn execute(&mut self, byte: u8) {- match byte {- 0x07 => self.bell = true,- 0x08 => self.grid.backspace(),- 0x09 => self.grid.tab(),- 0x0A..=0x0C => self.grid.line_feed(),- 0x0D => self.grid.carriage_return(),- 0x0E => self.shift_out = true,- 0x0F => self.shift_out = false,- _ => {}- }- }-- fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {- let private = intermediates.first() == Some(&b'?');- match action {- 'A' => self.grid.cursor_up(n(params, 0, 1)),- 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),- 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),- 'D' => self.grid.cursor_back(n(params, 0, 1)),- 'E' => {- self.grid.cursor_down(n(params, 0, 1));- self.grid.carriage_return();- }- 'F' => {- self.grid.cursor_up(n(params, 0, 1));- self.grid.carriage_return();- }- 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),- 'd' => self.grid.move_to_row(n(params, 0, 1) - 1),- 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),- 'J' => self.grid.erase_display(raw(params, 0)),- 'K' => self.grid.erase_line(raw(params, 0)),- '@' => self.grid.insert_chars(n(params, 0, 1)),- 'P' => self.grid.delete_chars(n(params, 0, 1)),- 'L' => self.grid.insert_lines(n(params, 0, 1)),- 'M' => self.grid.delete_lines(n(params, 0, 1)),- 'X' => self.grid.erase_chars(n(params, 0, 1)),- 'S' => self.grid.scroll_up(n(params, 0, 1)),- 'T' => self.grid.scroll_down(n(params, 0, 1)),- 'm' => self.sgr(params),- 'r' => {- let top = n(params, 0, 1) - 1;- let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {- Some(0) | None => self.grid.rows() - 1,- Some(v) => (v as usize).saturating_sub(1),- };- self.grid.set_scroll_region(top, bottom);- }- 'h' => self.set_mode(params, private, true),- 'l' => self.set_mode(params, private, false),- 'c' => self.device_attrs(match intermediates.first() {- Some(b'>') => DaLevel::Secondary,- Some(b'=') => DaLevel::Tertiary,- _ => DaLevel::Primary,- }),- 'q' if intermediates.first() == Some(&b'>') => self.report_version(),- 'q' if intermediates.first() == Some(&b' ') => {- let code = raw(params, 0);- self.grid.set_cursor_shape(match code {- 3 | 4 => CursorShape::Underline,- 5 | 6 => CursorShape::Beam,- _ => CursorShape::Block,- });- // Even codes are steady; 0/1 and other odd codes blink.- self.grid.set_cursor_blink(code == 0 || code % 2 == 1);- }- 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),- 'n' => self.device_status(params),- 's' => self.grid.save_cursor(),- // `CSI u` is SCORC, but the kitty keyboard protocol overloads it with- // private prefixes: `?` query, `>` push, `<` pop, `=` set flags.- 'u' => match intermediates.first() {- Some(b'?') => self.report_kitty_flags(),- Some(b'>') => self.grid.kitty_push(n(params, 0, 0) as u8),- Some(b'<') => self.grid.kitty_pop(n(params, 0, 1)),- Some(b'=') => self- .grid- .kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),- _ => self.grid.restore_cursor(),- },- 't' => self.title_stack_op(params),- 'g' => match raw(params, 0) {- 3 => self.grid.clear_all_tabs(),- _ => self.grid.clear_tab(),- },- _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),- }- }-- fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {- match (intermediates.first().copied(), byte) {- (None, b'D') => self.grid.line_feed(),- (None, b'M') => self.grid.reverse_index(),- (None, b'E') => self.grid.next_line(),- (None, b'7') => self.grid.save_cursor(),- (None, b'8') => self.grid.restore_cursor(),- (None, b'H') => self.grid.set_tab(),- (None, b'c') => {- self.grid.reset_pen();- self.grid.set_scroll_region(0, self.grid.rows() - 1);- self.grid.set_autowrap(true);- self.grid.set_origin(false);- self.grid.erase_display(2);- self.grid.move_to(0, 0);- self.g0 = Charset::Ascii;- self.g1 = Charset::Ascii;- self.shift_out = false;- }- (Some(b'('), c) => self.g0 = charset(c),- (Some(b')'), c) => self.g1 = charset(c),- _ => {}- }- }-- fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {- match params.first() {- Some(&n) if n == b"0" || n == b"2" => {- if let Some(text) = params.get(1) {- self.title = Some(String::from_utf8_lossy(text).into_owned());- }- }- // OSC 7: the shell reports its cwd as a `file://host/path` URI.- Some(&n) if n == b"7" => {- if let Some(uri) = params.get(1) {- self.cwd = file_uri_path(uri);- }- }- // OSC 133: shell-integration prompt marks (A/B/C/D, with optional- // `;key=value` attributes we ignore).- Some(&n) if n == b"133" => {- if let Some(kind) = params- .get(1)- .and_then(|p| p.first())- .and_then(|&b| prompt_kind(b))- {- self.grid.set_prompt_mark(kind);- }- }- // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the- // link. The URI is everything after the second field, rejoined since- // a URI may itself contain ';'.- Some(&n) if n == b"8" => {- let uri_bytes = params- .get(2..)- .map(|parts| parts.join(&b';'))- .unwrap_or_default();- let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");- self.grid.set_link((!uri.is_empty()).then_some(uri));- }- // OSC 9: iTerm2-style notification (`OSC 9 ; body`).- Some(&n) if n == b"9" => {- if let Some(body) = osc_text(params.get(1)) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 777: `OSC 777 ; notify ; title ; body`.- Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {- let title = osc_text(params.get(2));- if let Some(body) = osc_text(params.get(3)) {- self.notifications.push(Notification { title, body });- }- }- // OSC 99: kitty desktop-notification protocol. We honour the common- // single-chunk form, taking the payload as the body and ignoring the- // metadata key=value field.- Some(&n) if n == b"99" => {- if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 4: set/query palette entries (pairs of index;spec).- Some(&n) if n == b"4" => self.osc_palette(params, bell),- // OSC 104: reset palette (all, or the listed indices).- Some(&n) if n == b"104" => {- if params.len() <= 1 {- self.theme.reset_palette();- } else {- for p in ¶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);- }- }-}@@ -1,883 +0,0 @@-use super::*;-use crate::bindings::MouseButton;--impl CompositorHandler for App {- fn scale_factor_changed(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- factor: i32,- ) {- // Integer fallback for compositors without fractional-scale-v1; ignored- // when the fractional-scale object drives the scale instead.- if self.fractional_scale.is_none() {- self.set_scale((factor.max(1) as u32) * 120);- }- }-- fn transform_changed(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: wl_output::Transform,- ) {- }-- fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {- // The compositor is ready for another frame; `flush` will repaint if the- // grid has changed since the last present.- self.frame_pending = false;- }-- fn surface_enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: &wl_output::WlOutput,- ) {- }-- fn surface_leave(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: &wl_output::WlOutput,- ) {- }-}--impl WindowHandler for App {- fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {- self.exit = true;- }-- fn configure(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &Window,- configure: WindowConfigure,- _serial: u32,- ) {- if let (Some(w), Some(h)) = configure.new_size {- self.width = w.get();- self.height = h.get();- if let Some(vp) = &self.viewport {- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- }- }- self.focused = configure.is_activated();- self.sync_idle_inhibit();- if self.session.is_none() {- self.spawn_session();- } else {- self.resize_grid();- }- self.needs_draw = true;- }-}--impl ShmHandler for App {- fn shm_state(&mut self) -> &mut Shm {- &mut self.shm- }-}--impl SeatHandler for App {- fn seat_state(&mut self) -> &mut SeatState {- &mut self.seat_state- }-- fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {- // Clipboard/primary devices are seat-scoped, not capability-scoped.- let i = self.seat_index(&seat);- self.ensure_clipboard_devices(qh, &seat, i);- }-- fn new_capability(- &mut self,- _: &Connection,- qh: &QueueHandle<Self>,- seat: wl_seat::WlSeat,- capability: Capability,- ) {- let i = self.seat_index(&seat);- // A pre-existing seat may reach us via its capabilities without a- // `new_seat` call, so make sure the clipboard devices are attached.- self.ensure_clipboard_devices(qh, &seat, i);- if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {- // get_keyboard_with_repeat drives key repeat off a calloop timer and- // delivers each repeat through the callback.- let loop_handle = self.loop_handle.clone();- let keyboard = self.seat_state.get_keyboard_with_repeat(- qh,- &seat,- None,- loop_handle,- Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),- );- match keyboard {- Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),- Err(err) => tracing::warn!("get keyboard: {err}"),- }- if self.seats[i].text_input.is_none()- && let Some(mgr) = self.text_input_manager.as_ref()- {- self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));- }- }- if capability == Capability::Pointer && self.seats[i].pointer.is_none() {- match self.seat_state.get_pointer(qh, &seat) {- Ok(pointer) => {- self.seats[i].cursor_shape_device = self- .cursor_shape_manager- .as_ref()- .map(|m| m.get_shape_device(&pointer, qh));- self.seats[i].pointer = Some(pointer);- }- Err(err) => tracing::warn!("get pointer: {err}"),- }- }- if capability == Capability::Touch && self.seats[i].touch.is_none() {- match self.seat_state.get_touch(qh, &seat) {- Ok(touch) => self.seats[i].touch = Some(touch),- Err(err) => tracing::warn!("get touch: {err}"),- }- }- }-- fn remove_capability(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- seat: wl_seat::WlSeat,- capability: Capability,- ) {- let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {- return;- };- match capability {- Capability::Keyboard => {- if let Some(keyboard) = s.keyboard.take() {- keyboard.release();- }- }- Capability::Pointer => {- s.cursor_shape_device = None;- if let Some(pointer) = s.pointer.take() {- pointer.release();- }- }- Capability::Touch => {- if let Some(touch) = s.touch.take() {- touch.release();- }- self.touch_scroll = None;- }- _ => {}- }- }-- fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {- self.seats.retain(|s| s.seat != seat);- self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));- }-}--impl KeyboardHandler for App {- fn enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- keyboard: &wl_keyboard::WlKeyboard,- _: &wl_surface::WlSurface,- serial: u32,- _: &[u32],- _: &[Keysym],- ) {- self.activate_keyboard(keyboard);- self.serial = serial;- self.focused = true;- self.sync_idle_inhibit();- self.report_focus(true);- self.needs_draw = true;- }-- fn leave(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: &wl_surface::WlSurface,- _: u32,- ) {- self.focused = false;- self.sync_idle_inhibit();- // Drop held-key state so a key released while unfocused can't leak a- // stale kitty release event later.- self.keys_down.clear();- self.report_focus(false);- self.needs_draw = true;- }-- fn press_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- keyboard: &wl_keyboard::WlKeyboard,- serial: u32,- event: KeyEvent,- ) {- self.activate_keyboard(keyboard);- self.serial = serial;- self.handle_key(&event);- }-- fn repeat_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- _: KeyEvent,- ) {- // Repeats are delivered through the get_keyboard_with_repeat callback;- // this non-calloop hook is unused.- }-- fn release_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- event: KeyEvent,- ) {- self.handle_key_release(&event);- }-- fn update_modifiers(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- modifiers: Modifiers,- _: RawModifiers,- _: u32,- ) {- self.modifiers = modifiers;- }-- fn update_repeat_info(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: RepeatInfo,- ) {- }-}--/// Parse a configured cursor-style name into a [`CursorShape`].-pub(super) fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {- match style? {- "block" => Some(CursorShape::Block),- "beam" | "bar" => Some(CursorShape::Beam),- "underline" => Some(CursorShape::Underline),- other => {- tracing::warn!("unknown cursor style {other:?}");- None- }- }-}--/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the-/// same length so prefix matching is unambiguous.-pub(super) fn hint_labels(n: usize) -> Vec<String> {- const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";- if n == 0 {- return Vec::new();- }- let (mut width, mut capacity) = (1usize, 26usize);- while capacity < n {- width += 1;- capacity *= 26;- }- (0..n)- .map(|i| {- let mut idx = i;- let mut chars = vec![b'a'; width];- for slot in chars.iter_mut().rev() {- *slot = ALPHABET[idx % 26];- idx /= 26;- }- String::from_utf8(chars).expect("ascii labels are valid utf-8")- })- .collect()-}--/// Map a Wayland button code to the terminal mouse base code, if reportable.-fn button_code(button: u32) -> Option<u8> {- match button {- BTN_LEFT => Some(0),- BTN_MIDDLE => Some(1),- BTN_RIGHT => Some(2),- _ => None,- }-}--/// Map a Wayland button code to a bindable [`MouseButton`].-fn mouse_button(button: u32) -> Option<MouseButton> {- match button {- BTN_LEFT => Some(MouseButton::Left),- BTN_MIDDLE => Some(MouseButton::Middle),- BTN_RIGHT => Some(MouseButton::Right),- _ => None,- }-}--impl PointerHandler for App {- fn pointer_frame(- &mut self,- _: &Connection,- qh: &QueueHandle<Self>,- pointer: &wl_pointer::WlPointer,- events: &[PointerEvent],- ) {- self.activate_pointer(pointer);- let cell_h = f64::from(self.renderer.metrics().height);- for event in events {- match &event.kind {- PointerEventKind::Enter { serial } => {- self.pointer_pos = event.position;- self.pointer_enter_serial = *serial;- self.update_hover(pointer);- self.pointer_drag();- }- PointerEventKind::Motion { .. } => {- self.pointer_pos = event.position;- if self.try_report_motion() {- continue;- }- if !self.selecting {- self.update_hover(pointer);- }- self.pointer_drag();- }- PointerEventKind::Press {- button,- serial,- time,- ..- } => {- self.serial = *serial;- self.pointer_pos = event.position;- if let Some(code) = button_code(*button)- && self.try_report_button(code, true)- {- self.pressed_button = Some(code);- continue;- }- // A configured `[mouse-bindings]` action (Middle defaults to- // primary paste) fires before the built-in left-drag select.- if let Some(mb) = mouse_button(*button)- && let Some(action) = self.bindings.mouse_action(mb, self.modifiers)- {- self.dispatch_action(action);- continue;- }- if *button == BTN_LEFT {- self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);- self.pointer_press(*time);- }- }- PointerEventKind::Release { button, .. } => {- self.pointer_pos = event.position;- let code = button_code(*button);- if let Some(code) = code- && self.try_report_button(code, false)- {- if self.pressed_button == Some(code) {- self.pressed_button = None;- }- continue;- }- if *button == BTN_LEFT {- self.maybe_open_clicked_link();- self.pointer_release(qh);- }- }- PointerEventKind::Axis { vertical, .. } => {- // Wheel notches arrive as value120 (÷120) or legacy discrete- // steps, ~3 lines each; touchpads send pixels per cell height.- let (raw, scale) = if vertical.value120 != 0 {- (f64::from(vertical.value120) / 120.0, 3.0)- } else if vertical.discrete != 0 {- (f64::from(vertical.discrete), 3.0)- } else if cell_h > 0.0 {- (vertical.absolute / cell_h, 1.0)- } else {- continue;- };- if raw == 0.0 {- continue;- }- let mult = self.config.mouse.scroll_multiplier.max(0.0);- let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;- let up = raw < 0.0;- // Reporting apps get wheel buttons (64 up / 65 down) as- // presses, one per line, capped so a flick cannot flood.- if self.mouse_reporting() {- let code = if up { 64 } else { 65 };- for _ in 0..lines.clamp(1, 8) {- self.try_report_button(code, true);- }- continue;- }- // On the alternate screen there is no scrollback to move, so- // (when enabled) translate the wheel into cursor-key presses- // for apps that did not request mouse reporting.- let alt = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().alt_active());- if alt && self.config.mouse.alternate_scroll {- self.alternate_scroll(up, lines.clamp(1, 8));- continue;- }- // Positive axis = scroll down (toward live); the viewport- // scrolls the opposite way (negative offset delta).- let delta = if up { lines } else { -lines };- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(delta);- self.needs_draw = true;- }- }- _ => {}- }- }- }-}--// Touch drives one-finger drag-to-scroll of the scrollback viewport. Taps and-// multi-finger gestures are ignored; only the first touch point is tracked.-impl TouchHandler for App {- fn down(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_touch::WlTouch,- _serial: u32,- _time: u32,- _surface: wl_surface::WlSurface,- id: i32,- position: (f64, f64),- ) {- if self.touch_scroll.is_none() {- self.touch_scroll = Some(TouchScroll {- id,- last_y: position.1,- acc: 0.0,- });- }- }-- fn up(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_touch::WlTouch,- _serial: u32,- _time: u32,- id: i32,- ) {- if self.touch_scroll.as_ref().is_some_and(|t| t.id == id) {- self.touch_scroll = None;- }- }-- fn motion(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_touch::WlTouch,- _time: u32,- id: i32,- position: (f64, f64),- ) {- let cell_h = self.renderer.metrics().height as f64;- let Some(touch) = self.touch_scroll.as_mut().filter(|t| t.id == id) else {- return;- };- touch.acc += position.1 - touch.last_y;- touch.last_y = position.1;- // Dragging down (positive delta) reveals older lines, matching the- // viewport's "positive scrolls back" convention.- let lines = (touch.acc / cell_h) as isize;- if lines != 0 {- touch.acc -= lines as f64 * cell_h;- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(lines);- self.needs_draw = true;- }- }- }-- fn shape(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_touch::WlTouch,- _: i32,- _: f64,- _: f64,- ) {- }-- fn orientation(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_touch::WlTouch,- _: i32,- _: f64,- ) {- }-- fn cancel(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_touch::WlTouch) {- self.touch_scroll = None;- }-}--impl OutputHandler for App {- fn output_state(&mut self) -> &mut OutputState {- &mut self.output_state- }-- fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-- fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-- fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-}--impl ProvidesRegistryState for App {- fn registry(&mut self) -> &mut RegistryState {- &mut self.registry_state- }- registry_handlers![OutputState, SeatState];-}--/// Serve the held clipboard text when a paste target requests it.-fn serve(text: &str, fd: WritePipe) {- let mut file = File::from(OwnedFd::from(fd));- let _ = file.write_all(text.as_bytes());-}--impl DataDeviceHandler for App {- fn enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &WlDataDevice,- _: f64,- _: f64,- _: &wl_surface::WlSurface,- ) {- }- fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}- fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}- fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}- fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}-}--impl DataOfferHandler for App {- fn source_actions(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &mut DragOffer,- _: DndAction,- ) {- }- fn selected_action(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &mut DragOffer,- _: DndAction,- ) {- }-}--impl DataSourceHandler for App {- fn accept_mime(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &WlDataSource,- _: Option<String>,- ) {- }-- fn send_request(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &WlDataSource,- _mime: String,- fd: WritePipe,- ) {- if self- .copy_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- serve(&self.clipboard, fd);- }- }-- fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {- if self- .copy_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- self.copy_source = None;- }- }-- fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}- fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}- fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}-}--impl PrimarySelectionDeviceHandler for App {- fn selection(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &ZwpPrimarySelectionDeviceV1,- ) {- }-}--impl PrimarySelectionSourceHandler for App {- fn send_request(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &ZwpPrimarySelectionSourceV1,- _mime: String,- fd: WritePipe,- ) {- if self- .primary_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- serve(&self.primary_clip, fd);- }- }-- fn cancelled(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &ZwpPrimarySelectionSourceV1,- ) {- if self- .primary_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- self.primary_source = None;- }- }-}--// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by-// hand. Only the fractional-scale object carries an event we act on.-impl Dispatch<WpFractionalScaleV1, ()> for App {- fn event(- state: &mut Self,- _: &WpFractionalScaleV1,- event: wp_fractional_scale_v1::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {- state.set_scale(scale);- }- }-}--impl Dispatch<WpFractionalScaleManagerV1, ()> for App {- fn event(- _: &mut Self,- _: &WpFractionalScaleManagerV1,- _: <WpFractionalScaleManagerV1 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpViewporter, ()> for App {- fn event(- _: &mut Self,- _: &WpViewporter,- _: <WpViewporter as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpViewport, ()> for App {- fn event(- _: &mut Self,- _: &WpViewport,- _: <WpViewport as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--// idle-inhibit and content-type are likewise raw protocol objects; none of them-// emit events we act on.-impl Dispatch<ZwpIdleInhibitManagerV1, ()> for App {- fn event(- _: &mut Self,- _: &ZwpIdleInhibitManagerV1,- _: <ZwpIdleInhibitManagerV1 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<ZwpIdleInhibitorV1, ()> for App {- fn event(- _: &mut Self,- _: &ZwpIdleInhibitorV1,- _: zwp_idle_inhibitor_v1::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpContentTypeManagerV1, ()> for App {- fn event(- _: &mut Self,- _: &WpContentTypeManagerV1,- _: <WpContentTypeManagerV1 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpContentTypeV1, ()> for App {- fn event(- _: &mut Self,- _: &WpContentTypeV1,- _: wp_content_type_v1::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl ActivationHandler for App {- type RequestData = RequestData;-- fn new_token(&mut self, token: String, _: &RequestData) {- // The compositor granted an activation token; use it to draw attention.- if let Some(activation) = self.activation.as_ref() {- activation.activate::<App>(self.window.wl_surface(), token);- }- }-}--impl Dispatch<ZwpTextInputManagerV3, ()> for App {- fn event(- _: &mut Self,- _: &ZwpTextInputManagerV3,- _: <ZwpTextInputManagerV3 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the-// accumulated transaction on `done` and re-enable on focus enter.-impl Dispatch<ZwpTextInputV3, ()> for App {- fn event(- state: &mut Self,- ti: &ZwpTextInputV3,- event: zwp_text_input_v3::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- use zwp_text_input_v3::Event;- match event {- Event::Enter { .. } => {- ti.enable();- ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);- state.ime_set_cursor_rect(ti);- ti.commit();- }- Event::Leave { .. } => {- ti.disable();- ti.commit();- state.preedit.clear();- state.ime_preedit_pending.clear();- state.ime_commit_pending.clear();- state.needs_draw = true;- }- Event::PreeditString { text, .. } => {- state.ime_preedit_pending = text.unwrap_or_default();- }- Event::CommitString { text } => {- state.ime_commit_pending.push_str(&text.unwrap_or_default());- }- Event::Done { .. } => state.ime_done(ti),- // We do not expose surrounding text, so nothing to delete.- Event::DeleteSurroundingText { .. } => {}- _ => {}- }- }-}--delegate_compositor!(App);-delegate_output!(App);-delegate_shm!(App);-delegate_seat!(App);-delegate_keyboard!(App);-delegate_pointer!(App);-delegate_touch!(App);-delegate_xdg_shell!(App);-delegate_xdg_window!(App);-delegate_data_device!(App);-delegate_primary_selection!(App);-delegate_registry!(App);-delegate_activation!(App);@@ -1,1878 +0,0 @@-//! Wayland front-end: connection, surface, and software-drawn window.-//!-//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the-//! event loop, so the PTY master fd and timers share one loop.--mod handlers;-mod rendering;--use std::fs::File;-use std::io::{Read as _, Write as _};-use std::num::NonZeroU16;-use std::os::fd::OwnedFd;-use std::os::unix::process::ExitStatusExt;-use std::process::ExitCode;--use std::time::Duration;--use anyhow::Context;-use calloop::generic::Generic;-use calloop::timer::{TimeoutAction, Timer};-use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken};-use calloop_wayland_source::WaylandSource;--use crate::config::Config;-use crate::font::Fonts;-use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit};-use crate::pty::Pty;-use crate::render::Renderer;-use crate::vt::Term;-use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{- Shape, WpCursorShapeDeviceV1,-};-use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{- zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,- zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,-};-use smithay_client_toolkit::{- activation::{ActivationHandler, ActivationState, RequestData},- compositor::{CompositorHandler, CompositorState},- data_device_manager::{- DataDeviceManagerState, WritePipe,- data_device::{DataDevice, DataDeviceHandler},- data_offer::{DataOfferHandler, DragOffer},- data_source::{CopyPasteSource, DataSourceHandler},- },- delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard,- delegate_output,- delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,- delegate_touch, delegate_xdg_shell, delegate_xdg_window,- output::{OutputHandler, OutputState},- primary_selection::{- PrimarySelectionManagerState,- device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},- selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},- },- registry::{ProvidesRegistryState, RegistryState},- registry_handlers,- seat::{- Capability, SeatHandler, SeatState,- keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},- pointer::{- BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,- cursor_shape::CursorShapeManager,- },- touch::TouchHandler,- },- shell::{- WaylandSurface,- xdg::{- XdgShell,- window::{Window, WindowConfigure, WindowDecorations, WindowHandler},- },- },- shm::{- Shm, ShmHandler,- slot::{Buffer, SlotPool},- },-};-use wayland_client::{- Connection, Dispatch, Proxy, QueueHandle,- globals::{GlobalList, registry_queue_init},- protocol::{- wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,- wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,- wl_surface, wl_touch,- },-};-use wayland_protocols::wp::content_type::v1::client::{- wp_content_type_manager_v1::WpContentTypeManagerV1,- wp_content_type_v1::{self, WpContentTypeV1},-};-use wayland_protocols::wp::fractional_scale::v1::client::{- wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,- wp_fractional_scale_v1::{self, WpFractionalScaleV1},-};-use wayland_protocols::wp::idle_inhibit::zv1::client::{- zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1,- zwp_idle_inhibitor_v1::{self, ZwpIdleInhibitorV1},-};-use wayland_protocols::wp::text_input::zv3::client::{- zwp_text_input_manager_v3::ZwpTextInputManagerV3,- zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3},-};-use wayland_protocols::wp::viewporter::client::{- wp_viewport::WpViewport, wp_viewporter::WpViewporter,-};--use handlers::{cursor_shape_from, hint_labels};-use rendering::FrameBuf;--/// MIME types beer offers and accepts for clipboard text.-const TEXT_MIMES: &[&str] = &[- "text/plain;charset=utf-8",- "text/plain;charset=UTF-8",- "UTF8_STRING",- "STRING",- "text/plain",- "TEXT",-];--/// Pick the first MIME type we understand from an offer's advertised set.-fn pick_mime(mimes: &[String]) -> Option<String> {- mimes- .iter()- .find(|m| TEXT_MIMES.contains(&m.as_str()))- .cloned()-}--/// Max gap between clicks counted as a multi-click (ms).-const MULTI_CLICK_MS: u32 = 400;--/// Blink half-period: cells/cursor toggle visibility this often.-const BLINK_MS: u64 = 500;--/// Buffers kept for double/triple buffering before we wait for a release.-const MAX_BUFFERS: usize = 3;--/// How long synchronized output (DECSET 2026) may hold the screen before we-/// present anyway, so a misbehaving app cannot freeze the window.-const SYNC_TIMEOUT_MS: u64 = 150;--/// Interval between autoscroll steps while a drag selection runs off an edge.-const AUTOSCROLL_MS: u64 = 40;--/// How long the visual bell inverts the screen.-const FLASH_MS: u64 = 80;--/// Fallback window size in pixels if the configured geometry yields nothing.-const DEFAULT_W: u32 = 800;-const DEFAULT_H: u32 = 600;--/// Run a single window until it is closed, returning the shell's exit code.-pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {- let conn = Connection::connect_to_env().context("connect to Wayland compositor")?;- let (globals, event_queue) =- registry_queue_init(&conn).context("initialize Wayland registry")?;- let qh = event_queue.handle();-- let mut event_loop: EventLoop<App> =- EventLoop::try_new().context("create calloop event loop")?;- WaylandSource::new(conn, event_queue)- .insert(event_loop.handle())- .map_err(|e| anyhow::anyhow!("insert Wayland source into event loop: {e}"))?;-- let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;- let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;- let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;- let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)- .context("wl_data_device_manager not available")?;- let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();- let cursor_shape_manager = CursorShapeManager::bind(&globals, &qh).ok();-- let surface = compositor.create_surface(&qh);- let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);- window.set_title("beer");- window.set_app_id("dev.notashelf.beer");- window.set_min_size(Some((1, 1)));-- // Decorrelate buffer pixels from surface size so we can render at the- // compositor's preferred fractional scale (crisp glyphs on a 150% output)- // and present at the logical size. Both are optional; without them the- // window falls back to integer buffer scaling via `scale_factor_changed`.- let viewport = bind_global::<WpViewporter>(&globals, &qh)- .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ()));- // Fractional scaling needs a viewport to present the scaled buffer back at- // the logical size; without one we can only do integer buffer scaling.- let fractional_scale = viewport.as_ref().and_then(|_| {- bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)- .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))- });- let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &qh);- let activation = ActivationState::bind(&globals, &qh).ok();- let idle_inhibit_manager = bind_global::<ZwpIdleInhibitManagerV1>(&globals, &qh);- // Tag the surface as plain content (a terminal is none of photo/video/game)- // so the compositor applies no media-specific treatment. Applies on commit.- let content_type = bind_global::<WpContentTypeManagerV1>(&globals, &qh)- .map(|mgr| mgr.get_surface_content_type(window.wl_surface(), &qh, ()));- if let Some(ct) = &content_type {- ct.set_content_type(wp_content_type_v1::Type::None);- }-- // First commit with no buffer kicks off the initial configure.- window.commit();-- let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?;- let mut renderer = Renderer::new(fonts);- renderer.set_padding(config.main.pad_x, config.main.pad_y);-- // Start at the configured cell geometry plus padding; the compositor may- // override it on the first configure.- let m = renderer.metrics();- let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1);- let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);- let pool = SlotPool::new(- (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,- &shm,- )- .context("create shm slot pool")?;-- let bindings = crate::bindings::Bindings::from_config(- &config.key_bindings,- &config.text_bindings,- &config.mouse_bindings,- );- let font_size = config.main.font_size;-- let mut app = App {- registry_state: RegistryState::new(&globals),- output_state: OutputState::new(&globals, &qh),- seat_state: SeatState::new(&globals, &qh),- shm,- pool,- window,- renderer,- loop_handle: event_loop.handle(),- qh: qh.clone(),- data_device_manager,- primary_manager,- cursor_shape_manager,- text_input_manager,- activation,- idle_inhibit_manager,- idle_inhibitor: None,- content_type,- preedit: String::new(),- ime_preedit_pending: String::new(),- ime_commit_pending: String::new(),- viewport,- fractional_scale,- scale120: 120,- seats: Vec::new(),- active_seat: 0,- copy_source: None,- primary_source: None,- clipboard: String::new(),- primary_clip: String::new(),- selecting: false,- hovered_link: None,- pointer_enter_serial: 0,- press_cell: None,- pressed_button: None,- last_report_cell: None,- autoscroll: 0,- autoscroll_timer: None,- pointer_pos: (0.0, 0.0),- last_click: None,- serial: 0,- modifiers: Modifiers::default(),- // The PTY is spawned on the first configure, once the real window size- // is known, so the shell starts at the final size and is not hit by a- // startup SIGWINCH storm that makes it reprint its prompt.- session: None,- title: None,- config,- config_path,- bindings,- font_size,- fullscreen: false,- width,- height,- needs_draw: false,- frame_pending: false,- frames: Vec::new(),- buf_dims: (0, 0),- blink_on: true,- sync_timeout: None,- flashing: false,- flash_timer: None,- searching: false,- url_mode: false,- url_hits: Vec::new(),- url_labels: Vec::new(),- url_input: String::new(),- unicode_input: None,- keys_down: std::collections::HashSet::new(),- touch_scroll: None,- focused: true,- exit: false,- exit_code: ExitCode::SUCCESS,- };-- // Toggle the blink phase on a timer so blinking text and cursors animate.- let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));- let blink_registered = event_loop- .handle()- .insert_source(blink, |_, _, app: &mut App| {- app.blink_on = !app.blink_on;- app.needs_draw = true;- TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))- });- if let Err(err) = blink_registered {- tracing::warn!("register blink timer: {err}");- }-- // SIGUSR1 reloads the config in place.- match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {- Ok(signals) => {- let registered = event_loop- .handle()- .insert_source(signals, |_, _, app: &mut App| app.reload_config());- if let Err(err) = registered {- tracing::warn!("register signal source: {err}");- }- }- Err(err) => tracing::warn!("install SIGUSR1 handler: {err}"),- }-- // Each iteration blocks until an event (PTY output, input, configure, frame- // callback, blink) arrives, then presents at most one frame; bursts of PTY- // output between frame callbacks coalesce into a single repaint.- while !app.exit {- event_loop- .dispatch(None, &mut app)- .context("dispatch event loop")?;- app.flush();- }- Ok(app.exit_code)-}--/// Bind a singleton global at version 1 with `()` user-data, or `None` if the-/// compositor does not advertise it.-fn bind_global<I>(globals: &GlobalList, qh: &QueueHandle<App>) -> Option<I>-where- I: Proxy + 'static,- App: Dispatch<I, ()>,-{- match globals.bind(qh, 1..=1, ()) {- Ok(global) => Some(global),- Err(err) => {- tracing::debug!("bind {}: {err}", I::interface().name);- None- }- }-}--/// Columns and rows that fit a `width`×`height` px window at `metrics`, after-/// reserving `2 * pad` pixels of inner padding on each axis.-fn grid_size(- metrics: crate::font::CellMetrics,- width: u32,- height: u32,- pad: (u32, u32),-) -> (u16, u16) {- let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1);- let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1);- (cols as u16, rows as u16)-}--/// Write every byte to `fd`, retrying short writes and interrupts.-fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {- while !buf.is_empty() {- match rustix::io::write(fd, buf) {- Ok(0) => return Err(rustix::io::Errno::IO),- Ok(n) => buf = &buf[n..],- Err(rustix::io::Errno::INTR) => {}- Err(e) => return Err(e),- }- }- Ok(())-}--/// The per-window terminal: the PTY and the parsed screen behind it. Created on-/// the first configure, once the real size is known.-#[derive(Debug)]-struct Session {- pty: Pty,- term: Term,-}--/// Input devices for one seat. Several seats can drive the single window; the-/// most recently used one owns clipboard/primary claims.-#[derive(Debug)]-struct SeatData {- seat: wl_seat::WlSeat,- keyboard: Option<wl_keyboard::WlKeyboard>,- pointer: Option<wl_pointer::WlPointer>,- cursor_shape_device: Option<WpCursorShapeDeviceV1>,- data_device: Option<DataDevice>,- primary_device: Option<PrimarySelectionDevice>,- /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.- text_input: Option<ZwpTextInputV3>,- touch: Option<wl_touch::WlTouch>,-}--/// A single-finger touch drag in progress, used to scroll the viewport.-#[derive(Debug)]-struct TouchScroll {- /// Touch point id we are tracking (the first finger down).- id: i32,- /// Surface-local y of the last motion, to take per-event deltas.- last_y: f64,- /// Sub-cell pixel remainder carried between motions.- acc: f64,-}--/// Window + Wayland client state shared across all protocol handlers.-#[derive(Debug)]-struct App {- registry_state: RegistryState,- output_state: OutputState,- seat_state: SeatState,- shm: Shm,- pool: SlotPool,- window: Window,- renderer: Renderer,- loop_handle: LoopHandle<'static, App>,- qh: QueueHandle<App>,- data_device_manager: DataDeviceManagerState,- primary_manager: Option<PrimarySelectionManagerState>,- /// Sets the pointer to an I-beam over the window (cursor-shape-v1).- cursor_shape_manager: Option<CursorShapeManager>,- /// IME manager (text-input-v3); per-seat handles live in `seats`.- text_input_manager: Option<ZwpTextInputManagerV3>,- /// xdg-activation, used to request attention on an urgent bell.- activation: Option<ActivationState>,- /// idle-inhibit-v1 manager; an inhibitor is held while focused when the- /// `[main] idle-inhibit` config is on, so the screen does not blank.- idle_inhibit_manager: Option<ZwpIdleInhibitManagerV1>,- idle_inhibitor: Option<ZwpIdleInhibitorV1>,- /// content-type-v1 hint object. Set once at startup; held only so the- /// object (and thus the hint) outlives construction.- #[allow(- dead_code,- reason = "kept alive to preserve the surface content-type hint"- )]- content_type: Option<WpContentTypeV1>,- /// Committed IME preedit string shown inline at the cursor while composing.- preedit: String,- /// Preedit/commit accumulated since the last text-input `done`.- ime_preedit_pending: String,- ime_commit_pending: String,- /// Presents a scaled buffer at the logical surface size (viewporter).- viewport: Option<WpViewport>,- /// Per-surface fractional-scale object; kept alive to receive scale events.- fractional_scale: Option<WpFractionalScaleV1>,- /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5).- scale120: u32,- /// One entry per seat; `active_seat` indexes the most recently used.- seats: Vec<SeatData>,- active_seat: usize,- /// Held while we own the clipboard / primary selection, serving paste reads.- copy_source: Option<CopyPasteSource>,- primary_source: Option<PrimarySelectionSource>,- clipboard: String,- primary_clip: String,- /// A left-button drag is in progress.- selecting: bool,- /// OSC 8 hyperlink under the pointer, underlined and opened on click.- hovered_link: Option<NonZeroU16>,- /// Serial of the last pointer enter, reused to update the cursor shape.- pointer_enter_serial: u32,- /// Cell `(abs_row, col)` of the last left-press, for click-to-open links.- press_cell: Option<(usize, usize)>,- /// Button base code held down while mouse reporting, for drag reports.- pressed_button: Option<u8>,- /// Last cell a motion report was emitted for, to suppress duplicates.- last_report_cell: Option<(usize, usize)>,- /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live.- autoscroll: isize,- /// Calloop token for the repeating autoscroll timer, when armed.- autoscroll_timer: Option<RegistrationToken>,- pointer_pos: (f64, f64),- /// Last click (time ms, abs row, col, count) for double/triple detection.- last_click: Option<(u32, usize, usize, u32)>,- /// Most recent input serial, used to claim selections.- serial: u32,- modifiers: Modifiers,- /// `None` until the first configure spawns the shell.- session: Option<Session>,- /// Last title applied to the toplevel, to avoid redundant requests.- title: Option<String>,- /// The active user configuration.- config: Config,- /// Path the config was loaded from, for SIGUSR1 live reload.- config_path: Option<std::path::PathBuf>,- /// Resolved key/text bindings.- bindings: crate::bindings::Bindings,- /// Current font size in pixels (changed by font-resize bindings).- font_size: u32,- /// Whether the toplevel is fullscreen.- fullscreen: bool,- width: u32,- height: u32,- /// The grid changed and the window wants repainting on the next frame.- needs_draw: bool,- /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.- frame_pending: bool,- /// Double/triple-buffer ring, each tagged with the rows it currently shows.- frames: Vec<FrameBuf>,- /// Pixel size the `frames` buffers were allocated for.- buf_dims: (u32, u32),- /// Current blink phase, toggled by a timer; off hides blinking ink.- blink_on: bool,- /// Armed while synchronized output holds the screen, to force it open.- sync_timeout: Option<RegistrationToken>,- /// The visual bell is inverting the screen.- flashing: bool,- /// Timer that ends the visual-bell flash.- flash_timer: Option<RegistrationToken>,- /// Whether incremental search mode is active (the query lives in the grid).- searching: bool,- /// URL hint mode: detected URLs get keyboard labels to open them.- url_mode: bool,- /// Detected URLs and their hint labels (parallel), while `url_mode` is on.- url_hits: Vec<UrlHit>,- url_labels: Vec<String>,- /// Label characters typed so far in URL mode.- url_input: String,- /// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.- unicode_input: Option<String>,- /// Raw key codes currently held, to tell press from repeat for the kitty- /// keyboard protocol's event-type reporting.- keys_down: std::collections::HashSet<u32>,- /// A single-finger touch drag in progress (scrolls the viewport).- touch_scroll: Option<TouchScroll>,- /// Whether the toplevel currently has keyboard focus (drives the cursor).- focused: bool,- exit: bool,- /// Exit code to return, taken from the shell when it exits.- exit_code: ExitCode,-}--impl App {- /// Spawn the shell at the current window size and start reading its output.- fn spawn_session(&mut self) {- let (cols, rows) = self.grid_dims();- let pty = match Pty::spawn(cols, rows, &self.config.main.term) {- Ok(pty) => pty,- Err(err) => {- tracing::error!("spawn shell: {err:#}");- self.exit = true;- return;- }- };- let read_fd = match pty.master().try_clone() {- Ok(fd) => fd,- Err(err) => {- tracing::error!("clone pty master: {err}");- self.exit = true;- return;- }- };-- let mut parser = vte::Parser::new();- let source = Generic::new(read_fd, Interest::READ, Mode::Level);- let registered = self- .loop_handle- .insert_source(source, move |_, fd, app: &mut App| {- let mut buf = [0u8; 4096];- let n = match rustix::io::read(&*fd, &mut buf) {- Ok(0) => {- app.child_exited();- return Ok(PostAction::Remove);- }- Ok(n) => n,- Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => {- return Ok(PostAction::Continue);- }- Err(_) => {- app.child_exited();- return Ok(PostAction::Remove);- }- };- if let Some(session) = app.session.as_mut() {- parser.advance(&mut session.term, &buf[..n]);- }- app.after_feed();- Ok(PostAction::Continue)- });- if let Err(err) = registered {- tracing::error!("register pty in event loop: {err}");- self.exit = true;- return;- }-- let mut term = Term::new(cols as usize, rows as usize);- term.set_theme(crate::theme::Theme::from_config(&self.config.colors));- let grid = term.grid_mut();- grid.set_word_delimiters(self.config.main.word_delimiters.clone());- grid.set_scrollback_cap(self.config.scrollback.lines);- if let Some(shape) = cursor_shape_from(self.config.cursor.style.as_deref()) {- grid.set_cursor_shape(shape);- }- grid.set_cursor_blink(self.config.cursor.blink);- self.session = Some(Session { pty, term });- }-- /// Handle a key (initial press or repeat): configured bindings first, then- /// text bindings, else the byte encoding sent to the shell (which snaps the- /// viewport back to the live screen).- fn handle_key(&mut self, event: &KeyEvent) {- // A new arrival of a held key is a repeat; otherwise a fresh press.- let kind = if self.keys_down.insert(event.raw_code) {- crate::input::KeyKind::Press- } else {- crate::input::KeyKind::Repeat- };-- // The Unicode-input prompt, URL hint mode, and search each capture the- // keyboard while active.- if self.unicode_input.is_some() {- self.unicode_key(event);- return;- }- if self.url_mode {- self.url_key(event);- return;- }- if self.searching {- self.search_key(event);- return;- }- if let Some(action) = self.bindings.action(event, self.modifiers) {- self.dispatch_action(action);- return;- }- if let Some(text) = self.bindings.text(event, self.modifiers) {- let bytes = text.to_vec();- self.send_to_shell(&bytes);- return;- }-- let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {- (s.term.grid().app_cursor(), s.term.grid().kitty_flags())- });- let bytes = if kitty != 0 {- crate::input::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)- } else {- crate::input::encode(event, self.modifiers, app_cursor)- };- if let Some(bytes) = bytes {- self.send_to_shell(&bytes);- }- }-- /// Handle a key release: only the kitty keyboard protocol cares, and only- /// when it has asked for event reporting.- fn handle_key_release(&mut self, event: &KeyEvent) {- self.keys_down.remove(&event.raw_code);- let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {- (s.term.grid().app_cursor(), s.term.grid().kitty_flags())- });- if kitty == 0 {- return;- }- if let Some(bytes) = crate::input::kitty_encode(- event,- self.modifiers,- kitty,- crate::input::KeyKind::Release,- app_cursor,- ) {- self.send_to_shell(&bytes);- }- }-- /// Write key/text bytes to the shell, snapping the viewport to the live- /// screen and clearing any selection first.- fn send_to_shell(&mut self, bytes: &[u8]) {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- session.term.grid_mut().clear_selection();- self.needs_draw = true;- if let Err(err) = write_all(session.pty.master(), bytes) {- tracing::warn!("write key to pty: {err}");- }- }- }-- /// Run a bound editor action.- fn dispatch_action(&mut self, action: crate::bindings::Action) {- use crate::bindings::Action;- match action {- Action::Copy => {- let qh = self.qh.clone();- self.set_clipboard(&qh);- }- Action::Paste => self.paste_clipboard(),- Action::PastePrimary => self.paste_primary(),- Action::ScrollPageUp => self.scroll_page(true),- Action::ScrollPageDown => self.scroll_page(false),- Action::ScrollTop => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(isize::MAX);- self.needs_draw = true;- }- }- Action::ScrollBottom => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- self.needs_draw = true;- }- }- Action::SearchStart => self.toggle_search(),- Action::FontIncrease => self.change_font_size(self.font_size + 1),- Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),- Action::FontReset => self.change_font_size(self.config.main.font_size),- Action::Fullscreen => self.toggle_fullscreen(),- Action::NewWindow => self.spawn_new_window(),- Action::JumpPromptUp => self.jump_prompt(true),- Action::JumpPromptDown => self.jump_prompt(false),- Action::PipeCommandOutput => self.pipe_command_output(),- Action::UrlMode => self.enter_url_mode(),- Action::UnicodeInput => {- self.unicode_input = Some(String::new());- self.needs_draw = true;- }- }- }-- /// Handle a key while Unicode codepoint-input mode is active: accumulate hex- /// digits, then commit the codepoint as UTF-8 on Enter/Space.- fn unicode_key(&mut self, event: &KeyEvent) {- match event.keysym {- Keysym::Escape => {- self.unicode_input = None;- self.needs_draw = true;- }- Keysym::BackSpace => {- if let Some(buf) = self.unicode_input.as_mut() {- buf.pop();- }- self.needs_draw = true;- }- Keysym::Return | Keysym::KP_Enter | Keysym::space => {- let buf = self.unicode_input.take().unwrap_or_default();- if let Some(c) = u32::from_str_radix(buf.trim(), 16)- .ok()- .and_then(char::from_u32)- {- let mut bytes = [0u8; 4];- let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();- self.send_to_shell(&s);- }- self.needs_draw = true;- }- _ => {- if let Some(text) = event.utf8.as_ref() {- let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();- // Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.- if let Some(buf) = self.unicode_input.as_mut()- && buf.len() + hex.len() <= 6- {- buf.push_str(&hex);- }- self.needs_draw = true;- }- }- }- }-- /// Enter URL hint mode: detect the visible URLs and label them. No-op (with- /// a brief log) when there are none.- fn enter_url_mode(&mut self) {- let Some(session) = self.session.as_ref() else {- return;- };- let hits = session.term.grid().visible_urls();- if hits.is_empty() {- return;- }- self.url_labels = hint_labels(hits.len());- self.url_hits = hits;- self.url_input = String::new();- self.url_mode = true;- self.needs_draw = true;- }-- /// Leave URL hint mode, discarding any partial label input.- fn exit_url_mode(&mut self) {- self.url_mode = false;- self.url_hits.clear();- self.url_labels.clear();- self.url_input.clear();- // Drop the labelled buffers so the next present repaints without labels.- self.frames.clear();- self.needs_draw = true;- }-- /// Handle a key while URL hint mode is active: build up a label, open the- /// matching URL, or cancel.- fn url_key(&mut self, event: &KeyEvent) {- match event.keysym {- Keysym::Escape => self.exit_url_mode(),- Keysym::BackSpace => {- self.url_input.pop();- self.needs_draw = true;- }- _ => {- let Some(text) = event.utf8.as_ref() else {- return;- };- for c in text.chars().filter(|c| c.is_ascii_alphabetic()) {- self.url_input.push(c.to_ascii_lowercase());- }- // Exact match opens; if no label even has this prefix, cancel.- if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) {- let url = self.url_hits[i].url.clone();- self.exit_url_mode();- self.open_url(&url);- } else if !self- .url_labels- .iter()- .any(|l| l.starts_with(&self.url_input))- {- self.exit_url_mode();- } else {- self.needs_draw = true;- }- }- }- }-- /// Scroll the viewport to the previous/next shell prompt (OSC 133).- fn jump_prompt(&mut self, up: bool) {- if let Some(session) = self.session.as_mut() {- session.term.grid_mut().jump_prompt(up);- self.needs_draw = true;- }- }-- /// Feed the last command's output (between OSC 133 C and D) to the configured- /// command on stdin.- fn pipe_command_output(&mut self) {- let argv = &self.config.shell_integration.pipe_command;- let Some((program, args)) = argv.split_first() else {- tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured");- return;- };- let Some(text) = self- .session- .as_ref()- .and_then(|s| s.term.grid().last_command_output())- else {- return;- };- let mut cmd = std::process::Command::new(program);- cmd.args(args)- .stdin(std::process::Stdio::piped())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {- cmd.current_dir(cwd);- }- match cmd.spawn() {- Ok(mut child) => {- if let Some(mut stdin) = child.stdin.take() {- let _ = stdin.write_all(text.as_bytes());- }- }- Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),- }- }-- /// Launch another beer process in the shell's reported working directory- /// (OSC 7), inheriting the same config. The child is fully detached.- fn spawn_new_window(&mut self) {- let exe = match std::env::current_exe() {- Ok(exe) => exe,- Err(err) => {- tracing::warn!("locate beer executable: {err}");- return;- }- };- let mut cmd = std::process::Command::new(exe);- if let Some(path) = self.config_path.as_ref() {- cmd.arg("--config").arg(path);- }- if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {- cmd.current_dir(cwd);- }- cmd.stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Err(err) = cmd.spawn() {- tracing::warn!("spawn new window: {err}");- }- }-- /// Send `count` cursor-up/down keys to the shell for alternate-scroll,- /// honouring the application cursor-key mode (DECCKM).- fn alternate_scroll(&mut self, up: bool, count: isize) {- let app_cursor = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().app_cursor());- let seq: &[u8] = match (up, app_cursor) {- (true, false) => b"\x1b[A",- (true, true) => b"\x1bOA",- (false, false) => b"\x1b[B",- (false, true) => b"\x1bOB",- };- for _ in 0..count {- self.write_to_pty(seq);- }- }-- /// Scroll the viewport one page back (`up`) or toward the live screen.- fn scroll_page(&mut self, up: bool) {- if let Some(session) = self.session.as_mut() {- let page = session.term.page() as isize;- session.term.scroll_view(if up { page } else { -page });- self.needs_draw = true;- }- }-- /// Toggle the toplevel between fullscreen and windowed.- fn toggle_fullscreen(&mut self) {- self.fullscreen = !self.fullscreen;- if self.fullscreen {- self.window.set_fullscreen(None);- } else {- self.window.unset_fullscreen();- }- }-- /// Re-read the config file and apply it in place (SIGUSR1).- fn reload_config(&mut self) {- let new = Config::load(self.config_path.as_deref());- self.bindings = crate::bindings::Bindings::from_config(- &new.key_bindings,- &new.text_bindings,- &new.mouse_bindings,- );- let font_changed = new.main.font != self.config.main.font- || new.main.font_size != self.config.main.font_size;- if font_changed {- self.font_size = new.main.font_size;- }- self.config.main.font = new.main.font.clone();- self.config.main.font_size = new.main.font_size;- self.config.main.pad_x = new.main.pad_x;- self.config.main.pad_y = new.main.pad_y;- // Re-rasterize at the active scale and update padding in one place.- self.rescale_render();- if let Some(session) = self.session.as_mut() {- session- .term- .set_theme(crate::theme::Theme::from_config(&new.colors));- let grid = session.term.grid_mut();- grid.set_word_delimiters(new.main.word_delimiters.clone());- grid.set_scrollback_cap(new.scrollback.lines);- if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {- grid.set_cursor_shape(shape);- }- grid.set_cursor_blink(new.cursor.blink);- }- self.config = new;- self.frames.clear();- self.resize_grid();- self.needs_draw = true;- tracing::info!("config reloaded");- }-- /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.- fn change_font_size(&mut self, new_size: u32) {- let new_size = new_size.clamp(6, 200);- if new_size == self.font_size {- return;- }- self.font_size = new_size;- self.rescale_render();- self.frames.clear();- self.resize_grid();- self.needs_draw = true;- }-- /// Enter or leave incremental search mode.- fn toggle_search(&mut self) {- self.searching = !self.searching;- if let Some(session) = self.session.as_mut() {- if self.searching {- session.term.grid_mut().set_search("");- } else {- session.term.grid_mut().clear_search();- }- }- self.needs_draw = true;- }-- /// Handle a key while search mode is active: edit the query incrementally,- /// step between matches, or exit.- fn search_key(&mut self, event: &KeyEvent) {- let Some(session) = self.session.as_mut() else {- return;- };- let grid = session.term.grid_mut();- match event.keysym {- Keysym::Escape => {- grid.clear_search();- self.searching = false;- }- Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {- grid.search_step(false);- }- Keysym::Down | Keysym::Page_Down => grid.search_step(true),- Keysym::BackSpace => {- let mut query = grid.search_query().unwrap_or("").to_string();- query.pop();- grid.set_search(&query);- }- _ => {- // Append typed text, ignoring control characters.- if let Some(text) = event.utf8.as_ref() {- let printable: String = text.chars().filter(|c| !c.is_control()).collect();- if !printable.is_empty() {- let mut query = grid.search_query().unwrap_or("").to_string();- query.push_str(&printable);- grid.set_search(&query);- }- }- }- }- self.needs_draw = true;- }-- /// The OSC 8 hyperlink id under the pointer, if any.- fn link_under_pointer(&self) -> Option<NonZeroU16> {- let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;- self.session.as_ref()?.term.grid().link_at(row, col)- }-- /// Recompute the hyperlink under the pointer; when it changes, repaint to- /// move the hover underline and update the pointer to a hand over a link.- fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {- let link = self.link_under_pointer();- if link == self.hovered_link {- return;- }- self.hovered_link = link;- // The hover underline lives in every buffer's snapshot; drop the ring so- // the affected rows repaint with (or without) it.- self.frames.clear();- self.needs_draw = true;- let shape = if link.is_some() {- Shape::Pointer- } else {- Shape::Text- };- if let Some(device) = self- .seats- .iter()- .find(|s| s.pointer.as_ref() == Some(pointer))- .and_then(|s| s.cursor_shape_device.as_ref())- {- device.set_shape(self.pointer_enter_serial, shape);- }- }-- /// If the left button was pressed and released on the same hyperlinked cell- /// (a click, not a drag), open the link.- fn maybe_open_clicked_link(&mut self) {- let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);- let Some((row, col)) = release else { return };- if self.press_cell != Some((row, col)) {- return;- }- let uri = self- .session- .as_ref()- .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))- .and_then(|(s, id)| s.term.grid().link_uri(id))- .map(str::to_owned);- if let Some(uri) = uri {- self.open_url(&uri);- }- }-- /// Launch the configured opener (default `xdg-open`) on a URL.- fn open_url(&self, url: &str) {- let Some((program, args)) = self.config.url.launch.split_first() else {- tracing::warn!("open url: no [url] launch command configured");- return;- };- let mut cmd = std::process::Command::new(program);- cmd.args(args)- .arg(url)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Err(err) = cmd.spawn() {- tracing::warn!("open url {url:?}: {err}");- }- }-- /// Scale a logical pixel length to physical (buffer) pixels at the current- /// fractional scale, rounding to nearest.- fn to_phys(&self, v: u32) -> u32 {- ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32- }-- /// Physical (buffer) pixel size at the current scale.- fn phys_dims(&self) -> (u32, u32) {- (- self.to_phys(self.width).max(1),- self.to_phys(self.height).max(1),- )- }-- /// Columns and rows for the current physical size, metrics, and padding.- /// Scale-invariant: every term scales together so the cell count is stable.- fn grid_dims(&self) -> (u16, u16) {- let (pw, ph) = self.phys_dims();- grid_size(- self.renderer.metrics(),- pw,- ph,- (- self.to_phys(self.config.main.pad_x),- self.to_phys(self.config.main.pad_y),- ),- )- }-- /// Re-rasterize the font and padding at the current scale × the logical- /// font size, so glyphs are crisp at fractional scales.- fn rescale_render(&mut self) {- self.renderer.set_padding(- self.to_phys(self.config.main.pad_x),- self.to_phys(self.config.main.pad_y),- );- let px = self.to_phys(self.font_size).max(1);- if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {- tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);- }- }-- /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid- /// geometry, and update the viewport so the logical size stays put.- fn set_scale(&mut self, scale120: u32) {- let scale120 = scale120.max(1);- if scale120 == self.scale120 {- return;- }- self.scale120 = scale120;- self.rescale_render();- self.frames.clear();- self.buf_dims = (0, 0);- if let Some(vp) = &self.viewport {- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- }- self.resize_grid();- self.needs_draw = true;- }-- /// Inner padding `(x, y)` in physical pixels (pointer coords are converted- /// to physical before use).- fn padding(&self) -> (f64, f64) {- (- f64::from(self.to_phys(self.config.main.pad_x)),- f64::from(self.to_phys(self.config.main.pad_y)),- )- }-- /// Convert a logical surface coordinate to a physical buffer coordinate.- fn to_phys_f(&self, v: f64) -> f64 {- v * f64::from(self.scale120) / 120.0- }-- /// The active seat (the one that most recently produced input).- fn active(&self) -> Option<&SeatData> {- self.seats.get(self.active_seat)- }-- /// Attach the seat-scoped clipboard and primary-selection devices to seat- /// `i` if not already present. Idempotent, so it can run from either- /// `new_seat` or `new_capability` - some compositors surface a pre-existing- /// seat's capabilities without ever firing `new_seat`.- fn ensure_clipboard_devices(- &mut self,- qh: &QueueHandle<Self>,- seat: &wl_seat::WlSeat,- i: usize,- ) {- if self.seats[i].data_device.is_none() {- let dd = self.data_device_manager.get_data_device(qh, seat);- self.seats[i].data_device = Some(dd);- }- if self.seats[i].primary_device.is_none()- && let Some(m) = self.primary_manager.as_ref()- {- let pd = m.get_selection_device(qh, seat);- self.seats[i].primary_device = Some(pd);- }- }-- /// The active seat's clipboard device, if any.- fn data_device(&self) -> Option<&DataDevice> {- self.active().and_then(|s| s.data_device.as_ref())- }-- /// The active seat's primary-selection device, if any.- fn primary_device(&self) -> Option<&PrimarySelectionDevice> {- self.active().and_then(|s| s.primary_device.as_ref())- }-- /// Find (or create) the per-seat entry for `seat`, returning its index.- fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {- if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {- return i;- }- self.seats.push(SeatData {- seat: seat.clone(),- keyboard: None,- pointer: None,- cursor_shape_device: None,- data_device: None,- primary_device: None,- text_input: None,- touch: None,- });- self.seats.len() - 1- }-- /// Mark the seat owning `keyboard` as active for clipboard ownership.- fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {- if let Some(i) = self- .seats- .iter()- .position(|s| s.keyboard.as_ref() == Some(keyboard))- {- self.active_seat = i;- }- }-- /// Mark the seat owning `pointer` as active for clipboard ownership.- fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {- if let Some(i) = self- .seats- .iter()- .position(|s| s.pointer.as_ref() == Some(pointer))- {- self.active_seat = i;- }- }-- /// Map window pixel coordinates to an absolute `(row, col)` grid point.- fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {- let session = self.session.as_ref()?;- let m = self.renderer.metrics();- let (pad_x, pad_y) = self.padding();- let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));- let grid = session.term.grid();- let col =- ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));- let vrow =- ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));- Some((grid.view_to_abs(vrow), col))- }-- /// Left-button press: start (or word/line-extend) a selection.- fn pointer_press(&mut self, time: u32) {- let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {- return;- };- let count = match self.last_click {- Some((t, r, c, n))- if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>- {- n % 3 + 1- }- _ => 1,- };- self.last_click = Some((time, row, col, count));- let Some(session) = self.session.as_mut() else {- return;- };- let ctrl = self.modifiers.ctrl;- let grid = session.term.grid_mut();- match count {- 2 => grid.select_word(row, col),- 3 => grid.select_line(row),- // Holding Ctrl starts a rectangular (block) selection.- _ if ctrl => grid.start_block_selection(row, col),- _ => grid.start_selection(row, col),- }- self.selecting = true;- self.needs_draw = true;- }-- /// Pointer motion during a drag: extend the selection head and, if the- /// pointer has left the top/bottom edge, start autoscrolling.- fn pointer_drag(&mut self) {- if !self.selecting {- return;- }- let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {- return;- };- if let Some(session) = self.session.as_mut() {- session.term.grid_mut().extend_selection(row, col);- self.needs_draw = true;- }- self.update_autoscroll();- }-- /// Arm or disarm edge autoscroll based on the pointer's vertical position.- fn update_autoscroll(&mut self) {- let dir = if self.pointer_pos.1 < 0.0 {- 1 // above the top: reveal older lines- } else if self.pointer_pos.1 >= f64::from(self.height) {- -1 // below the bottom: advance toward the live screen- } else {- 0- };- self.autoscroll = dir;- if dir != 0 && self.autoscroll_timer.is_none() {- let timer = Timer::immediate();- self.autoscroll_timer = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())- .ok();- }- }-- /// One autoscroll step: scroll the viewport and drag the selection head to- /// the edge cell under the pointer. Reschedules until the drag ends or the- /// pointer returns inside the window.- fn autoscroll_step(&mut self) -> TimeoutAction {- if !self.selecting || self.autoscroll == 0 {- self.autoscroll_timer = None;- return TimeoutAction::Drop;- }- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(self.autoscroll);- }- let edge_y = if self.autoscroll > 0 {- 0.0- } else {- f64::from(self.height) - 1.0- };- if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)- && let Some(session) = self.session.as_mut()- {- session.term.grid_mut().extend_selection(row, col);- }- self.needs_draw = true;- TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))- }-- /// Left-button release: stop autoscrolling and publish the primary selection.- fn pointer_release(&mut self, qh: &QueueHandle<App>) {- if !self.selecting {- return;- }- self.selecting = false;- self.autoscroll = 0;- if let Some(token) = self.autoscroll_timer.take() {- self.loop_handle.remove(token);- }- self.set_primary(qh);- }-- /// Whether the application wants mouse reports and the user is not holding- /// Shift (which forces local selection regardless of mode).- fn mouse_reporting(&self) -> bool {- self.session- .as_ref()- .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)- && !self.modifiers.shift- }-- /// The viewport cell `(col, row)` under the pointer, clamped to the screen.- fn report_screen_cell(&self) -> Option<(usize, usize)> {- let session = self.session.as_ref()?;- let m = self.renderer.metrics();- let (pad_x, pad_y) = self.padding();- let (ppx, ppy) = (- self.to_phys_f(self.pointer_pos.0),- self.to_phys_f(self.pointer_pos.1),- );- let grid = session.term.grid();- let col =- ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));- let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)- .min(grid.rows().saturating_sub(1));- Some((col, row))- }-- /// Report a button press/release to the application, if reporting is active.- /// Returns whether the event was consumed (so local handling is skipped).- fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {- let Some(session) = self.session.as_ref() else {- return false;- };- let grid = session.term.grid();- let proto = grid.mouse_protocol();- if proto == MouseProtocol::Off || self.modifiers.shift {- return false;- }- let enc = grid.mouse_encoding();- // X10 (mode 9) reports presses only; a release is swallowed, not sent.- if (pressed || proto != MouseProtocol::X10)- && let Some((col, row)) = self.report_screen_cell()- {- let bytes =- crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);- self.write_to_pty(&bytes);- self.last_report_cell = Some((col, row));- }- true- }-- /// Report pointer motion to the application when the active mode wants it.- /// Returns whether reporting consumed the motion (suppressing local drag).- fn try_report_motion(&mut self) -> bool {- let Some(session) = self.session.as_ref() else {- return false;- };- let grid = session.term.grid();- let proto = grid.mouse_protocol();- if proto == MouseProtocol::Off || self.modifiers.shift {- return false;- }- let enc = grid.mouse_encoding();- let wants = match proto {- MouseProtocol::Any => true,- MouseProtocol::Button => self.pressed_button.is_some(),- _ => false,- };- if wants- && let Some((col, row)) = self.report_screen_cell()- && self.last_report_cell != Some((col, row))- {- // Any-event motion with no button held uses the "no button" code 3.- let code = self.pressed_button.unwrap_or(3);- let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers);- self.write_to_pty(&bytes);- self.last_report_cell = Some((col, row));- }- true- }-- /// Send focus in/out (DECSET 1004) to the application when it asked for it.- fn report_focus(&mut self, focused: bool) {- if self- .session- .as_ref()- .is_some_and(|s| s.term.grid().focus_events())- {- self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });- }- }-- /// Create or drop the idle inhibitor to match `[main] idle-inhibit` and the- /// current focus: inhibit only while focused, so a backgrounded terminal- /// still lets the screen blank. Idempotent; called on every focus change.- fn sync_idle_inhibit(&mut self) {- let want = self.config.main.idle_inhibit && self.focused;- if want && self.idle_inhibitor.is_none() {- if let Some(mgr) = &self.idle_inhibit_manager {- self.idle_inhibitor =- Some(mgr.create_inhibitor(self.window.wl_surface(), &self.qh, ()));- }- } else if !want && let Some(inhibitor) = self.idle_inhibitor.take() {- inhibitor.destroy();- }- }-- /// Write bytes to the PTY master, logging on failure.- fn write_to_pty(&mut self, bytes: &[u8]) {- if let Some(session) = self.session.as_mut()- && let Err(err) = write_all(session.pty.master(), bytes)- {- tracing::warn!("write to pty: {err}");- }- }-- /// The current selection text, if any and non-empty.- fn selection_text(&self) -> Option<String> {- let text = self.session.as_ref()?.term.grid().selection_text()?;- (!text.is_empty()).then_some(text)- }-- /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.- fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {- let Some(device) = self.data_device() else {- return;- };- let source = self- .data_device_manager- .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());- source.set_selection(device, self.serial);- self.clipboard = text;- self.copy_source = Some(source);- }-- /// Take ownership of the primary selection, serving `text` to pasters.- fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {- let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())- else {- return;- };- let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());- source.set_selection(device, self.serial);- self.primary_clip = text;- self.primary_source = Some(source);- }-- /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).- fn set_clipboard(&mut self, qh: &QueueHandle<App>) {- if let Some(text) = self.selection_text() {- self.claim_clipboard(text, qh);- }- }-- /// Claim the primary selection with the current selection (select-to-copy).- fn set_primary(&mut self, qh: &QueueHandle<App>) {- if let Some(text) = self.selection_text() {- self.claim_primary(text, qh);- }- }-- /// Point the IME's candidate popup at the terminal cursor, in logical- /// surface coordinates (the renderer works in physical pixels, so divide- /// the physical cell rectangle back down by the scale).- fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {- let Some(session) = self.session.as_ref() else {- return;- };- let (cx, cy) = session.term.grid().cursor();- let m = self.renderer.metrics();- let s = f64::from(self.scale120) / 120.0;- let pad_x = f64::from(self.to_phys(self.config.main.pad_x));- let pad_y = f64::from(self.to_phys(self.config.main.pad_y));- let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;- let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;- let w = (f64::from(m.width) / s) as i32;- let h = (f64::from(m.height) / s) as i32;- ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));- }-- /// Apply one IME transaction: commit any committed text to the shell, adopt- /// the new preedit, then re-commit our state (cursor rectangle) to the IME.- fn ime_done(&mut self, ti: &ZwpTextInputV3) {- let commit = std::mem::take(&mut self.ime_commit_pending);- // Preedit is replaced wholesale each cycle; an absent preedit clears it.- self.preedit = std::mem::take(&mut self.ime_preedit_pending);- if !commit.is_empty() {- self.send_to_shell(commit.as_bytes());- }- self.ime_set_cursor_rect(ti);- ti.commit();- self.needs_draw = true;- }-- /// Act on the OSC 52 clipboard requests an application made: take ownership- /// of the selection it set, or answer a query with what we currently hold.- fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {- use crate::vt::ClipboardOp;- let qh = self.qh.clone();- for op in ops {- match op {- ClipboardOp::Set {- primary: true,- text,- } => self.claim_primary(text, &qh),- ClipboardOp::Set {- primary: false,- text,- } => self.claim_clipboard(text, &qh),- ClipboardOp::Query { primary } => {- let text = if primary {- &self.primary_clip- } else {- &self.clipboard- };- let kind = if primary { 'p' } else { 'c' };- let reply = format!(- "\x1b]52;{kind};{}\x07",- crate::vt::base64_encode(text.as_bytes())- );- self.write_to_pty(reply.as_bytes());- }- }- }- }-- /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).- fn paste_clipboard(&mut self) {- let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {- tracing::debug!("paste: no clipboard selection offer");- return;- };- let Some(mime) = offer.with_mime_types(pick_mime) else {- tracing::debug!("paste: no acceptable text mime type offered");- return;- };- if let Ok(pipe) = offer.receive(mime) {- self.read_paste(pipe);- }- }-- /// Paste the primary selection into the shell (middle click).- fn paste_primary(&mut self) {- let Some(offer) = self- .primary_device()- .and_then(|d| d.data().selection_offer())- else {- return;- };- let Some(mime) = offer.with_mime_types(pick_mime) else {- return;- };- if let Ok(pipe) = offer.receive(mime) {- self.read_paste(pipe);- }- }-- /// Drain a clipboard read-pipe on the event loop, writing the bytes to the- /// PTY once the source closes its end.- fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {- let mut data: Vec<u8> = Vec::new();- let registered = self- .loop_handle- .insert_source(pipe, move |_, file, app: &mut App| {- // SAFETY: the file is owned by the source and not closed while read.- let f: &mut File = unsafe { file.get_mut() };- let mut tmp = [0u8; 4096];- match f.read(&mut tmp) {- Ok(0) => {- tracing::debug!("paste: read {} bytes from clipboard", data.len());- app.paste_bytes(&data);- PostAction::Remove- }- Ok(n) => {- data.extend_from_slice(&tmp[..n]);- PostAction::Continue- }- Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {- PostAction::Continue- }- Err(e) => {- tracing::warn!("read paste pipe: {e}");- PostAction::Remove- }- }- });- if let Err(err) = registered {- tracing::warn!("register paste pipe: {err}");- }- }-- /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and- /// snapping the viewport to the live screen.- fn paste_bytes(&mut self, data: &[u8]) {- let Some(session) = self.session.as_mut() else {- return;- };- session.term.scroll_to_bottom();- self.needs_draw = true;- let bracketed = session.term.grid().bracketed_paste();- // Strip control bytes a terminal must never receive raw from a paste;- // keep tab and newlines (CR is what the shell expects for Enter).- let mut clean: Vec<u8> = Vec::with_capacity(data.len());- for &b in data {- match b {- b'\n' => clean.push(b'\r'),- b'\t' | b'\r' => clean.push(b),- 0x20..=0xff => clean.push(b),- _ => {}- }- }- let fd = session.pty.master();- if bracketed {- let _ = write_all(fd, b"\x1b[200~");- let _ = write_all(fd, &clean);- let _ = write_all(fd, b"\x1b[201~");- } else if let Err(err) = write_all(fd, &clean) {- tracing::warn!("write paste to pty: {err}");- }- }-- /// After parsing child output: send any replies, sync the title, repaint.- fn after_feed(&mut self) {- let Some(session) = self.session.as_mut() else {- return;- };- let reply = session.term.take_response();- if !reply.is_empty()- && let Err(err) = write_all(session.pty.master(), &reply)- {- tracing::warn!("write to pty: {err}");- }-- let new_title = session.term.title().map(str::to_owned);- if new_title.as_deref() != self.title.as_deref() {- self.title = new_title;- self.window- .set_title(self.title.clone().unwrap_or_default());- }- let rang = session.term.take_bell();- let ops = session.term.take_clipboard_ops();- let notifications = session.term.take_notifications();- if !ops.is_empty() {- self.handle_clipboard_ops(ops);- }- for note in notifications {- self.send_notification(¬e);- }- if rang {- self.ring_bell();- }- self.needs_draw = true;- }-- /// React to a `BEL`: optionally flash, run the configured bell command, and- /// request the compositor's attention when unfocused.- fn ring_bell(&mut self) {- if self.config.bell.visual {- self.start_flash();- }- if let Some((program, args)) = self.config.bell.command.split_first() {- let _ = std::process::Command::new(program)- .args(args)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .spawn()- .inspect_err(|err| tracing::warn!("bell command: {err}"));- }- if self.config.bell.urgent && !self.focused {- self.request_attention();- }- }-- /// Deliver a desktop notification through the configured notifier (default- /// `notify-send`), appending the title and body as the final arguments.- fn send_notification(&self, note: &crate::vt::Notification) {- let Some((program, args)) = self.config.notify.command.split_first() else {- return;- };- let title = note- .title- .clone()- .or_else(|| self.title.clone())- .unwrap_or_else(|| "beer".to_string());- let _ = std::process::Command::new(program)- .args(args)- .arg(title)- .arg(¬e.body)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .spawn()- .inspect_err(|err| tracing::warn!("notify command: {err}"));- }-- /// Ask the compositor to draw attention to the window (xdg-activation).- fn request_attention(&mut self) {- let Some(activation) = self.activation.as_ref() else {- return;- };- let seat_and_serial = self- .seats- .get(self.active_seat)- .map(|s| (s.seat.clone(), self.serial));- let data = smithay_client_toolkit::activation::RequestData {- app_id: Some("dev.notashelf.beer".to_string()),- seat_and_serial,- surface: Some(self.window.wl_surface().clone()),- };- activation.request_token::<App>(&self.qh, data);- }-- /// Begin a visual-bell flash: invert the screen for a moment. Clearing the- /// buffer ring forces a full repaint with the inverted theme.- fn start_flash(&mut self) {- self.flashing = true;- self.frames.clear();- self.needs_draw = true;- if self.flash_timer.is_none() {- let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));- self.flash_timer = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| {- app.flashing = false;- app.frames.clear();- app.needs_draw = true;- app.flash_timer = None;- TimeoutAction::Drop- })- .ok();- }- }-- /// Recompute the grid size for the current window and tell the grid and the- /// PTY about it if it changed.- fn resize_grid(&mut self) {- let (cols, rows) = self.grid_dims();- let Some(session) = self.session.as_mut() else {- return;- };- if (cols as usize, rows as usize)- == (session.term.grid().cols(), session.term.grid().rows())- {- return;- }- session.term.resize(cols as usize, rows as usize);- if let Err(err) = session.pty.resize(cols, rows) {- tracing::warn!("resize pty: {err}");- }- }-- /// The child shell has gone away; reap it, capture its code, and tear the- /// window down.- fn child_exited(&mut self) {- if let Some(session) = self.session.as_mut() {- match session.pty.wait() {- Ok(status) => {- tracing::info!("shell exited: {status}");- // Mirror the shell's status: its code, or 128+signal if killed.- let code = status- .code()- .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));- self.exit_code = ExitCode::from(code as u8);- }- Err(err) => tracing::warn!("reap shell: {err}"),- }- }- self.exit = true;- }-- /// Present a frame if one is wanted and the compositor is ready for it.- /// Called after every event-loop wake; the frame-callback gate keeps draws- /// paced to the display instead of one per PTY read. While the app holds- /// synchronized output (DECSET 2026) we withhold the frame, but arm a- /// timeout so a stuck `2026h` cannot freeze the window.- fn flush(&mut self) {- let sync = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().sync_active());- if sync {- if self.sync_timeout.is_none() {- let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));- self.sync_timeout = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| {- if let Some(session) = app.session.as_mut() {- session.term.grid_mut().set_sync(false);- }- app.sync_timeout = None;- app.needs_draw = true;- TimeoutAction::Drop- })- .ok();- }- return;- }- if let Some(token) = self.sync_timeout.take() {- self.loop_handle.remove(token);- }- if self.needs_draw && !self.frame_pending && self.session.is_some() {- self.present();- }- }-}@@ -1,231 +0,0 @@-use super::*;--/// What determines one rendered row's pixels: its cells, the cursor on it, the-/// selection span over it, and the blink phase. Two equal `RowSnap`s render-/// identically, so a buffer holding an equal snapshot needs no repaint.-#[derive(Clone, PartialEq, Debug)]-struct RowSnap {- cells: Vec<Cell>,- /// `(col, shape, focused)` when the cursor is drawn on this row.- cursor: Option<(usize, CursorShape, bool)>,- /// Inclusive selected column span on this row.- sel: Option<(usize, usize)>,- /// Search-match spans `(lo, hi, is_current)` highlighted on this row.- search: Vec<(usize, usize, bool)>,- /// Search-prompt text drawn over this row (only the bottom row, when active).- overlay: Option<String>,- /// IME preedit `(start_col, text)` drawn inline over this row (cursor row).- preedit: Option<(usize, String)>,- /// Blink phase, but only varied when the row actually has blinking ink, so- /// non-blinking rows stay equal across phase toggles.- blink: bool,-}--/// One shm buffer plus the per-row snapshot of what it currently displays.-#[derive(Debug)]-pub(super) struct FrameBuf {- buffer: Buffer,- rows: Vec<RowSnap>,-}--/// Snapshot the determinants of viewport row `y`'s pixels.-fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {- let abs = grid.view_to_abs(y);- let cells = grid.view_row(y).to_vec();- let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {- let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);- visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))- } else {- None- };- let has_blink = cells- .iter()- .any(|c| c.flags.contains(crate::grid::Flags::BLINK));- RowSnap {- cells,- cursor,- sel: grid.selection_span_on(abs),- search: grid.search_spans_on(abs),- overlay: None,- preedit: None,- blink: if has_blink { blink_on } else { true },- }-}-impl App {- /// Render only the rows that changed since the chosen buffer last displayed- /// them, damage just those rows, and commit with a frame-callback request.- pub(super) fn present(&mut self) {- self.needs_draw = false;- // URL hint labels overlay the grid but are not part of the row snapshot,- // so force a full redraw while the labels are showing.- if self.url_mode {- self.frames.clear();- }- // Render into a buffer sized in physical pixels (logical × scale); the- // viewport presents it back at the logical surface size.- let (w, h) = self.phys_dims();- let m = self.renderer.metrics();- let (focused, blink_on) = (self.focused, self.blink_on);-- // A resize invalidates every buffer's contents and size.- if self.buf_dims != (w, h) {- self.frames.clear();- self.buf_dims = (w, h);- }-- let Some(session) = self.session.as_ref() else {- return;- };- let grid = session.term.grid();- // The visual bell inverts fg/bg for the duration of the flash.- let flashed = self.flashing.then(|| session.term.theme().inverted());- let theme = flashed.as_ref().unwrap_or(session.term.theme());- let rows = grid.rows();- let mut cur: Vec<RowSnap> = (0..rows)- .map(|y| row_snap(grid, y, focused, blink_on))- .collect();-- // The search prompt occupies the bottom row while search mode is active.- // Recording it in the snapshot keeps the row's damage/diff correct.- let bar_text = if let Some(hex) = &self.unicode_input {- Some(format!("unicode: U+{}", hex.to_uppercase()))- } else {- self.searching.then(|| {- let (n, total) = grid.search_count();- format!(- "search: {} [{n}/{total}]",- grid.search_query().unwrap_or("")- )- })- };- if let Some(text) = &bar_text- && rows > 0- {- cur[rows - 1].overlay = Some(text.clone());- }-- // The IME preedit is drawn inline at the cursor while composing.- if !self.preedit.is_empty() && grid.view_at_bottom() {- let (cx, cy) = grid.cursor();- if cy < rows {- cur[cy].preedit = Some((cx, self.preedit.clone()));- }- }-- // Reuse a buffer the compositor has released, else grow the ring.- let stride = w as i32 * 4;- let mut idx = None;- for i in 0..self.frames.len() {- if self.pool.canvas(&self.frames[i].buffer).is_some() {- idx = Some(i);- break;- }- }- let idx = match idx {- Some(i) => i,- None if self.frames.len() < MAX_BUFFERS => {- match self- .pool- .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)- {- Ok((buffer, _)) => {- self.frames.push(FrameBuf {- buffer,- rows: Vec::new(),- });- self.frames.len() - 1- }- Err(err) => {- tracing::error!("allocate shm buffer: {err}");- return;- }- }- }- // All buffers are still held by the compositor; a release event will- // wake us and `needs_draw` (re-set below) retries then.- None => {- self.needs_draw = true;- return;- }- };-- // Rows that differ from what this buffer last showed (all, if fresh).- let prev = &self.frames[idx].rows;- let dirty: Vec<usize> = (0..rows)- .filter(|&y| prev.get(y) != Some(&cur[y]))- .collect();- if dirty.is_empty() {- return;- }-- // A buffer used for the first time has uninitialized margins; paint the- // whole thing (background + padding) once, then damage it in full below.- let fresh = self.frames[idx].rows.is_empty();- let pad_y = self.to_phys(self.config.main.pad_y) as i32;- let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {- return;- };- let dims = (w as usize, h as usize);- let frame = crate::render::Frame {- theme,- focused,- blink_on,- hovered_link: self.hovered_link,- };- if fresh {- self.renderer.clear(canvas, dims, theme);- }- for &y in &dirty {- self.renderer.render_row(canvas, dims, grid, &frame, y);- }- // Draw the search prompt over the (now repainted) bottom row.- if let Some(text) = &bar_text- && dirty.contains(&(rows - 1))- {- self.renderer- .render_search_bar(canvas, dims, theme, rows - 1, text);- }- // Draw the IME preedit inline over its (repainted) cursor row.- for &y in &dirty {- if let Some((col, text)) = &cur[y].preedit {- self.renderer- .render_preedit(canvas, dims, theme, y, *col, text);- }- }- // Draw URL hint labels on top, narrowing to those matching the input.- if self.url_mode {- for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {- if label.starts_with(&self.url_input) {- self.renderer- .render_label(canvas, dims, theme, hit.row, hit.col, label);- }- }- }- self.frames[idx].rows = cur;-- let surface = self.window.wl_surface();- if let Err(err) = self.frames[idx].buffer.attach_to(surface) {- tracing::error!("attach buffer: {err}");- return;- }- // With a viewport the buffer is presented at the logical destination, so- // its own scale stays 1; without one, fall back to integer buffer scale.- if let Some(vp) = &self.viewport {- surface.set_buffer_scale(1);- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- } else {- surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);- }- if fresh {- surface.damage_buffer(0, 0, w as i32, h as i32);- } else {- for &y in &dirty {- let top = pad_y + y as i32 * m.height as i32;- surface.damage_buffer(0, top, w as i32, m.height as i32);- }- }- surface.frame(&self.qh, surface.clone());- self.window.commit();- self.frame_pending = true;- }-}