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

10
Cargo.lock generated
View file

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

View file

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

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

@ -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<Vec<u8>> {
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<Vec
}
/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum KeyKind {
Press = 1,
Repeat = 2,
@ -280,58 +279,6 @@ fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> 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<u8> {
let mod_bits =
(u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);
let motion_bit = if motion { 32 } else { 0 };
let mut out = Vec::new();
match encoding {
MouseEncoding::Sgr => {
let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);
let final_byte = if pressed { 'M' } else { 'm' };
let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);
}
enc => {
// Legacy form collapses every button release to code 3.
let base = if !pressed && button <= 2 { 3 } else { button };
let cb = 32 + base + motion_bit + mod_bits;
out.extend_from_slice(b"\x1b[M");
push_coord(&mut out, u32::from(cb), false);
let utf8 = enc == MouseEncoding::Utf8;
push_coord(&mut out, col as u32 + 33, utf8);
push_coord(&mut out, row as u32 + 33, utf8);
}
}
out
}
/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when
/// the extended (1005) mode is active, and clamping otherwise.
fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {
if utf8 {
let v = value.min(0x7ff);
if v >= 0x80 {
out.push(0xc0 | (v >> 6) as u8);
out.push(0x80 | (v & 0x3f) as u8);
} else {
out.push(v as u8);
}
} else {
out.push(value.min(255) as u8);
}
}
fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {
if mods.alt {
let mut out = Vec::with_capacity(bytes.len() + 1);
@ -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.

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

44
crates/beer/Cargo.toml Normal file
View file

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

View file

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

View file

@ -4,7 +4,6 @@ mod bindings;
mod config;
mod font;
mod grid;
mod input;
mod pty;
mod render;
mod theme;

View file

@ -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<Color>, usize) {
let head = items[i];
if head.len() >= 2 {
return (color_from_subparams(&head[1..]), 1);
}
match items.get(i + 1).and_then(|s| s.first().copied()) {
Some(5) => {
let idx = items
.get(i + 2)
.and_then(|s| s.first().copied())
.unwrap_or(0);
(Some(Color::Indexed(idx as u8)), 3)
}
Some(2) => {
let get = |k: usize| {
items
.get(i + k)
.and_then(|s| s.first().copied())
.unwrap_or(0)
};
(
Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),
5,
)
}
_ => (None, 1),
}
}
fn color_from_subparams(sub: &[u16]) -> Option<Color> {
match sub.first().copied() {
Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),
Some(2) => {
// Either `2:r:g:b` or `2:colorspace:r:g:b`.
let rgb = if sub.len() >= 5 {
&sub[2..5]
} else {
&sub[1..]
};
match rgb {
[r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),
_ => None,
}
}
_ => None,
}
}
fn charset(byte: u8) -> Charset {
match byte {
b'0' => Charset::DecSpecial,
_ => Charset::Ascii,
}
}
/// Translate a byte under the DEC special graphics set (line drawing).
fn dec_special(c: char) -> char {
match c {
'`' => '◆',
'a' => '▒',
'f' => '°',
'g' => '±',
'j' => '┘',
'k' => '┐',
'l' => '┌',
'm' => '└',
'n' => '┼',
'o' => '⎺',
'p' => '⎻',
'q' => '─',
'r' => '⎼',
's' => '⎽',
't' => '├',
'u' => '┤',
'v' => '┴',
'w' => '┬',
'x' => '│',
'y' => '≤',
'z' => '≥',
'~' => '·',
_ => c,
}
}
/// Look up a terminfo capability beer reports via XTGETTCAP.
fn cap_value(name: &[u8]) -> Option<&'static str> {
match name {
b"TN" => Some("beer"),
b"Co" | b"colors" => Some("256"),
b"RGB" => Some("8/8/8"),
_ => None,
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 encode (used for OSC 52 query replies).
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(B64[(n >> 18 & 63) as usize] as char);
out.push(B64[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 {
B64[(n >> 6 & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
B64[(n & 63) as usize] as char
} else {
'='
});
}
out
}
/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad
/// character.
fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
match c {
b'A'..=b'Z' => Some(u32::from(c - b'A')),
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
};
let filtered: Vec<u8> = data
.iter()
.copied()
.filter(|&c| c != b'=' && !c.is_ascii_whitespace())
.collect();
let mut out = Vec::with_capacity(filtered.len() / 4 * 3);
for chunk in filtered.chunks(4) {
if chunk.len() == 1 {
return None; // a lone sextet cannot form a byte
}
let mut n = 0u32;
for &c in chunk {
n = (n << 6) | val(c)?;
}
n <<= 6 * (4 - chunk.len() as u32);
out.push((n >> 16) as u8);
if chunk.len() >= 3 {
out.push((n >> 8) as u8);
}
if chunk.len() >= 4 {
out.push(n as u8);
}
}
Some(out)
}
/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.
fn osc_text(field: Option<&&[u8]>) -> Option<String> {
field.map(|b| String::from_utf8_lossy(b).into_owned())
}
/// Map an OSC 133 mark letter to a [`PromptKind`].
fn prompt_kind(b: u8) -> Option<PromptKind> {
match b {
b'A' => Some(PromptKind::PromptStart),
b'B' => Some(PromptKind::CmdStart),
b'C' => Some(PromptKind::OutputStart),
b'D' => Some(PromptKind::CmdEnd),
_ => None,
}
}
/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
/// `None` if it is not a usable absolute path.
fn file_uri_path(uri: &[u8]) -> Option<String> {
let rest = uri.strip_prefix(b"file://").unwrap_or(uri);
// Skip the authority (host) up to the first '/', which begins the path.
let slash = rest.iter().position(|&b| b == b'/')?;
let path_bytes = percent_decode(&rest[slash..]);
let path = String::from_utf8(path_bytes).ok()?;
path.starts_with('/').then_some(path)
}
/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.
fn percent_decode(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while i < s.len() {
if s[i] == b'%' && i + 2 < s.len() {
let hi = (s[i + 1] as char).to_digit(16);
let lo = (s[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(s[i]);
i += 1;
}
out
}
/// Decode an even-length lowercase/uppercase hex string into bytes.
fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
return None;
}
let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
s.chunks_exact(2)
.map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -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);

View file

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

View file

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