forked from NotAShelf/beer
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:
parent
3e49e94f56
commit
1f1451f108
30 changed files with 910 additions and 484 deletions
17
crates/beer-protocols/Cargo.toml
Normal file
17
crates/beer-protocols/Cargo.toml
Normal 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
|
||||
212
crates/beer-protocols/README.md
Normal file
212
crates/beer-protocols/README.md
Normal 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.
|
||||
25
crates/beer-protocols/src/caps.rs
Normal file
25
crates/beer-protocols/src/caps.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
61
crates/beer-protocols/src/charset.rs
Normal file
61
crates/beer-protocols/src/charset.rs
Normal 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
|
||||
}
|
||||
}
|
||||
154
crates/beer-protocols/src/codec.rs
Normal file
154
crates/beer-protocols/src/codec.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
578
crates/beer-protocols/src/key.rs
Normal file
578
crates/beer-protocols/src/key.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
36
crates/beer-protocols/src/lib.rs
Normal file
36
crates/beer-protocols/src/lib.rs
Normal 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,
|
||||
};
|
||||
106
crates/beer-protocols/src/mouse.rs
Normal file
106
crates/beer-protocols/src/mouse.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
97
crates/beer-protocols/src/sgr.rs
Normal file
97
crates/beer-protocols/src/sgr.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
83
crates/beer-protocols/src/style.rs
Normal file
83
crates/beer-protocols/src/style.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue