meta: split protocol codecs and encoders into a beer-protocols crate

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib7706308c892e43d2044fbb766505e9e6a6a6964
This commit is contained in:
raf 2026-06-26 11:44:20 +03:00
commit 1f1451f108
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
30 changed files with 910 additions and 484 deletions

View file

@ -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

View file

@ -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.

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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);
}
}

View file

@ -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())
);
}
}

View file

@ -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,
};

View file

@ -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()
);
}
}

View file

@ -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));
}
}

View file

@ -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,
}
}