From 1f1451f108c01b9f5fd26f21c870712d55a146fb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 26 Jun 2026 11:44:20 +0300 Subject: [PATCH] meta: split protocol codecs and encoders into a beer-protocols crate Signed-off-by: NotAShelf Change-Id: Ib7706308c892e43d2044fbb766505e9e6a6a6964 --- Cargo.lock | 10 +- Cargo.toml | 47 +--- crates/beer-protocols/Cargo.toml | 17 ++ crates/beer-protocols/README.md | 212 ++++++++++++++ crates/beer-protocols/src/caps.rs | 25 ++ crates/beer-protocols/src/charset.rs | 61 ++++ crates/beer-protocols/src/codec.rs | 154 ++++++++++ .../beer-protocols/src/key.rs | 123 ++------ crates/beer-protocols/src/lib.rs | 36 +++ crates/beer-protocols/src/mouse.rs | 106 +++++++ crates/beer-protocols/src/sgr.rs | 97 +++++++ crates/beer-protocols/src/style.rs | 83 ++++++ crates/beer/Cargo.toml | 44 +++ {src => crates/beer/src}/bindings.rs | 0 {src => crates/beer/src}/config.rs | 0 {src => crates/beer/src}/font.rs | 0 {src => crates/beer/src}/grid/links.rs | 0 {src => crates/beer/src}/grid/mod.rs | 72 +---- {src => crates/beer/src}/grid/search.rs | 0 {src => crates/beer/src}/grid/selection.rs | 0 {src => crates/beer/src}/main.rs | 1 - {src => crates/beer/src}/pty.rs | 0 {src => crates/beer/src}/render.rs | 0 {src => crates/beer/src}/theme.rs | 0 {src => crates/beer/src}/vt/mod.rs | 265 +----------------- {src => crates/beer/src}/vt/perform.rs | 0 {src => crates/beer/src}/wayland/handlers.rs | 0 {src => crates/beer/src}/wayland/mod.rs | 35 ++- {src => crates/beer/src}/wayland/rendering.rs | 0 nix/package.nix | 6 +- 30 files changed, 910 insertions(+), 484 deletions(-) create mode 100644 crates/beer-protocols/Cargo.toml create mode 100644 crates/beer-protocols/README.md create mode 100644 crates/beer-protocols/src/caps.rs create mode 100644 crates/beer-protocols/src/charset.rs create mode 100644 crates/beer-protocols/src/codec.rs rename src/input.rs => crates/beer-protocols/src/key.rs (84%) create mode 100644 crates/beer-protocols/src/lib.rs create mode 100644 crates/beer-protocols/src/mouse.rs create mode 100644 crates/beer-protocols/src/sgr.rs create mode 100644 crates/beer-protocols/src/style.rs create mode 100644 crates/beer/Cargo.toml rename {src => crates/beer/src}/bindings.rs (100%) rename {src => crates/beer/src}/config.rs (100%) rename {src => crates/beer/src}/font.rs (100%) rename {src => crates/beer/src}/grid/links.rs (100%) rename {src => crates/beer/src}/grid/mod.rs (96%) rename {src => crates/beer/src}/grid/search.rs (100%) rename {src => crates/beer/src}/grid/selection.rs (100%) rename {src => crates/beer/src}/main.rs (99%) rename {src => crates/beer/src}/pty.rs (100%) rename {src => crates/beer/src}/render.rs (100%) rename {src => crates/beer/src}/theme.rs (100%) rename {src => crates/beer/src}/vt/mod.rs (78%) rename {src => crates/beer/src}/vt/perform.rs (100%) rename {src => crates/beer/src}/wayland/handlers.rs (100%) rename {src => crates/beer/src}/wayland/mod.rs (98%) rename {src => crates/beer/src}/wayland/rendering.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 1802465..c04ac89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,10 @@ checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "beer" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", + "beer-protocols", "calloop", "calloop-wayland-source", "fontconfig", @@ -55,6 +56,13 @@ dependencies = [ "wayland-protocols", ] +[[package]] +name = "beer-protocols" +version = "0.4.0" +dependencies = [ + "smithay-client-toolkit", +] + [[package]] name = "bitflags" version = "2.13.0" diff --git a/Cargo.toml b/Cargo.toml index b4f961f..4e4ff30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,49 +1,18 @@ -[package] -name = "beer" -version = "0.3.1" +[workspace] +resolver = "3" +members = ["crates/beer", "crates/beer-protocols"] + +[workspace.package] +version = "0.4.0" edition = "2024" rust-version = "1.95.0" -description = "A fast, software-rendered, Wayland-native terminal emulator" license = "EUPL-1.2" -readme = true -[dependencies] -anyhow = "1.0.102" -calloop = { version = "0.14.4", features = ["signals"] } -calloop-wayland-source = "0.4.1" -fontconfig = "0.11.0" -freetype-rs = "0.38.0" -harfbuzz_rs_now = "2.3.2" -lru = "0.18.0" -pound = "0.1.6" -rustix = { version = "1.1.4", features = [ - "pty", - "process", - "termios", - "stdio", - "fs", -] } -serde = { version = "1.0.228", features = ["derive"] } -serde_ignored = "0.1.14" -smithay-client-toolkit = "0.20.0" -thiserror = "2.0.18" -toml = "1.1.2" -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } -unicode-width = "0.2.2" -vte = "0.15.0" -wayland-client = "0.31.14" -wayland-protocols = { version = "0.32.13", features = [ - "client", - "staging", - "unstable", -] } - -[lints.rust] +[workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" missing_debug_implementations = "warn" -[lints.clippy] +[workspace.lints.clippy] all = { level = "warn", priority = -1 } [profile.release] diff --git a/crates/beer-protocols/Cargo.toml b/crates/beer-protocols/Cargo.toml new file mode 100644 index 0000000..39c2da0 --- /dev/null +++ b/crates/beer-protocols/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "beer-protocols" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Terminal protocol codecs and key/mouse wire encoders used by beer" +readme = "README.md" + +[dependencies] +# Provides the keyboard vocabulary (Keysym, Modifiers, KeyEvent) the key +# encoder consumes; the version is kept in lockstep with beer so the types +# unify to a single definition across the workspace. +smithay-client-toolkit = "0.20.0" + +[lints] +workspace = true diff --git a/crates/beer-protocols/README.md b/crates/beer-protocols/README.md new file mode 100644 index 0000000..230cff4 --- /dev/null +++ b/crates/beer-protocols/README.md @@ -0,0 +1,212 @@ +# beer-protocols + +The terminal-protocol building blocks beer is built on: byte codecs, the +terminfo capability table, character-set translation, SGR parsing, and the +keyboard/mouse wire encoders. Everything here is a pure function or a plain +data type, with no terminal state, so each protocol detail can be read and +tested on its own. beer feeds these into its `vte`-driven dispatcher and its +grid model. + +This README doubles as the reference for **which escape sequences, OSC +commands, and POSIX behaviours beer actually implements** - the parts a user +notices. Sequences not listed here are parsed and ignored rather than passed +through. Notation: `ESC` is `0x1b`, `CSI` is `ESC [`, `OSC` is `ESC ]`, `DCS` +is `ESC P`, `ST` is the string terminator `ESC \` (a `BEL` is also accepted to +end an OSC). `Ps` is a numeric parameter, `Pt` a text parameter. + +## Crate layout + +| Module | What it covers | +| --- | --- | +| `codec` | base64 (OSC 52), hex (XTGETTCAP names), `file://` URI percent-decoding (OSC 7) | +| `caps` | terminfo capabilities answered over XTGETTCAP | +| `charset` | G0/G1 designation and DEC special-graphics line drawing | +| `sgr` | the multi-parameter SGR colour and underline forms | +| `key` | legacy xterm/VT and kitty keyboard-protocol key encoding | +| `mouse` | X10 / UTF-8 / SGR mouse-report encoding | +| `style` | the enums an SGR/DECSET stream selects | + +--- + +## C0 control characters + +| Byte | Name | Effect | +| --- | --- | --- | +| `0x07` | BEL | rings the bell (visual flash / command / urgency, per config) | +| `0x08` | BS | backspace | +| `0x09` | HT | tab to next stop | +| `0x0A`-`0x0C` | LF/VT/FF | line feed | +| `0x0D` | CR | carriage return | +| `0x0E` | SO | invoke G1 (shift out) | +| `0x0F` | SI | invoke G0 (shift in) | + +## Cursor movement and editing (CSI) + +| Sequence | Name | Effect | +| --- | --- | --- | +| `CSI Ps A` | CUU | cursor up | +| `CSI Ps B` / `CSI Ps e` | CUD | cursor down | +| `CSI Ps C` / `CSI Ps a` | CUF | cursor forward | +| `CSI Ps D` | CUB | cursor back | +| `CSI Ps E` | CNL | cursor down, to column 1 | +| `CSI Ps F` | CPL | cursor up, to column 1 | +| `CSI Ps G` / `CSI Ps \`` | CHA | move to column | +| `CSI Ps d` | VPA | move to row | +| `CSI Ps ; Ps H` / `f` | CUP/HVP | move to row;col | +| `CSI Ps J` | ED | erase in display (0 below, 1 above, 2 all) | +| `CSI Ps K` | EL | erase in line (0 right, 1 left, 2 all) | +| `CSI Ps @` | ICH | insert blank characters | +| `CSI Ps P` | DCH | delete characters | +| `CSI Ps L` | IL | insert lines | +| `CSI Ps M` | DL | delete lines | +| `CSI Ps X` | ECH | erase characters | +| `CSI Ps S` | SU | scroll up | +| `CSI Ps T` | SD | scroll down | +| `CSI Ps ; Ps r` | DECSTBM | set scroll region (top;bottom) | +| `CSI Ps g` | TBC | clear tab stop (0) or all tabs (3) | +| `CSI s` / `CSI u` | SCOSC/SCORC | save / restore cursor | + +## ESC (non-CSI) + +| Sequence | Name | Effect | +| --- | --- | --- | +| `ESC D` | IND | line feed | +| `ESC M` | RI | reverse index | +| `ESC E` | NEL | next line | +| `ESC 7` / `ESC 8` | DECSC/DECRC | save / restore cursor | +| `ESC H` | HTS | set tab stop | +| `ESC c` | RIS | full reset | +| `ESC ( c` / `ESC ) c` | SCS | designate G0 / G1 charset (`0` = DEC special graphics, `B` = ASCII) | + +## Graphic rendition (SGR, `CSI Ps m`) + +Bold (1), dim (2), italic (3), underline (4), blink (5/6), reverse (7), hidden +(8), strike (9), and their resets (22-29). Overline (53) and its reset (55). +Underline styles via `4:x` and SGR 21: single, double, curly, dotted, dashed. + +Colours: the 16 ANSI colours (30-37, 90-97 foreground; 40-47, 100-107 +background) and default (39/49). Extended colour for foreground (38), +background (48), and underline (58/59), in both the legacy semicolon form +(`38;5;n`, `38;2;r;g;b`) and the colon-subparameter form (`38:5:n`, +`38:2:r:g:b`, with an ignored colour-space id). 256-colour and 24-bit truecolor +are fully supported. + +## Modes (DECSET/DECRST `CSI [?] Ps h` / `l`) + +| Mode | Name | Effect | +| --- | --- | --- | +| `4` (ANSI) | IRM | insert/replace | +| `?1` | DECCKM | application cursor keys | +| `?6` | DECOM | origin mode | +| `?7` | DECAWM | autowrap | +| `?25` | DECTCEM | cursor visibility | +| `?9` | - | X10 mouse reporting | +| `?1000` | - | normal mouse (press/release) | +| `?1002` | - | button-event mouse (drag) | +| `?1003` | - | any-event mouse (all motion) | +| `?1004` | - | focus in/out reporting | +| `?1005` | - | UTF-8 mouse coordinates | +| `?1006` | - | SGR mouse coordinates | +| `?47` / `?1047` | - | alternate screen | +| `?1049` | - | alternate screen + save/restore cursor | +| `?2004` | - | bracketed paste | +| `?2026` | - | synchronized output | + +Mode state is reportable with **DECRQM** (`CSI [?] Ps $ p`), which replies +`CSI [?] Ps ; state $ y` (1 set, 2 reset, 0 unrecognized). + +## Cursor style (DECSCUSR, `CSI Ps SP q`) + +`0`/`1` blinking block, `2` steady block, `3` blinking underline, `4` steady +underline, `5` blinking bar, `6` steady bar. The configured default applies +until an application overrides it. + +## Device reports + +| Sequence | Name | Reply | +| --- | --- | --- | +| `CSI c` | DA1 | `CSI ?62;22c` (VT220 + ANSI colour) | +| `CSI > c` | DA2 | `CSI >0;276;0c` | +| `CSI = c` | DA3 | `DCS !\|00000000 ST` | +| `CSI 5 n` | DSR | `CSI 0n` (terminal OK) | +| `CSI 6 n` | CPR | `CSI row;col R` (cursor position) | +| `CSI > q` | XTVERSION | `DCS >\|beer(version) ST` | +| `DCS + q 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 ; ` 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 `), with Alt sending an ESC +(meta) prefix. + +The **kitty keyboard protocol** (`CSI > flags u` push, `CSI < flags u` pop, +`CSI = flags ; mode u` set, `CSI ? u` query) is implemented with the +disambiguate, report-event-types, report-alternate-keys, report-all-keys, and +report-associated-text enhancement flags. Keys are encoded as `CSI +code[:shifted] ; mod[:event] [; text] u`, with a 32-deep push/pop stack. The +common keys (Enter, Tab, Backspace) keep their legacy bytes when unmodified so +a plain shell stays usable. + +## Mouse + +Reports are framed in the legacy byte form (`CSI M Cb Cx Cy`), the UTF-8 +coordinate form (DECSET 1005), or the SGR form (`CSI < Cb ; Cx ; Cy M/m`, +DECSET 1006). Shift/Alt/Ctrl modifier bits and the motion bit are encoded; +legacy releases collapse to button code 3. Reporting level is chosen by the +application via the mouse modes above; with reporting off the pointer drives +local selection and scrollback. + +## POSIX / terminal behaviour + +- A real PTY pair (`rustix` `openpt`/`grantpt`/`unlockpt`), with the child's + `TERM` set to the configured value (default `beer`) and the window size kept + in sync via `TIOCSWINSZ` (`SIGWINCH` reaches the child). +- The child's exit status is propagated as beer's own exit code. +- Alternate screen, scroll regions, autowrap and reflow on resize, and a + scrollback buffer. +- Bracketed paste (DECSET 2004) and synchronized output (DECSET 2026) so + applications can paste safely and update atomically. diff --git a/crates/beer-protocols/src/caps.rs b/crates/beer-protocols/src/caps.rs new file mode 100644 index 0000000..b8ff4c2 --- /dev/null +++ b/crates/beer-protocols/src/caps.rs @@ -0,0 +1,25 @@ +//! Terminfo capability values reported via XTGETTCAP (`DCS + q ST`). + +/// Look up a terminfo capability beer reports via XTGETTCAP, by its terminfo +/// name. `None` means the capability is unknown (the reply is then a negative +/// `DCS 0 + r`). +pub fn cap_value(name: &[u8]) -> Option<&'static str> { + match name { + b"TN" => Some("beer"), + b"Co" | b"colors" => Some("256"), + b"RGB" => Some("8/8/8"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_and_unknown_caps() { + assert_eq!(cap_value(b"TN"), Some("beer")); + assert_eq!(cap_value(b"colors"), Some("256")); + assert_eq!(cap_value(b"nope"), None); + } +} diff --git a/crates/beer-protocols/src/charset.rs b/crates/beer-protocols/src/charset.rs new file mode 100644 index 0000000..220c931 --- /dev/null +++ b/crates/beer-protocols/src/charset.rs @@ -0,0 +1,61 @@ +//! G0/G1 character-set designation and the DEC special graphics (line-drawing) +//! translation. + +/// A designated character set (`ESC ( c` / `ESC ) c`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Charset { + Ascii, + DecSpecial, +} + +/// Map a designation byte to a [`Charset`]. `0` selects DEC special graphics; +/// everything else falls back to ASCII. +pub fn charset(byte: u8) -> Charset { + match byte { + b'0' => Charset::DecSpecial, + _ => Charset::Ascii, + } +} + +/// Translate a byte under the DEC special graphics set (VT100 line drawing). +pub fn dec_special(c: char) -> char { + match c { + '`' => '◆', + 'a' => '▒', + 'f' => '°', + 'g' => '±', + 'j' => '┘', + 'k' => '┐', + 'l' => '┌', + 'm' => '└', + 'n' => '┼', + 'o' => '⎺', + 'p' => '⎻', + 'q' => '─', + 'r' => '⎼', + 's' => '⎽', + 't' => '├', + 'u' => '┤', + 'v' => '┴', + 'w' => '┬', + 'x' => '│', + 'y' => '≤', + 'z' => '≥', + '~' => '·', + _ => c, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_drawing_translates() { + assert_eq!(charset(b'0'), Charset::DecSpecial); + assert_eq!(charset(b'B'), Charset::Ascii); + assert_eq!(dec_special('q'), '─'); + assert_eq!(dec_special('x'), '│'); + assert_eq!(dec_special('A'), 'A'); // unmapped passes through + } +} diff --git a/crates/beer-protocols/src/codec.rs b/crates/beer-protocols/src/codec.rs new file mode 100644 index 0000000..1fe38e3 --- /dev/null +++ b/crates/beer-protocols/src/codec.rs @@ -0,0 +1,154 @@ +//! Byte codecs the escape-sequence layer leans on: base64 (OSC 52 clipboard), +//! hex (XTGETTCAP capability names), and percent-decoding of `file://` URIs +//! (OSC 7 working-directory reports). + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Standard base64 encode (used for OSC 52 query replies). +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(B64[(n >> 18 & 63) as usize] as char); + out.push(B64[(n >> 12 & 63) as usize] as char); + out.push(if chunk.len() > 1 { + B64[(n >> 6 & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + B64[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad +/// character. +pub fn base64_decode(data: &[u8]) -> Option> { + let val = |c: u8| -> Option { + 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 = 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> { + 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 { + 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 { + let rest = uri.strip_prefix(b"file://").unwrap_or(uri); + // Skip the authority (host) up to the first '/', which begins the path. + let slash = rest.iter().position(|&b| b == b'/')?; + let path_bytes = percent_decode(&rest[slash..]); + let path = String::from_utf8(path_bytes).ok()?; + path.starts_with('/').then_some(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_round_trips() { + for s in [ + "", + "f", + "fo", + "foo", + "foob", + "fooba", + "foobar", + "hi there\n", + ] { + let enc = base64_encode(s.as_bytes()); + assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes())); + } + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..])); + } + + #[test] + fn decode_hex_rejects_odd_and_nonhex() { + assert_eq!(decode_hex(b"544e").as_deref(), Some(&b"TN"[..])); + assert_eq!(decode_hex(b"54e"), None); + assert_eq!(decode_hex(b"zz"), None); + } + + #[test] + fn file_uri_decodes_percent_and_drops_host() { + assert_eq!( + file_uri_path(b"file://hermes/home/user/my%20dir").as_deref(), + Some("/home/user/my dir") + ); + assert_eq!(file_uri_path(b"file://host").as_deref(), None); + } +} diff --git a/src/input.rs b/crates/beer-protocols/src/key.rs similarity index 84% rename from src/input.rs rename to crates/beer-protocols/src/key.rs index d608ff5..630bca1 100644 --- a/src/input.rs +++ b/crates/beer-protocols/src/key.rs @@ -1,13 +1,12 @@ //! Keyboard encoding: translate decoded key events into the byte sequences a -//! terminal application expects (xterm/VT-style). - -use std::io::Write as _; +//! 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}; -use crate::grid::MouseEncoding; - -/// Encode a key press into bytes for the PTY, or `None` if it produces no input. +/// 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> { let seq = match event.keysym { Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods), @@ -56,7 +55,7 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option Stri 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 { - 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, 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, mods: Modifiers) -> Vec { if mods.alt { let mut out = Vec::with_capacity(bytes.len() + 1); @@ -407,6 +354,15 @@ mod tests { 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!( @@ -417,9 +373,9 @@ mod tests { #[test] fn alt_prefixes_escape() { - let mods = Modifiers { alt: true, ..NONE }; + let m = Modifiers { alt: true, ..NONE }; assert_eq!( - encode(&key(Keysym::a, Some("a")), mods, false), + encode(&key(Keysym::a, Some("a")), m, false), Some(b"\x1ba".to_vec()) ); } @@ -438,54 +394,13 @@ mod tests { #[test] fn modified_arrow_uses_csi_param() { - let mods = Modifiers { ctrl: true, ..NONE }; + let m = Modifiers { ctrl: true, ..NONE }; assert_eq!( - encode(&key(Keysym::Right, None), mods, false), + encode(&key(Keysym::Right, None), m, 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. diff --git a/crates/beer-protocols/src/lib.rs b/crates/beer-protocols/src/lib.rs new file mode 100644 index 0000000..7b0ea4f --- /dev/null +++ b/crates/beer-protocols/src/lib.rs @@ -0,0 +1,36 @@ +//! Terminal protocol building blocks for [beer](https://github.com/NotAShelf/beer). +//! +//! This crate gathers the self-contained pieces of beer's terminal-protocol +//! support: the byte codecs an escape stream needs, the terminfo capability +//! table, character-set translation, SGR colour/underline parsing, and the +//! keyboard/mouse wire encoders. It deliberately holds no terminal state (no +//! grid, no parser loop): every item here is a pure function or a plain data +//! type, so each protocol detail can be read and tested on its own. beer wires +//! these into its `vte`-driven dispatcher and its [`Grid`] model. +//! +//! The modules map onto the protocols a terminal user cares about: +//! +//! - [`codec`] - base64 (OSC 52 clipboard), hex (XTGETTCAP), and `file://` URI +//! percent-decoding (OSC 7). +//! - [`caps`] - the terminfo capabilities answered over XTGETTCAP. +//! - [`charset`] - G0/G1 designation and DEC special-graphics line drawing. +//! - [`sgr`] - the multi-parameter SGR colour and underline forms. +//! - [`key`] - legacy xterm/VT and kitty keyboard-protocol key encoding. +//! - [`mouse`] - X10/UTF-8/SGR mouse-report encoding. +//! - [`style`] - the enums an SGR/DECSET stream selects (colour, underline, +//! cursor shape, mouse protocol/encoding, shell-integration prompt marks). +//! +//! See `README.md` for the full inventory of escape sequences, OSC commands, +//! and POSIX behaviour beer implements. + +pub mod caps; +pub mod charset; +pub mod codec; +pub mod key; +pub mod mouse; +pub mod sgr; +pub mod style; + +pub use style::{ + Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline, prompt_kind, +}; diff --git a/crates/beer-protocols/src/mouse.rs b/crates/beer-protocols/src/mouse.rs new file mode 100644 index 0000000..5ed3150 --- /dev/null +++ b/crates/beer-protocols/src/mouse.rs @@ -0,0 +1,106 @@ +//! Mouse-event encoding: frame a button/motion report in the wire form the +//! application selected (legacy byte form, UTF-8, or SGR). + +use std::io::Write as _; + +use smithay_client_toolkit::seat::keyboard::Modifiers; + +use crate::style::MouseEncoding; + +/// Encode a mouse event for the application. `button` is the base code (0/1/2 +/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based +/// screen cells; `motion` marks a drag/move report. Returns the bytes to send. +pub fn encode_mouse( + encoding: MouseEncoding, + button: u8, + col: usize, + row: usize, + pressed: bool, + motion: bool, + mods: Modifiers, +) -> Vec { + 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, value: u32, utf8: bool) { + if utf8 { + let v = value.min(0x7ff); + if v >= 0x80 { + out.push(0xc0 | (v >> 6) as u8); + out.push(0x80 | (v & 0x3f) as u8); + } else { + out.push(v as u8); + } + } else { + out.push(value.min(255) as u8); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NONE: Modifiers = Modifiers { + ctrl: false, + alt: false, + shift: false, + caps_lock: false, + logo: false, + num_lock: false, + }; + + #[test] + fn mouse_sgr_press_and_release() { + // Left press at column 3, row 5 (0-based) -> SGR with 1-based coords. + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE), + b"\x1b[<0;4;6M".to_vec() + ); + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE), + b"\x1b[<0;4;6m".to_vec() + ); + } + + #[test] + fn mouse_legacy_release_collapses_to_three() { + // Legacy release: button bits become 3; values offset by 32. + assert_eq!( + encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE), + vec![0x1b, b'[', b'M', 32 + 3, 33, 33] + ); + } + + #[test] + fn mouse_motion_and_modifiers_set_bits() { + let mods = Modifiers { ctrl: true, ..NONE }; + // Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl). + assert_eq!( + encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods), + b"\x1b[<48;1;1M".to_vec() + ); + } +} diff --git a/crates/beer-protocols/src/sgr.rs b/crates/beer-protocols/src/sgr.rs new file mode 100644 index 0000000..ebdd572 --- /dev/null +++ b/crates/beer-protocols/src/sgr.rs @@ -0,0 +1,97 @@ +//! SGR (Select Graphic Rendition) parsing: the colour and underline forms that +//! need more than a single parameter. Plain attribute toggles (bold, reverse, +//! ...) stay in the dispatcher; what lives here is the multi-parameter parsing +//! that benefits from being testable in isolation. + +use crate::style::{Color, Underline}; + +/// Map an SGR 4 parameter (`4` or `4:x`) to an underline style. +pub fn underline_from(param: &[u16]) -> Underline { + match param.get(1).copied().unwrap_or(1) { + 0 => Underline::None, + 2 => Underline::Double, + 3 => Underline::Curly, + 4 => Underline::Dotted, + 5 => Underline::Dashed, + _ => Underline::Single, + } +} + +/// Parse an SGR 38/48/58 extended colour, given the full parameter list and the +/// index of the introducer. Returns the colour and how many top-level +/// parameters it consumed (1 for the colon-subparameter form, more for the +/// legacy semicolon form). +pub fn ext_color(items: &[&[u16]], i: usize) -> (Option, 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 { + match sub.first().copied() { + Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)), + Some(2) => { + // Either `2:r:g:b` or `2:colorspace:r:g:b`. + let rgb = if sub.len() >= 5 { + &sub[2..5] + } else { + &sub[1..] + }; + match rgb { + [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)), + _ => None, + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn underline_styles() { + assert_eq!(underline_from(&[4]), Underline::Single); + assert_eq!(underline_from(&[4, 3]), Underline::Curly); + assert_eq!(underline_from(&[4, 0]), Underline::None); + } + + #[test] + fn extended_colour_semicolon_and_colon() { + // `38;2;10;20;30` direct RGB. + let items: Vec<&[u16]> = vec![&[38], &[2], &[10], &[20], &[30]]; + assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(10, 20, 30)), 5)); + // `38:2:40:50:60` colon subparameters. + let items: Vec<&[u16]> = vec![&[38, 2, 40, 50, 60]]; + assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(40, 50, 60)), 1)); + // `38;5;1` indexed. + let items: Vec<&[u16]> = vec![&[38], &[5], &[1]]; + assert_eq!(ext_color(&items, 0), (Some(Color::Indexed(1)), 3)); + } +} diff --git a/crates/beer-protocols/src/style.rs b/crates/beer-protocols/src/style.rs new file mode 100644 index 0000000..a0305bd --- /dev/null +++ b/crates/beer-protocols/src/style.rs @@ -0,0 +1,83 @@ +//! The protocol vocabulary: the enums an SGR/DECSET stream selects, shared by +//! the parser, the grid model, and the renderer. + +/// A cell colour: terminal default, a palette index, or direct RGB (SGR 30-49, +/// 90-107, and the `38`/`48`/`58` extended forms). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Color { + #[default] + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +/// Underline style (SGR 4 / 4:x / 21). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Underline { + #[default] + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} + +/// Cursor shape (DECSCUSR, `CSI Ps SP q`). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CursorShape { + #[default] + Block, + Underline, + Beam, +} + +/// Which mouse events the application has asked to receive (DECSET 9/1000-1003). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MouseProtocol { + /// No reporting; the pointer drives local selection/scroll. + #[default] + Off, + /// X10 (9): button presses only. + X10, + /// Normal (1000): button press and release. + Normal, + /// Button-event (1002): press, release, and motion while a button is held. + Button, + /// Any-event (1003): press, release, and all pointer motion. + Any, +} + +/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MouseEncoding { + /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (<= 223). + #[default] + X10, + /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005). + Utf8, + /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006). + Sgr, +} + +/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt, +/// the start of typed command input, the start of command output, or the line +/// where the command finished. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PromptKind { + PromptStart, + CmdStart, + OutputStart, + CmdEnd, +} + +/// Map an OSC 133 mark letter (`A`/`B`/`C`/`D`) to a [`PromptKind`]. +pub fn prompt_kind(b: u8) -> Option { + match b { + b'A' => Some(PromptKind::PromptStart), + b'B' => Some(PromptKind::CmdStart), + b'C' => Some(PromptKind::OutputStart), + b'D' => Some(PromptKind::CmdEnd), + _ => None, + } +} diff --git a/crates/beer/Cargo.toml b/crates/beer/Cargo.toml new file mode 100644 index 0000000..ba8fc0d --- /dev/null +++ b/crates/beer/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "beer" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "A fast, software-rendered, Wayland-native terminal emulator" +readme = true + +[dependencies] +anyhow = "1.0.102" +beer-protocols = { path = "../beer-protocols" } +calloop = { version = "0.14.4", features = ["signals"] } +calloop-wayland-source = "0.4.1" +fontconfig = "0.11.0" +freetype-rs = "0.38.0" +harfbuzz_rs_now = "2.3.2" +lru = "0.18.0" +pound = "0.1.6" +rustix = { version = "1.1.4", features = [ + "pty", + "process", + "termios", + "stdio", + "fs", +] } +serde = { version = "1.0.228", features = ["derive"] } +serde_ignored = "0.1.14" +smithay-client-toolkit = "0.20.0" +thiserror = "2.0.18" +toml = "1.1.2" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +unicode-width = "0.2.2" +vte = "0.15.0" +wayland-client = "0.31.14" +wayland-protocols = { version = "0.32.13", features = [ + "client", + "staging", + "unstable", +] } + +[lints] +workspace = true diff --git a/src/bindings.rs b/crates/beer/src/bindings.rs similarity index 100% rename from src/bindings.rs rename to crates/beer/src/bindings.rs diff --git a/src/config.rs b/crates/beer/src/config.rs similarity index 100% rename from src/config.rs rename to crates/beer/src/config.rs diff --git a/src/font.rs b/crates/beer/src/font.rs similarity index 100% rename from src/font.rs rename to crates/beer/src/font.rs diff --git a/src/grid/links.rs b/crates/beer/src/grid/links.rs similarity index 100% rename from src/grid/links.rs rename to crates/beer/src/grid/links.rs diff --git a/src/grid/mod.rs b/crates/beer/src/grid/mod.rs similarity index 96% rename from src/grid/mod.rs rename to crates/beer/src/grid/mod.rs index d82ea33..ef06572 100644 --- a/src/grid/mod.rs +++ b/crates/beer/src/grid/mod.rs @@ -16,14 +16,10 @@ 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), -} +/// 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)] @@ -62,55 +58,6 @@ impl Flags { } } -/// 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 { @@ -150,17 +97,6 @@ struct Cursor { 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. diff --git a/src/grid/search.rs b/crates/beer/src/grid/search.rs similarity index 100% rename from src/grid/search.rs rename to crates/beer/src/grid/search.rs diff --git a/src/grid/selection.rs b/crates/beer/src/grid/selection.rs similarity index 100% rename from src/grid/selection.rs rename to crates/beer/src/grid/selection.rs diff --git a/src/main.rs b/crates/beer/src/main.rs similarity index 99% rename from src/main.rs rename to crates/beer/src/main.rs index c31d897..3dc53a0 100644 --- a/src/main.rs +++ b/crates/beer/src/main.rs @@ -4,7 +4,6 @@ mod bindings; mod config; mod font; mod grid; -mod input; mod pty; mod render; mod theme; diff --git a/src/pty.rs b/crates/beer/src/pty.rs similarity index 100% rename from src/pty.rs rename to crates/beer/src/pty.rs diff --git a/src/render.rs b/crates/beer/src/render.rs similarity index 100% rename from src/render.rs rename to crates/beer/src/render.rs diff --git a/src/theme.rs b/crates/beer/src/theme.rs similarity index 100% rename from src/theme.rs rename to crates/beer/src/theme.rs diff --git a/src/vt/mod.rs b/crates/beer/src/vt/mod.rs similarity index 78% rename from src/vt/mod.rs rename to crates/beer/src/vt/mod.rs index af8ac58..4021cc5 100644 --- a/src/vt/mod.rs +++ b/crates/beer/src/vt/mod.rs @@ -6,17 +6,14 @@ use std::io::Write as _; use vte::Params; -use crate::grid::{ - Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline, -}; -use crate::theme::{Rgb, Theme}; +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; -/// G0/G1 character set designation. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Charset { - Ascii, - DecSpecial, -} +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)] @@ -84,18 +81,6 @@ 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 { @@ -478,228 +463,11 @@ fn raw(params: &Params, idx: usize) -> u16 { .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, 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 { - 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> { - let val = |c: u8| -> Option { - 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 = 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 { 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 { - 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 { - 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 { - 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> { - 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::*; @@ -859,25 +627,6 @@ mod tests { 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); diff --git a/src/vt/perform.rs b/crates/beer/src/vt/perform.rs similarity index 100% rename from src/vt/perform.rs rename to crates/beer/src/vt/perform.rs diff --git a/src/wayland/handlers.rs b/crates/beer/src/wayland/handlers.rs similarity index 100% rename from src/wayland/handlers.rs rename to crates/beer/src/wayland/handlers.rs diff --git a/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rs similarity index 98% rename from src/wayland/mod.rs rename to crates/beer/src/wayland/mod.rs index b74ba1f..53f8e29 100644 --- a/src/wayland/mod.rs +++ b/crates/beer/src/wayland/mod.rs @@ -611,9 +611,9 @@ impl App { 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 + beer_protocols::key::KeyKind::Press } else { - crate::input::KeyKind::Repeat + beer_protocols::key::KeyKind::Repeat }; // The Unicode-input prompt, URL hint mode, and search each capture the @@ -644,9 +644,9 @@ impl App { (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) + beer_protocols::key::kitty_encode(event, self.modifiers, kitty, kind, app_cursor) } else { - crate::input::encode(event, self.modifiers, app_cursor) + beer_protocols::key::encode(event, self.modifiers, app_cursor) }; if let Some(bytes) = bytes { self.send_to_shell(&bytes); @@ -663,11 +663,11 @@ impl App { if kitty == 0 { return; } - if let Some(bytes) = crate::input::kitty_encode( + if let Some(bytes) = beer_protocols::key::kitty_encode( event, self.modifiers, kitty, - crate::input::KeyKind::Release, + beer_protocols::key::KeyKind::Release, app_cursor, ) { self.send_to_shell(&bytes); @@ -1413,8 +1413,15 @@ impl App { 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); + 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)); } @@ -1444,7 +1451,15 @@ impl App { { // 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); + 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)); } @@ -1588,7 +1603,7 @@ impl App { let kind = if primary { 'p' } else { 'c' }; let reply = format!( "\x1b]52;{kind};{}\x07", - crate::vt::base64_encode(text.as_bytes()) + beer_protocols::codec::base64_encode(text.as_bytes()) ); self.write_to_pty(reply.as_bytes()); } diff --git a/src/wayland/rendering.rs b/crates/beer/src/wayland/rendering.rs similarity index 100% rename from src/wayland/rendering.rs rename to crates/beer/src/wayland/rendering.rs diff --git a/nix/package.nix b/nix/package.nix index 1b302de..5c19725 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -12,11 +12,11 @@ fontconfig, harfbuzz, }: let - cargoTOML = (lib.importTOML ../Cargo.toml).package.version; + cargoTOML = lib.importTOML ../Cargo.toml; in rustPlatform.buildRustPackage (finalAttrs: { pname = "beer"; - version = cargoTOML.package.version; + version = cargoTOML.workspace.package.version; src = let fs = lib.fileset; @@ -25,7 +25,7 @@ in fs.toSource { root = s; fileset = fs.unions [ - (s + /src) + (s + /crates) (s + /Cargo.lock) (s + /Cargo.toml) (s + /terminfo)