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
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -31,9 +31,10 @@ checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beer"
|
name = "beer"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"beer-protocols",
|
||||||
"calloop",
|
"calloop",
|
||||||
"calloop-wayland-source",
|
"calloop-wayland-source",
|
||||||
"fontconfig",
|
"fontconfig",
|
||||||
|
|
@ -55,6 +56,13 @@ dependencies = [
|
||||||
"wayland-protocols",
|
"wayland-protocols",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beer-protocols"
|
||||||
|
version = "0.4.0"
|
||||||
|
dependencies = [
|
||||||
|
"smithay-client-toolkit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
|
|
|
||||||
47
Cargo.toml
47
Cargo.toml
|
|
@ -1,49 +1,18 @@
|
||||||
[package]
|
[workspace]
|
||||||
name = "beer"
|
resolver = "3"
|
||||||
version = "0.3.1"
|
members = ["crates/beer", "crates/beer-protocols"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95.0"
|
rust-version = "1.95.0"
|
||||||
description = "A fast, software-rendered, Wayland-native terminal emulator"
|
|
||||||
license = "EUPL-1.2"
|
license = "EUPL-1.2"
|
||||||
readme = true
|
|
||||||
|
|
||||||
[dependencies]
|
[workspace.lints.rust]
|
||||||
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]
|
|
||||||
unsafe_op_in_unsafe_fn = "deny"
|
unsafe_op_in_unsafe_fn = "deny"
|
||||||
missing_debug_implementations = "warn"
|
missing_debug_implementations = "warn"
|
||||||
|
|
||||||
[lints.clippy]
|
[workspace.lints.clippy]
|
||||||
all = { level = "warn", priority = -1 }
|
all = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
//! Keyboard encoding: translate decoded key events into the byte sequences a
|
//! Keyboard encoding: translate decoded key events into the byte sequences a
|
||||||
//! terminal application expects (xterm/VT-style).
|
//! terminal application expects, in both the legacy xterm/VT form and the kitty
|
||||||
|
//! keyboard progressive-enhancement form.
|
||||||
use std::io::Write as _;
|
|
||||||
|
|
||||||
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};
|
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};
|
||||||
|
|
||||||
use crate::grid::MouseEncoding;
|
/// 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
|
||||||
/// Encode a key press into bytes for the PTY, or `None` if it produces no input.
|
/// (DECCKM).
|
||||||
pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
|
pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
|
||||||
let seq = match event.keysym {
|
let seq = match event.keysym {
|
||||||
Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),
|
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.
|
/// 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 {
|
pub enum KeyKind {
|
||||||
Press = 1,
|
Press = 1,
|
||||||
Repeat = 2,
|
Repeat = 2,
|
||||||
|
|
@ -280,58 +279,6 @@ fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> Stri
|
||||||
cp.to_string()
|
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> {
|
fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {
|
||||||
if mods.alt {
|
if mods.alt {
|
||||||
let mut out = Vec::with_capacity(bytes.len() + 1);
|
let mut out = Vec::with_capacity(bytes.len() + 1);
|
||||||
|
|
@ -407,6 +354,15 @@ mod tests {
|
||||||
num_lock: false,
|
num_lock: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {
|
||||||
|
Modifiers {
|
||||||
|
ctrl,
|
||||||
|
shift,
|
||||||
|
alt,
|
||||||
|
..NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_text_passes_through() {
|
fn plain_text_passes_through() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -417,9 +373,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alt_prefixes_escape() {
|
fn alt_prefixes_escape() {
|
||||||
let mods = Modifiers { alt: true, ..NONE };
|
let m = Modifiers { alt: true, ..NONE };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode(&key(Keysym::a, Some("a")), mods, false),
|
encode(&key(Keysym::a, Some("a")), m, false),
|
||||||
Some(b"\x1ba".to_vec())
|
Some(b"\x1ba".to_vec())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -438,54 +394,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn modified_arrow_uses_csi_param() {
|
fn modified_arrow_uses_csi_param() {
|
||||||
let mods = Modifiers { ctrl: true, ..NONE };
|
let m = Modifiers { ctrl: true, ..NONE };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode(&key(Keysym::Right, None), mods, false),
|
encode(&key(Keysym::Right, None), m, false),
|
||||||
Some(b"\x1b[1;5C".to_vec())
|
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]
|
#[test]
|
||||||
fn kitty_plain_text_key_stays_text() {
|
fn kitty_plain_text_key_stays_text() {
|
||||||
// Disambiguate only: an unmodified letter is still sent as text.
|
// Disambiguate only: an unmodified letter is still sent as text.
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/beer/Cargo.toml
Normal file
44
crates/beer/Cargo.toml
Normal 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
|
||||||
|
|
@ -16,14 +16,10 @@ use search::SearchState;
|
||||||
/// Maximum scrollback lines retained for the main screen.
|
/// Maximum scrollback lines retained for the main screen.
|
||||||
const SCROLLBACK_CAP: usize = 10_000;
|
const SCROLLBACK_CAP: usize = 10_000;
|
||||||
|
|
||||||
/// A cell colour: terminal default, a palette index, or direct RGB.
|
/// The protocol vocabulary an SGR/DECSET stream selects lives in
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
/// `beer-protocols` and is re-exported here so the grid and renderer keep
|
||||||
pub enum Color {
|
/// referring to it as `grid::Color`, `grid::Underline`, and so on.
|
||||||
#[default]
|
pub use beer_protocols::{Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline};
|
||||||
Default,
|
|
||||||
Indexed(u8),
|
|
||||||
Rgb(u8, u8, u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-cell style flags, packed into a `u16`.
|
/// Per-cell style flags, packed into a `u16`.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
#[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.
|
/// One grid cell: a character plus its rendering style.
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct Cell {
|
pub struct Cell {
|
||||||
|
|
@ -150,17 +97,6 @@ struct Cursor {
|
||||||
y: usize,
|
y: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
|
|
||||||
/// the start of typed command input, the start of command output, or the line
|
|
||||||
/// where the command finished.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum PromptKind {
|
|
||||||
PromptStart,
|
|
||||||
CmdStart,
|
|
||||||
OutputStart,
|
|
||||||
CmdEnd,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One screen/scrollback row: its cells plus whether it soft-wrapped into the
|
/// 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
|
/// next row (autowrap continuation, as opposed to a hard line break). The flag
|
||||||
/// is what lets resize rejoin and rewrap paragraphs.
|
/// is what lets resize rejoin and rewrap paragraphs.
|
||||||
|
|
@ -4,7 +4,6 @@ mod bindings;
|
||||||
mod config;
|
mod config;
|
||||||
mod font;
|
mod font;
|
||||||
mod grid;
|
mod grid;
|
||||||
mod input;
|
|
||||||
mod pty;
|
mod pty;
|
||||||
mod render;
|
mod render;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|
@ -6,17 +6,14 @@ use std::io::Write as _;
|
||||||
|
|
||||||
use vte::Params;
|
use vte::Params;
|
||||||
|
|
||||||
use crate::grid::{
|
use beer_protocols::caps::cap_value;
|
||||||
Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,
|
use beer_protocols::charset::{Charset, charset, dec_special};
|
||||||
};
|
use beer_protocols::codec::{base64_decode, decode_hex, file_uri_path};
|
||||||
use crate::theme::{Rgb, Theme};
|
use beer_protocols::sgr::{ext_color, underline_from};
|
||||||
|
use beer_protocols::style::prompt_kind;
|
||||||
|
|
||||||
/// G0/G1 character set designation.
|
use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline};
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
use crate::theme::{Rgb, Theme};
|
||||||
enum Charset {
|
|
||||||
Ascii,
|
|
||||||
DecSpecial,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which device-attributes query is being answered.
|
/// Which device-attributes query is being answered.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
|
@ -84,18 +81,6 @@ fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {
|
||||||
if on { encoding } else { MouseEncoding::X10 }
|
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.
|
/// The terminal model: a grid plus the escape-sequence state around it.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Term {
|
pub struct Term {
|
||||||
|
|
@ -478,228 +463,11 @@ fn raw(params: &Params, idx: usize) -> u16 {
|
||||||
.unwrap_or(0)
|
.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.
|
/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.
|
||||||
fn osc_text(field: Option<&&[u8]>) -> Option<String> {
|
fn osc_text(field: Option<&&[u8]>) -> Option<String> {
|
||||||
field.map(|b| String::from_utf8_lossy(b).into_owned())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -859,25 +627,6 @@ mod tests {
|
||||||
assert_eq!(t.take_response(), b"\x1b[?2026;2$y");
|
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]
|
#[test]
|
||||||
fn osc52_set_and_query() {
|
fn osc52_set_and_query() {
|
||||||
let mut t = Term::new(20, 2);
|
let mut t = Term::new(20, 2);
|
||||||
|
|
@ -611,9 +611,9 @@ impl App {
|
||||||
fn handle_key(&mut self, event: &KeyEvent) {
|
fn handle_key(&mut self, event: &KeyEvent) {
|
||||||
// A new arrival of a held key is a repeat; otherwise a fresh press.
|
// A new arrival of a held key is a repeat; otherwise a fresh press.
|
||||||
let kind = if self.keys_down.insert(event.raw_code) {
|
let kind = if self.keys_down.insert(event.raw_code) {
|
||||||
crate::input::KeyKind::Press
|
beer_protocols::key::KeyKind::Press
|
||||||
} else {
|
} else {
|
||||||
crate::input::KeyKind::Repeat
|
beer_protocols::key::KeyKind::Repeat
|
||||||
};
|
};
|
||||||
|
|
||||||
// The Unicode-input prompt, URL hint mode, and search each capture the
|
// 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())
|
(s.term.grid().app_cursor(), s.term.grid().kitty_flags())
|
||||||
});
|
});
|
||||||
let bytes = if kitty != 0 {
|
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 {
|
} else {
|
||||||
crate::input::encode(event, self.modifiers, app_cursor)
|
beer_protocols::key::encode(event, self.modifiers, app_cursor)
|
||||||
};
|
};
|
||||||
if let Some(bytes) = bytes {
|
if let Some(bytes) = bytes {
|
||||||
self.send_to_shell(&bytes);
|
self.send_to_shell(&bytes);
|
||||||
|
|
@ -663,11 +663,11 @@ impl App {
|
||||||
if kitty == 0 {
|
if kitty == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(bytes) = crate::input::kitty_encode(
|
if let Some(bytes) = beer_protocols::key::kitty_encode(
|
||||||
event,
|
event,
|
||||||
self.modifiers,
|
self.modifiers,
|
||||||
kitty,
|
kitty,
|
||||||
crate::input::KeyKind::Release,
|
beer_protocols::key::KeyKind::Release,
|
||||||
app_cursor,
|
app_cursor,
|
||||||
) {
|
) {
|
||||||
self.send_to_shell(&bytes);
|
self.send_to_shell(&bytes);
|
||||||
|
|
@ -1413,8 +1413,15 @@ impl App {
|
||||||
if (pressed || proto != MouseProtocol::X10)
|
if (pressed || proto != MouseProtocol::X10)
|
||||||
&& let Some((col, row)) = self.report_screen_cell()
|
&& let Some((col, row)) = self.report_screen_cell()
|
||||||
{
|
{
|
||||||
let bytes =
|
let bytes = beer_protocols::mouse::encode_mouse(
|
||||||
crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);
|
enc,
|
||||||
|
code,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
pressed,
|
||||||
|
false,
|
||||||
|
self.modifiers,
|
||||||
|
);
|
||||||
self.write_to_pty(&bytes);
|
self.write_to_pty(&bytes);
|
||||||
self.last_report_cell = Some((col, row));
|
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.
|
// Any-event motion with no button held uses the "no button" code 3.
|
||||||
let code = self.pressed_button.unwrap_or(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.write_to_pty(&bytes);
|
||||||
self.last_report_cell = Some((col, row));
|
self.last_report_cell = Some((col, row));
|
||||||
}
|
}
|
||||||
|
|
@ -1588,7 +1603,7 @@ impl App {
|
||||||
let kind = if primary { 'p' } else { 'c' };
|
let kind = if primary { 'p' } else { 'c' };
|
||||||
let reply = format!(
|
let reply = format!(
|
||||||
"\x1b]52;{kind};{}\x07",
|
"\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());
|
self.write_to_pty(reply.as_bytes());
|
||||||
}
|
}
|
||||||
|
|
@ -12,11 +12,11 @@
|
||||||
fontconfig,
|
fontconfig,
|
||||||
harfbuzz,
|
harfbuzz,
|
||||||
}: let
|
}: let
|
||||||
cargoTOML = (lib.importTOML ../Cargo.toml).package.version;
|
cargoTOML = lib.importTOML ../Cargo.toml;
|
||||||
in
|
in
|
||||||
rustPlatform.buildRustPackage (finalAttrs: {
|
rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
pname = "beer";
|
pname = "beer";
|
||||||
version = cargoTOML.package.version;
|
version = cargoTOML.workspace.package.version;
|
||||||
|
|
||||||
src = let
|
src = let
|
||||||
fs = lib.fileset;
|
fs = lib.fileset;
|
||||||
|
|
@ -25,7 +25,7 @@ in
|
||||||
fs.toSource {
|
fs.toSource {
|
||||||
root = s;
|
root = s;
|
||||||
fileset = fs.unions [
|
fileset = fs.unions [
|
||||||
(s + /src)
|
(s + /crates)
|
||||||
(s + /Cargo.lock)
|
(s + /Cargo.lock)
|
||||||
(s + /Cargo.toml)
|
(s + /Cargo.toml)
|
||||||
(s + /terminfo)
|
(s + /terminfo)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue