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

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

View file

@ -0,0 +1,17 @@
[package]
name = "beer-protocols"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Terminal protocol codecs and key/mouse wire encoders used by beer"
readme = "README.md"
[dependencies]
# Provides the keyboard vocabulary (Keysym, Modifiers, KeyEvent) the key
# encoder consumes; the version is kept in lockstep with beer so the types
# unify to a single definition across the workspace.
smithay-client-toolkit = "0.20.0"
[lints]
workspace = true

View file

@ -0,0 +1,212 @@
# beer-protocols
The terminal-protocol building blocks beer is built on: byte codecs, the
terminfo capability table, character-set translation, SGR parsing, and the
keyboard/mouse wire encoders. Everything here is a pure function or a plain
data type, with no terminal state, so each protocol detail can be read and
tested on its own. beer feeds these into its `vte`-driven dispatcher and its
grid model.
This README doubles as the reference for **which escape sequences, OSC
commands, and POSIX behaviours beer actually implements** - the parts a user
notices. Sequences not listed here are parsed and ignored rather than passed
through. Notation: `ESC` is `0x1b`, `CSI` is `ESC [`, `OSC` is `ESC ]`, `DCS`
is `ESC P`, `ST` is the string terminator `ESC \` (a `BEL` is also accepted to
end an OSC). `Ps` is a numeric parameter, `Pt` a text parameter.
## Crate layout
| Module | What it covers |
| --- | --- |
| `codec` | base64 (OSC 52), hex (XTGETTCAP names), `file://` URI percent-decoding (OSC 7) |
| `caps` | terminfo capabilities answered over XTGETTCAP |
| `charset` | G0/G1 designation and DEC special-graphics line drawing |
| `sgr` | the multi-parameter SGR colour and underline forms |
| `key` | legacy xterm/VT and kitty keyboard-protocol key encoding |
| `mouse` | X10 / UTF-8 / SGR mouse-report encoding |
| `style` | the enums an SGR/DECSET stream selects |
---
## C0 control characters
| Byte | Name | Effect |
| --- | --- | --- |
| `0x07` | BEL | rings the bell (visual flash / command / urgency, per config) |
| `0x08` | BS | backspace |
| `0x09` | HT | tab to next stop |
| `0x0A`-`0x0C` | LF/VT/FF | line feed |
| `0x0D` | CR | carriage return |
| `0x0E` | SO | invoke G1 (shift out) |
| `0x0F` | SI | invoke G0 (shift in) |
## Cursor movement and editing (CSI)
| Sequence | Name | Effect |
| --- | --- | --- |
| `CSI Ps A` | CUU | cursor up |
| `CSI Ps B` / `CSI Ps e` | CUD | cursor down |
| `CSI Ps C` / `CSI Ps a` | CUF | cursor forward |
| `CSI Ps D` | CUB | cursor back |
| `CSI Ps E` | CNL | cursor down, to column 1 |
| `CSI Ps F` | CPL | cursor up, to column 1 |
| `CSI Ps G` / `CSI Ps \`` | CHA | move to column |
| `CSI Ps d` | VPA | move to row |
| `CSI Ps ; Ps H` / `f` | CUP/HVP | move to row;col |
| `CSI Ps J` | ED | erase in display (0 below, 1 above, 2 all) |
| `CSI Ps K` | EL | erase in line (0 right, 1 left, 2 all) |
| `CSI Ps @` | ICH | insert blank characters |
| `CSI Ps P` | DCH | delete characters |
| `CSI Ps L` | IL | insert lines |
| `CSI Ps M` | DL | delete lines |
| `CSI Ps X` | ECH | erase characters |
| `CSI Ps S` | SU | scroll up |
| `CSI Ps T` | SD | scroll down |
| `CSI Ps ; Ps r` | DECSTBM | set scroll region (top;bottom) |
| `CSI Ps g` | TBC | clear tab stop (0) or all tabs (3) |
| `CSI s` / `CSI u` | SCOSC/SCORC | save / restore cursor |
## ESC (non-CSI)
| Sequence | Name | Effect |
| --- | --- | --- |
| `ESC D` | IND | line feed |
| `ESC M` | RI | reverse index |
| `ESC E` | NEL | next line |
| `ESC 7` / `ESC 8` | DECSC/DECRC | save / restore cursor |
| `ESC H` | HTS | set tab stop |
| `ESC c` | RIS | full reset |
| `ESC ( c` / `ESC ) c` | SCS | designate G0 / G1 charset (`0` = DEC special graphics, `B` = ASCII) |
## Graphic rendition (SGR, `CSI Ps m`)
Bold (1), dim (2), italic (3), underline (4), blink (5/6), reverse (7), hidden
(8), strike (9), and their resets (22-29). Overline (53) and its reset (55).
Underline styles via `4:x` and SGR 21: single, double, curly, dotted, dashed.
Colours: the 16 ANSI colours (30-37, 90-97 foreground; 40-47, 100-107
background) and default (39/49). Extended colour for foreground (38),
background (48), and underline (58/59), in both the legacy semicolon form
(`38;5;n`, `38;2;r;g;b`) and the colon-subparameter form (`38:5:n`,
`38:2:r:g:b`, with an ignored colour-space id). 256-colour and 24-bit truecolor
are fully supported.
## Modes (DECSET/DECRST `CSI [?] Ps h` / `l`)
| Mode | Name | Effect |
| --- | --- | --- |
| `4` (ANSI) | IRM | insert/replace |
| `?1` | DECCKM | application cursor keys |
| `?6` | DECOM | origin mode |
| `?7` | DECAWM | autowrap |
| `?25` | DECTCEM | cursor visibility |
| `?9` | - | X10 mouse reporting |
| `?1000` | - | normal mouse (press/release) |
| `?1002` | - | button-event mouse (drag) |
| `?1003` | - | any-event mouse (all motion) |
| `?1004` | - | focus in/out reporting |
| `?1005` | - | UTF-8 mouse coordinates |
| `?1006` | - | SGR mouse coordinates |
| `?47` / `?1047` | - | alternate screen |
| `?1049` | - | alternate screen + save/restore cursor |
| `?2004` | - | bracketed paste |
| `?2026` | - | synchronized output |
Mode state is reportable with **DECRQM** (`CSI [?] Ps $ p`), which replies
`CSI [?] Ps ; state $ y` (1 set, 2 reset, 0 unrecognized).
## Cursor style (DECSCUSR, `CSI Ps SP q`)
`0`/`1` blinking block, `2` steady block, `3` blinking underline, `4` steady
underline, `5` blinking bar, `6` steady bar. The configured default applies
until an application overrides it.
## Device reports
| Sequence | Name | Reply |
| --- | --- | --- |
| `CSI c` | DA1 | `CSI ?62;22c` (VT220 + ANSI colour) |
| `CSI > c` | DA2 | `CSI >0;276;0c` |
| `CSI = c` | DA3 | `DCS !\|00000000 ST` |
| `CSI 5 n` | DSR | `CSI 0n` (terminal OK) |
| `CSI 6 n` | CPR | `CSI row;col R` (cursor position) |
| `CSI > q` | XTVERSION | `DCS >\|beer(version) ST` |
| `DCS + q <names> ST` | XTGETTCAP | per name, `DCS 1 + r name=value ST` or `DCS 0 + r name ST` |
XTGETTCAP answers `TN` (terminal name `beer`), `Co`/`colors` (256), and `RGB`
(`8/8/8`, i.e. truecolor).
## Window title
`OSC 0 ; Pt` and `OSC 2 ; Pt` set the title. The title stack (`CSI 22 t` push,
`CSI 23 t` pop) is supported, so full-screen programs can save and restore it.
## OSC commands
| Command | Effect |
| --- | --- |
| `OSC 0` / `OSC 2` | set window title |
| `OSC 4 ; idx ; spec` | set / query palette entry (`?` queries) |
| `OSC 104 [; idx ...]` | reset palette (all, or listed entries) |
| `OSC 10` / `OSC 11` | set / query default foreground / background |
| `OSC 110` / `OSC 111` | reset foreground / background |
| `OSC 12` / `OSC 112` | set / reset cursor colour |
| `OSC 17` / `OSC 19` | set / query selection background / foreground |
| `OSC 7 ; file://host/path` | report working directory (used for new windows) |
| `OSC 8 ; params ; URI` | hyperlink (empty URI ends it) |
| `OSC 9 ; Pt` | desktop notification (iTerm2 style) |
| `OSC 777 ; notify ; title ; body` | desktop notification (rxvt style) |
| `OSC 99 ; metadata ; body` | desktop notification (kitty style, single-chunk) |
| `OSC 52 ; target ; data` | clipboard set (base64) or query (`?`) |
| `OSC 133 ; A/B/C/D` | shell-integration prompt marks |
Colour specs follow the X11 forms `#rrggbb` and `rgb:rr/gg/bb` (1-4 hex digits
per channel). Colour queries reply in the `rgb:rrrr/gggg/bbbb` form.
### Clipboard (OSC 52)
`OSC 52 ; c ; <base64>` sets the clipboard, `; p ;` the primary selection;
`; c ; ?` queries. For privacy, a query is answered from the text beer itself
last placed there, **not** the live system clipboard - so a remote program
cannot read what another application copied.
### Shell integration (OSC 133 / OSC 7)
Prompt marks `A` (prompt start), `B` (command start), `C` (output start), and
`D` (command end) drive prompt-jumping and "pipe last command output". `OSC 7`
tracks the working directory so a new window opens in the same place.
## Keyboard
The legacy xterm/VT encoding covers cursor keys (with DECCKM application mode),
the editing keypad (Insert/Delete/PageUp/PageDown), F1-F12, and the
xterm modifier parameter (`CSI 1 ; m <letter>`), with Alt sending an ESC
(meta) prefix.
The **kitty keyboard protocol** (`CSI > flags u` push, `CSI < flags u` pop,
`CSI = flags ; mode u` set, `CSI ? u` query) is implemented with the
disambiguate, report-event-types, report-alternate-keys, report-all-keys, and
report-associated-text enhancement flags. Keys are encoded as `CSI
code[:shifted] ; mod[:event] [; text] u`, with a 32-deep push/pop stack. The
common keys (Enter, Tab, Backspace) keep their legacy bytes when unmodified so
a plain shell stays usable.
## Mouse
Reports are framed in the legacy byte form (`CSI M Cb Cx Cy`), the UTF-8
coordinate form (DECSET 1005), or the SGR form (`CSI < Cb ; Cx ; Cy M/m`,
DECSET 1006). Shift/Alt/Ctrl modifier bits and the motion bit are encoded;
legacy releases collapse to button code 3. Reporting level is chosen by the
application via the mouse modes above; with reporting off the pointer drives
local selection and scrollback.
## POSIX / terminal behaviour
- A real PTY pair (`rustix` `openpt`/`grantpt`/`unlockpt`), with the child's
`TERM` set to the configured value (default `beer`) and the window size kept
in sync via `TIOCSWINSZ` (`SIGWINCH` reaches the child).
- The child's exit status is propagated as beer's own exit code.
- Alternate screen, scroll regions, autowrap and reflow on resize, and a
scrollback buffer.
- Bracketed paste (DECSET 2004) and synchronized output (DECSET 2026) so
applications can paste safely and update atomically.

View file

@ -0,0 +1,25 @@
//! Terminfo capability values reported via XTGETTCAP (`DCS + q <name> ST`).
/// Look up a terminfo capability beer reports via XTGETTCAP, by its terminfo
/// name. `None` means the capability is unknown (the reply is then a negative
/// `DCS 0 + r`).
pub fn cap_value(name: &[u8]) -> Option<&'static str> {
match name {
b"TN" => Some("beer"),
b"Co" | b"colors" => Some("256"),
b"RGB" => Some("8/8/8"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_and_unknown_caps() {
assert_eq!(cap_value(b"TN"), Some("beer"));
assert_eq!(cap_value(b"colors"), Some("256"));
assert_eq!(cap_value(b"nope"), None);
}
}

View file

@ -0,0 +1,61 @@
//! G0/G1 character-set designation and the DEC special graphics (line-drawing)
//! translation.
/// A designated character set (`ESC ( c` / `ESC ) c`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Charset {
Ascii,
DecSpecial,
}
/// Map a designation byte to a [`Charset`]. `0` selects DEC special graphics;
/// everything else falls back to ASCII.
pub fn charset(byte: u8) -> Charset {
match byte {
b'0' => Charset::DecSpecial,
_ => Charset::Ascii,
}
}
/// Translate a byte under the DEC special graphics set (VT100 line drawing).
pub fn dec_special(c: char) -> char {
match c {
'`' => '◆',
'a' => '▒',
'f' => '°',
'g' => '±',
'j' => '┘',
'k' => '┐',
'l' => '┌',
'm' => '└',
'n' => '┼',
'o' => '⎺',
'p' => '⎻',
'q' => '─',
'r' => '⎼',
's' => '⎽',
't' => '├',
'u' => '┤',
'v' => '┴',
'w' => '┬',
'x' => '│',
'y' => '≤',
'z' => '≥',
'~' => '·',
_ => c,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_drawing_translates() {
assert_eq!(charset(b'0'), Charset::DecSpecial);
assert_eq!(charset(b'B'), Charset::Ascii);
assert_eq!(dec_special('q'), '─');
assert_eq!(dec_special('x'), '│');
assert_eq!(dec_special('A'), 'A'); // unmapped passes through
}
}

View file

@ -0,0 +1,154 @@
//! Byte codecs the escape-sequence layer leans on: base64 (OSC 52 clipboard),
//! hex (XTGETTCAP capability names), and percent-decoding of `file://` URIs
//! (OSC 7 working-directory reports).
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 encode (used for OSC 52 query replies).
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(B64[(n >> 18 & 63) as usize] as char);
out.push(B64[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 {
B64[(n >> 6 & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
B64[(n & 63) as usize] as char
} else {
'='
});
}
out
}
/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad
/// character.
pub fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
match c {
b'A'..=b'Z' => Some(u32::from(c - b'A')),
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
};
let filtered: Vec<u8> = data
.iter()
.copied()
.filter(|&c| c != b'=' && !c.is_ascii_whitespace())
.collect();
let mut out = Vec::with_capacity(filtered.len() / 4 * 3);
for chunk in filtered.chunks(4) {
if chunk.len() == 1 {
return None; // a lone sextet cannot form a byte
}
let mut n = 0u32;
for &c in chunk {
n = (n << 6) | val(c)?;
}
n <<= 6 * (4 - chunk.len() as u32);
out.push((n >> 16) as u8);
if chunk.len() >= 3 {
out.push((n >> 8) as u8);
}
if chunk.len() >= 4 {
out.push(n as u8);
}
}
Some(out)
}
/// Decode an even-length lowercase/uppercase hex string into bytes (XTGETTCAP
/// names arrive hex-encoded).
pub fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
if s.is_empty() || !s.len().is_multiple_of(2) {
return None;
}
let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
s.chunks_exact(2)
.map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))
.collect()
}
/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.
pub fn percent_decode(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while i < s.len() {
if s[i] == b'%' && i + 2 < s.len() {
let hi = (s[i + 1] as char).to_digit(16);
let lo = (s[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(s[i]);
i += 1;
}
out
}
/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
/// `None` if it is not a usable absolute path.
pub fn file_uri_path(uri: &[u8]) -> Option<String> {
let rest = uri.strip_prefix(b"file://").unwrap_or(uri);
// Skip the authority (host) up to the first '/', which begins the path.
let slash = rest.iter().position(|&b| b == b'/')?;
let path_bytes = percent_decode(&rest[slash..]);
let path = String::from_utf8(path_bytes).ok()?;
path.starts_with('/').then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_round_trips() {
for s in [
"",
"f",
"fo",
"foo",
"foob",
"fooba",
"foobar",
"hi there\n",
] {
let enc = base64_encode(s.as_bytes());
assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));
}
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
}
#[test]
fn decode_hex_rejects_odd_and_nonhex() {
assert_eq!(decode_hex(b"544e").as_deref(), Some(&b"TN"[..]));
assert_eq!(decode_hex(b"54e"), None);
assert_eq!(decode_hex(b"zz"), None);
}
#[test]
fn file_uri_decodes_percent_and_drops_host() {
assert_eq!(
file_uri_path(b"file://hermes/home/user/my%20dir").as_deref(),
Some("/home/user/my dir")
);
assert_eq!(file_uri_path(b"file://host").as_deref(), None);
}
}

View file

@ -0,0 +1,578 @@
//! Keyboard encoding: translate decoded key events into the byte sequences a
//! terminal application expects, in both the legacy xterm/VT form and the kitty
//! keyboard progressive-enhancement form.
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};
/// Encode a key press into bytes for the PTY in the legacy xterm/VT form, or
/// `None` if it produces no input. `app_cursor` selects the SS3 cursor-key form
/// (DECCKM).
pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
let seq = match event.keysym {
Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),
Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),
Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),
Keysym::Tab => b"\t".to_vec(),
Keysym::Escape => b"\x1b".to_vec(),
Keysym::Up => csi_letter(b'A', mods, app_cursor),
Keysym::Down => csi_letter(b'B', mods, app_cursor),
Keysym::Right => csi_letter(b'C', mods, app_cursor),
Keysym::Left => csi_letter(b'D', mods, app_cursor),
Keysym::Home => csi_letter(b'H', mods, app_cursor),
Keysym::End => csi_letter(b'F', mods, app_cursor),
Keysym::Insert => csi_tilde(2, mods),
Keysym::Delete => csi_tilde(3, mods),
Keysym::Page_Up => csi_tilde(5, mods),
Keysym::Page_Down => csi_tilde(6, mods),
// F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.
Keysym::F1 => fkey(b'P', mods),
Keysym::F2 => fkey(b'Q', mods),
Keysym::F3 => fkey(b'R', mods),
Keysym::F4 => fkey(b'S', mods),
Keysym::F5 => csi_tilde(15, mods),
Keysym::F6 => csi_tilde(17, mods),
Keysym::F7 => csi_tilde(18, mods),
Keysym::F8 => csi_tilde(19, mods),
Keysym::F9 => csi_tilde(20, mods),
Keysym::F10 => csi_tilde(21, mods),
Keysym::F11 => csi_tilde(23, mods),
Keysym::F12 => csi_tilde(24, mods),
// Everything else: the xkb-composed text (which already folds in Ctrl),
// with Alt sending an ESC prefix (meta).
_ => {
let text = event.utf8.as_ref()?;
if text.is_empty() {
return None;
}
prefix_alt(text.as_bytes().to_vec(), mods)
}
};
Some(seq)
}
/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum KeyKind {
Press = 1,
Repeat = 2,
Release = 3,
}
/// How a functional (non-text) key is encoded in the kitty protocol.
enum Func {
/// `CSI number ... u`.
Number(u32),
/// `CSI number ... ~`.
Tilde(u32),
/// `CSI 1 ... LETTER`.
Letter(u8),
}
/// Map a keysym to its kitty functional encoding, or `None` for a text key
/// (whose code is its Unicode codepoint).
fn functional(keysym: Keysym) -> Option<Func> {
Some(match keysym {
Keysym::Escape => Func::Number(27),
Keysym::Return | Keysym::KP_Enter => Func::Number(13),
Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),
Keysym::BackSpace => Func::Number(127),
Keysym::Insert => Func::Tilde(2),
Keysym::Delete => Func::Tilde(3),
Keysym::Page_Up => Func::Tilde(5),
Keysym::Page_Down => Func::Tilde(6),
Keysym::Up => Func::Letter(b'A'),
Keysym::Down => Func::Letter(b'B'),
Keysym::Right => Func::Letter(b'C'),
Keysym::Left => Func::Letter(b'D'),
Keysym::Home => Func::Letter(b'H'),
Keysym::End => Func::Letter(b'F'),
Keysym::F1 => Func::Letter(b'P'),
Keysym::F2 => Func::Letter(b'Q'),
Keysym::F3 => Func::Tilde(13),
Keysym::F4 => Func::Letter(b'S'),
Keysym::F5 => Func::Tilde(15),
Keysym::F6 => Func::Tilde(17),
Keysym::F7 => Func::Tilde(18),
Keysym::F8 => Func::Tilde(19),
Keysym::F9 => Func::Tilde(20),
Keysym::F10 => Func::Tilde(21),
Keysym::F11 => Func::Tilde(23),
Keysym::F12 => Func::Tilde(24),
_ => return None,
})
}
/// The kitty key code for a lone modifier key, reported only in report-all mode.
fn modifier_key(keysym: Keysym) -> Option<u32> {
Some(match keysym {
Keysym::Caps_Lock => 57358,
Keysym::Num_Lock => 57360,
Keysym::Shift_L => 57441,
Keysym::Control_L => 57442,
Keysym::Alt_L => 57443,
Keysym::Super_L => 57444,
Keysym::Shift_R => 57447,
Keysym::Control_R => 57448,
Keysym::Alt_R => 57449,
Keysym::Super_R => 57450,
_ => return None,
})
}
/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8); the wire
/// parameter is `1 + this`.
fn kitty_mod_bits(mods: Modifiers) -> u32 {
u32::from(mods.shift)
| (u32::from(mods.alt) << 1)
| (u32::from(mods.ctrl) << 2)
| (u32::from(mods.logo) << 3)
}
/// The un-shifted Unicode codepoint of a text key (always the lowercase form,
/// per the protocol). `None` if the key produces no character.
fn base_codepoint(keysym: Keysym) -> Option<u32> {
let c = keysym.key_char()?;
Some(u32::from(if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else {
c
}))
}
/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier
/// section is emitted when modifiers/event/text require it; the event sub-field
/// only when the event is not a plain press.
fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {
let mut s = String::from("\x1b[");
s.push_str(&field);
let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();
if needs_mods {
s.push(';');
s.push_str(&mod_param.to_string());
if event != KeyKind::Press {
s.push(':');
s.push_str(&(event as u8).to_string());
}
}
if let Some(text) = text {
s.push(';');
let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();
s.push_str(&codes.join(":"));
}
s.push(char::from(term));
s.into_bytes()
}
/// Encode a key event under the kitty keyboard protocol with the given active
/// `flags`. Returns `None` when the event produces nothing (e.g. a text key
/// release without report-all mode). `app_cursor` only affects unmodified
/// cursor keys, matching legacy behaviour.
pub fn kitty_encode(
event: &KeyEvent,
mods: Modifiers,
flags: u8,
kind: KeyKind,
app_cursor: bool,
) -> Option<Vec<u8>> {
let report_all = flags & 0b1000 != 0;
let report_events = flags & 0b10 != 0;
let report_alt = flags & 0b100 != 0;
let report_text = flags & 0b1_0000 != 0;
let bits = kitty_mod_bits(mods);
let mod_param = bits + 1;
// Events other than a press are only reported when asked for.
let events_ok = report_events || report_all;
let kind = if events_ok { kind } else { KeyKind::Press };
// Lone modifier keys: only in report-all mode.
if let Some(code) = modifier_key(event.keysym) {
if !report_all {
return None;
}
return Some(csi(code.to_string(), mod_param, kind, None, b'u'));
}
match functional(event.keysym) {
Some(func) => {
// Enter/Tab/Backspace keep legacy bytes when unmodified and not in
// report-all mode, so a shell stays usable.
let legacy_special = matches!(
event.keysym,
Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace
);
if legacy_special && !report_all && bits == 0 {
if kind != KeyKind::Press {
return None;
}
return Some(match event.keysym {
Keysym::BackSpace => b"\x7f".to_vec(),
Keysym::Tab => b"\t".to_vec(),
_ => b"\r".to_vec(),
});
}
if kind != KeyKind::Press && !events_ok {
return None;
}
Some(match func {
Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),
Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),
Func::Letter(l) => {
if mod_param == 1 && kind == KeyKind::Press {
// Unmodified: legacy CSI/SS3 form, honouring app-cursor.
vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]
} else {
csi("1".to_string(), mod_param, kind, None, l)
}
}
})
}
None => {
let cp = base_codepoint(event.keysym)?;
if report_all {
if cp == 0 {
return None;
}
let text = if report_text {
event
.utf8
.as_deref()
.filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))
} else {
None
};
let field = alt_field(cp, event.keysym, mods, report_alt);
return Some(csi(field, mod_param, kind, text, b'u'));
}
// Without report-all, text keys only report presses.
if kind != KeyKind::Press {
return None;
}
// Plain or shift-only keys send their text; ctrl/alt/super → CSI u.
if bits & !1 == 0 {
let text = event.utf8.as_ref()?;
if text.is_empty() {
return None;
}
return Some(text.as_bytes().to_vec());
}
let field = alt_field(cp, event.keysym, mods, report_alt);
Some(csi(field, mod_param, KeyKind::Press, None, b'u'))
}
}
}
/// The key-code field, with the shifted alternate appended (`code:shifted`) when
/// alternate-key reporting is on and shift is held.
fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {
if report_alt
&& mods.shift
&& let Some(shifted) = keysym.key_char().map(u32::from)
&& shifted != cp
{
return format!("{cp}:{shifted}");
}
cp.to_string()
}
fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {
if mods.alt {
let mut out = Vec::with_capacity(bytes.len() + 1);
out.push(0x1b);
out.extend_from_slice(&bytes);
out
} else {
bytes
}
}
/// xterm modifier parameter: 1 + a bitfield of the held modifiers.
fn modifier_param(mods: Modifiers) -> u8 {
1 + u8::from(mods.shift)
+ (u8::from(mods.alt) << 1)
+ (u8::from(mods.ctrl) << 2)
+ (u8::from(mods.logo) << 3)
}
/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and
/// `ESC [ 1 ; m X` when modifiers are held.
fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {
let m = modifier_param(mods);
if m == 1 {
vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]
} else {
let mut v = format!("\x1b[1;{m}").into_bytes();
v.push(final_byte);
v
}
}
/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.
fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {
let m = modifier_param(mods);
if m == 1 {
format!("\x1b[{n}~").into_bytes()
} else {
format!("\x1b[{n};{m}~").into_bytes()
}
}
fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {
let m = modifier_param(mods);
if m == 1 {
vec![0x1b, b'O', final_byte]
} else {
let mut v = format!("\x1b[1;{m}").into_bytes();
v.push(final_byte);
v
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {
KeyEvent {
time: 0,
raw_code: 0,
keysym,
utf8: utf8.map(str::to_owned),
}
}
const NONE: Modifiers = Modifiers {
ctrl: false,
alt: false,
shift: false,
caps_lock: false,
logo: false,
num_lock: false,
};
fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {
Modifiers {
ctrl,
shift,
alt,
..NONE
}
}
#[test]
fn plain_text_passes_through() {
assert_eq!(
encode(&key(Keysym::a, Some("a")), NONE, false),
Some(b"a".to_vec())
);
}
#[test]
fn alt_prefixes_escape() {
let m = Modifiers { alt: true, ..NONE };
assert_eq!(
encode(&key(Keysym::a, Some("a")), m, false),
Some(b"\x1ba".to_vec())
);
}
#[test]
fn arrows_respect_application_mode() {
assert_eq!(
encode(&key(Keysym::Up, None), NONE, false),
Some(b"\x1b[A".to_vec())
);
assert_eq!(
encode(&key(Keysym::Up, None), NONE, true),
Some(b"\x1bOA".to_vec())
);
}
#[test]
fn modified_arrow_uses_csi_param() {
let m = Modifiers { ctrl: true, ..NONE };
assert_eq!(
encode(&key(Keysym::Right, None), m, false),
Some(b"\x1b[1;5C".to_vec())
);
}
#[test]
fn kitty_plain_text_key_stays_text() {
// Disambiguate only: an unmodified letter is still sent as text.
assert_eq!(
kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),
Some(b"a".to_vec())
);
}
#[test]
fn kitty_ctrl_letter_is_csi_u() {
// ctrl+a → CSI 97 ; 5 u (5 = 1 + ctrl).
assert_eq!(
kitty_encode(
&key(Keysym::a, None),
mods(true, false, false),
0b1,
KeyKind::Press,
false
),
Some(b"\x1b[97;5u".to_vec())
);
}
#[test]
fn kitty_escape_disambiguated() {
assert_eq!(
kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),
Some(b"\x1b[27u".to_vec())
);
}
#[test]
fn kitty_associated_text_and_alternate() {
// shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.
let flags = 0b1_1001; // disambiguate | report-all | associated-text
assert_eq!(
kitty_encode(
&key(Keysym::A, Some("A")),
mods(false, true, false),
flags,
KeyKind::Press,
false
),
Some(b"\x1b[97;2;65u".to_vec())
);
// ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.
assert_eq!(
kitty_encode(
&key(Keysym::A, None),
mods(true, true, false),
0b101,
KeyKind::Press,
false
),
Some(b"\x1b[97:65;6u".to_vec())
);
}
#[test]
fn kitty_release_only_with_report_all() {
let release = |flags| {
kitty_encode(
&key(Keysym::a, None),
mods(true, false, false),
flags,
KeyKind::Release,
false,
)
};
// Event reporting alone does not report text-key release.
assert_eq!(release(0b11), None);
// Report-all does: CSI 97 ; 5 : 3 u.
assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));
}
#[test]
fn kitty_lock_keys_do_not_leak() {
let numlock = Modifiers {
num_lock: true,
..NONE
};
// Num lock on must not turn unmodified Enter into a CSI u sequence.
assert_eq!(
kitty_encode(
&key(Keysym::Return, None),
numlock,
0b1,
KeyKind::Press,
false
),
Some(b"\r".to_vec())
);
// Nor pollute the modifier parameter of a real chord.
let ctrl_numlock = Modifiers {
ctrl: true,
num_lock: true,
..NONE
};
assert_eq!(
kitty_encode(
&key(Keysym::a, None),
ctrl_numlock,
0b1,
KeyKind::Press,
false
),
Some(b"\x1b[97;5u".to_vec())
);
}
#[test]
fn kitty_functional_keys() {
// Unmodified arrow keeps the legacy form (app-cursor honoured).
assert_eq!(
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),
Some(b"\x1b[A".to_vec())
);
assert_eq!(
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),
Some(b"\x1bOA".to_vec())
);
// Modified arrow → CSI 1 ; mods LETTER.
assert_eq!(
kitty_encode(
&key(Keysym::Up, None),
mods(true, false, false),
0b1,
KeyKind::Press,
false
),
Some(b"\x1b[1;5A".to_vec())
);
// F5 keeps its tilde form.
assert_eq!(
kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),
Some(b"\x1b[15~".to_vec())
);
// Unmodified Enter is still a carriage return; report-all makes it CSI u.
assert_eq!(
kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),
Some(b"\r".to_vec())
);
assert_eq!(
kitty_encode(
&key(Keysym::Return, None),
NONE,
0b1001,
KeyKind::Press,
false
),
Some(b"\x1b[13u".to_vec())
);
}
#[test]
fn special_keys() {
assert_eq!(
encode(&key(Keysym::Return, None), NONE, false),
Some(b"\r".to_vec())
);
assert_eq!(
encode(&key(Keysym::BackSpace, None), NONE, false),
Some(b"\x7f".to_vec())
);
assert_eq!(
encode(&key(Keysym::Delete, None), NONE, false),
Some(b"\x1b[3~".to_vec())
);
assert_eq!(
encode(&key(Keysym::F5, None), NONE, false),
Some(b"\x1b[15~".to_vec())
);
}
}

View file

@ -0,0 +1,36 @@
//! Terminal protocol building blocks for [beer](https://github.com/NotAShelf/beer).
//!
//! This crate gathers the self-contained pieces of beer's terminal-protocol
//! support: the byte codecs an escape stream needs, the terminfo capability
//! table, character-set translation, SGR colour/underline parsing, and the
//! keyboard/mouse wire encoders. It deliberately holds no terminal state (no
//! grid, no parser loop): every item here is a pure function or a plain data
//! type, so each protocol detail can be read and tested on its own. beer wires
//! these into its `vte`-driven dispatcher and its [`Grid`] model.
//!
//! The modules map onto the protocols a terminal user cares about:
//!
//! - [`codec`] - base64 (OSC 52 clipboard), hex (XTGETTCAP), and `file://` URI
//! percent-decoding (OSC 7).
//! - [`caps`] - the terminfo capabilities answered over XTGETTCAP.
//! - [`charset`] - G0/G1 designation and DEC special-graphics line drawing.
//! - [`sgr`] - the multi-parameter SGR colour and underline forms.
//! - [`key`] - legacy xterm/VT and kitty keyboard-protocol key encoding.
//! - [`mouse`] - X10/UTF-8/SGR mouse-report encoding.
//! - [`style`] - the enums an SGR/DECSET stream selects (colour, underline,
//! cursor shape, mouse protocol/encoding, shell-integration prompt marks).
//!
//! See `README.md` for the full inventory of escape sequences, OSC commands,
//! and POSIX behaviour beer implements.
pub mod caps;
pub mod charset;
pub mod codec;
pub mod key;
pub mod mouse;
pub mod sgr;
pub mod style;
pub use style::{
Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline, prompt_kind,
};

View file

@ -0,0 +1,106 @@
//! Mouse-event encoding: frame a button/motion report in the wire form the
//! application selected (legacy byte form, UTF-8, or SGR).
use std::io::Write as _;
use smithay_client_toolkit::seat::keyboard::Modifiers;
use crate::style::MouseEncoding;
/// Encode a mouse event for the application. `button` is the base code (0/1/2
/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based
/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.
pub fn encode_mouse(
encoding: MouseEncoding,
button: u8,
col: usize,
row: usize,
pressed: bool,
motion: bool,
mods: Modifiers,
) -> Vec<u8> {
let mod_bits =
(u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);
let motion_bit = if motion { 32 } else { 0 };
let mut out = Vec::new();
match encoding {
MouseEncoding::Sgr => {
let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);
let final_byte = if pressed { 'M' } else { 'm' };
let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);
}
enc => {
// Legacy form collapses every button release to code 3.
let base = if !pressed && button <= 2 { 3 } else { button };
let cb = 32 + base + motion_bit + mod_bits;
out.extend_from_slice(b"\x1b[M");
push_coord(&mut out, u32::from(cb), false);
let utf8 = enc == MouseEncoding::Utf8;
push_coord(&mut out, col as u32 + 33, utf8);
push_coord(&mut out, row as u32 + 33, utf8);
}
}
out
}
/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when
/// the extended (1005) mode is active, and clamping otherwise.
fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {
if utf8 {
let v = value.min(0x7ff);
if v >= 0x80 {
out.push(0xc0 | (v >> 6) as u8);
out.push(0x80 | (v & 0x3f) as u8);
} else {
out.push(v as u8);
}
} else {
out.push(value.min(255) as u8);
}
}
#[cfg(test)]
mod tests {
use super::*;
const NONE: Modifiers = Modifiers {
ctrl: false,
alt: false,
shift: false,
caps_lock: false,
logo: false,
num_lock: false,
};
#[test]
fn mouse_sgr_press_and_release() {
// Left press at column 3, row 5 (0-based) -> SGR with 1-based coords.
assert_eq!(
encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE),
b"\x1b[<0;4;6M".to_vec()
);
assert_eq!(
encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE),
b"\x1b[<0;4;6m".to_vec()
);
}
#[test]
fn mouse_legacy_release_collapses_to_three() {
// Legacy release: button bits become 3; values offset by 32.
assert_eq!(
encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE),
vec![0x1b, b'[', b'M', 32 + 3, 33, 33]
);
}
#[test]
fn mouse_motion_and_modifiers_set_bits() {
let mods = Modifiers { ctrl: true, ..NONE };
// Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl).
assert_eq!(
encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods),
b"\x1b[<48;1;1M".to_vec()
);
}
}

View file

@ -0,0 +1,97 @@
//! SGR (Select Graphic Rendition) parsing: the colour and underline forms that
//! need more than a single parameter. Plain attribute toggles (bold, reverse,
//! ...) stay in the dispatcher; what lives here is the multi-parameter parsing
//! that benefits from being testable in isolation.
use crate::style::{Color, Underline};
/// Map an SGR 4 parameter (`4` or `4:x`) to an underline style.
pub fn underline_from(param: &[u16]) -> Underline {
match param.get(1).copied().unwrap_or(1) {
0 => Underline::None,
2 => Underline::Double,
3 => Underline::Curly,
4 => Underline::Dotted,
5 => Underline::Dashed,
_ => Underline::Single,
}
}
/// Parse an SGR 38/48/58 extended colour, given the full parameter list and the
/// index of the introducer. Returns the colour and how many top-level
/// parameters it consumed (1 for the colon-subparameter form, more for the
/// legacy semicolon form).
pub fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {
let head = items[i];
if head.len() >= 2 {
return (color_from_subparams(&head[1..]), 1);
}
match items.get(i + 1).and_then(|s| s.first().copied()) {
Some(5) => {
let idx = items
.get(i + 2)
.and_then(|s| s.first().copied())
.unwrap_or(0);
(Some(Color::Indexed(idx as u8)), 3)
}
Some(2) => {
let get = |k: usize| {
items
.get(i + k)
.and_then(|s| s.first().copied())
.unwrap_or(0)
};
(
Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),
5,
)
}
_ => (None, 1),
}
}
/// Parse the colon-subparameter colour form: `5:idx` (indexed) or `2[:cs]:r:g:b`
/// (direct RGB, with an optional colour-space id that is ignored).
fn color_from_subparams(sub: &[u16]) -> Option<Color> {
match sub.first().copied() {
Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),
Some(2) => {
// Either `2:r:g:b` or `2:colorspace:r:g:b`.
let rgb = if sub.len() >= 5 {
&sub[2..5]
} else {
&sub[1..]
};
match rgb {
[r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),
_ => None,
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn underline_styles() {
assert_eq!(underline_from(&[4]), Underline::Single);
assert_eq!(underline_from(&[4, 3]), Underline::Curly);
assert_eq!(underline_from(&[4, 0]), Underline::None);
}
#[test]
fn extended_colour_semicolon_and_colon() {
// `38;2;10;20;30` direct RGB.
let items: Vec<&[u16]> = vec![&[38], &[2], &[10], &[20], &[30]];
assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(10, 20, 30)), 5));
// `38:2:40:50:60` colon subparameters.
let items: Vec<&[u16]> = vec![&[38, 2, 40, 50, 60]];
assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(40, 50, 60)), 1));
// `38;5;1` indexed.
let items: Vec<&[u16]> = vec![&[38], &[5], &[1]];
assert_eq!(ext_color(&items, 0), (Some(Color::Indexed(1)), 3));
}
}

View file

@ -0,0 +1,83 @@
//! The protocol vocabulary: the enums an SGR/DECSET stream selects, shared by
//! the parser, the grid model, and the renderer.
/// A cell colour: terminal default, a palette index, or direct RGB (SGR 30-49,
/// 90-107, and the `38`/`48`/`58` extended forms).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Color {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
/// Underline style (SGR 4 / 4:x / 21).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Underline {
#[default]
None,
Single,
Double,
Curly,
Dotted,
Dashed,
}
/// Cursor shape (DECSCUSR, `CSI Ps SP q`).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum CursorShape {
#[default]
Block,
Underline,
Beam,
}
/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MouseProtocol {
/// No reporting; the pointer drives local selection/scroll.
#[default]
Off,
/// X10 (9): button presses only.
X10,
/// Normal (1000): button press and release.
Normal,
/// Button-event (1002): press, release, and motion while a button is held.
Button,
/// Any-event (1003): press, release, and all pointer motion.
Any,
}
/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MouseEncoding {
/// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (<= 223).
#[default]
X10,
/// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).
Utf8,
/// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).
Sgr,
}
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
/// the start of typed command input, the start of command output, or the line
/// where the command finished.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PromptKind {
PromptStart,
CmdStart,
OutputStart,
CmdEnd,
}
/// Map an OSC 133 mark letter (`A`/`B`/`C`/`D`) to a [`PromptKind`].
pub fn prompt_kind(b: u8) -> Option<PromptKind> {
match b {
b'A' => Some(PromptKind::PromptStart),
b'B' => Some(PromptKind::CmdStart),
b'C' => Some(PromptKind::OutputStart),
b'D' => Some(PromptKind::CmdEnd),
_ => None,
}
}

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

457
crates/beer/src/bindings.rs Normal file
View file

@ -0,0 +1,457 @@
//! Configurable key bindings: chord strings (e.g. `Ctrl+Shift+C`) mapped to
//! editor actions, plus text bindings that send a literal string.
use std::collections::HashMap;
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};
/// An action a key binding can trigger.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Action {
Copy,
Paste,
PastePrimary,
ScrollPageUp,
ScrollPageDown,
ScrollTop,
ScrollBottom,
SearchStart,
FontIncrease,
FontDecrease,
FontReset,
Fullscreen,
NewWindow,
JumpPromptUp,
JumpPromptDown,
PipeCommandOutput,
UrlMode,
UnicodeInput,
}
impl Action {
fn parse(name: &str) -> Option<Self> {
Some(match name {
"copy" => Self::Copy,
"paste" => Self::Paste,
"paste-primary" => Self::PastePrimary,
"scrollback-up" => Self::ScrollPageUp,
"scrollback-down" => Self::ScrollPageDown,
"scrollback-top" => Self::ScrollTop,
"scrollback-bottom" => Self::ScrollBottom,
"search" => Self::SearchStart,
"font-increase" => Self::FontIncrease,
"font-decrease" => Self::FontDecrease,
"font-reset" => Self::FontReset,
"fullscreen" => Self::Fullscreen,
"new-window" => Self::NewWindow,
"jump-prompt-up" => Self::JumpPromptUp,
"jump-prompt-down" => Self::JumpPromptDown,
"pipe-command-output" => Self::PipeCommandOutput,
"url-mode" => Self::UrlMode,
"unicode-input" => Self::UnicodeInput,
_ => return None,
})
}
}
/// A parsed chord: a key plus the modifiers that must be held exactly.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Chord {
key: Keysym,
ctrl: bool,
shift: bool,
alt: bool,
logo: bool,
}
impl Chord {
/// Parse a chord like `Ctrl+Shift+C`. Modifier order is irrelevant; the
/// final token is the key. Returns `None` if no key is recognized.
fn parse(spec: &str) -> Option<Self> {
let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);
let mut key = None;
for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {
match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" => ctrl = true,
"shift" => shift = true,
"alt" | "mod1" | "meta" => alt = true,
"super" | "logo" | "cmd" | "mod4" => logo = true,
_ => key = keysym_from_token(token),
}
}
Some(Self {
key: key?,
ctrl,
shift,
alt,
logo,
})
}
/// Whether `event`/`mods` match this chord. Letters compare case-insensitively
/// (Shift is matched via the modifier, not the keysym case).
fn matches(&self, event: &KeyEvent, mods: Modifiers) -> bool {
if mods.ctrl != self.ctrl
|| mods.shift != self.shift
|| mods.alt != self.alt
|| mods.logo != self.logo
{
return false;
}
event.keysym == self.key
|| matches!(
(self.key.key_char(), event.keysym.key_char()),
(Some(a), Some(b)) if a.eq_ignore_ascii_case(&b)
)
}
}
/// A mouse button that a `[mouse-bindings]` chord can name.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MouseButton {
Left,
Middle,
Right,
}
/// A parsed mouse chord: a button plus the modifiers that must be held exactly.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct MouseChord {
button: MouseButton,
ctrl: bool,
shift: bool,
alt: bool,
logo: bool,
}
impl MouseChord {
/// Parse a chord like `Shift+Middle`; the final non-modifier token is the
/// button. Returns `None` if no button is recognized.
fn parse(spec: &str) -> Option<Self> {
let (mut ctrl, mut shift, mut alt, mut logo) = (false, false, false, false);
let mut button = None;
for token in spec.split('+').map(str::trim).filter(|t| !t.is_empty()) {
match token.to_ascii_lowercase().as_str() {
"ctrl" | "control" => ctrl = true,
"shift" => shift = true,
"alt" | "mod1" | "meta" => alt = true,
"super" | "logo" | "cmd" | "mod4" => logo = true,
"left" | "button1" => button = Some(MouseButton::Left),
"middle" | "button2" => button = Some(MouseButton::Middle),
"right" | "button3" => button = Some(MouseButton::Right),
other => {
tracing::warn!("unknown mouse button {other:?} in binding");
return None;
}
}
}
Some(Self {
button: button?,
ctrl,
shift,
alt,
logo,
})
}
fn matches(&self, button: MouseButton, mods: Modifiers) -> bool {
self.button == button
&& mods.ctrl == self.ctrl
&& mods.shift == self.shift
&& mods.alt == self.alt
&& mods.logo == self.logo
}
}
/// The resolved binding tables for a session.
#[derive(Clone, Debug, Default)]
pub struct Bindings {
keys: Vec<(Chord, Action)>,
text: Vec<(Chord, Vec<u8>)>,
mouse: Vec<(MouseChord, Action)>,
}
impl Bindings {
/// Build the tables from config, starting from the built-in defaults and
/// applying user overrides (a value of `none` unbinds a default chord).
pub fn from_config(
key_bindings: &HashMap<String, String>,
text_bindings: &HashMap<String, String>,
mouse_bindings: &HashMap<String, String>,
) -> Self {
let mut keys: Vec<(Chord, Action)> = Vec::new();
let mut add = |chord: &str, action: Action| {
if let Some(c) = Chord::parse(chord) {
keys.retain(|(existing, _)| *existing != c);
keys.push((c, action));
}
};
for (chord, action) in DEFAULT_BINDINGS {
if let Some(a) = Action::parse(action) {
add(chord, a);
}
}
for (chord, action) in key_bindings {
if let Some(c) = Chord::parse(chord) {
keys.retain(|(existing, _)| *existing != c);
if let Some(a) = Action::parse(action) {
keys.push((c, a));
} else if action != "none" {
tracing::warn!("unknown key-binding action {action:?}");
}
}
}
let mut text = Vec::new();
for (chord, value) in text_bindings {
if let Some(c) = Chord::parse(chord) {
text.push((c, unescape(value)));
}
}
let mut mouse: Vec<(MouseChord, Action)> = Vec::new();
let mut add_mouse = |chord: &str, action: Option<Action>| {
if let Some(c) = MouseChord::parse(chord) {
mouse.retain(|(existing, _)| *existing != c);
if let Some(a) = action {
mouse.push((c, a));
}
}
};
for (chord, action) in DEFAULT_MOUSE_BINDINGS {
add_mouse(chord, Action::parse(action));
}
for (chord, action) in mouse_bindings {
if action == "none" {
add_mouse(chord, None);
} else if let Some(a) = Action::parse(action) {
add_mouse(chord, Some(a));
} else {
tracing::warn!("unknown mouse-binding action {action:?}");
}
}
Self { keys, text, mouse }
}
/// The action bound to this key event, if any.
pub fn action(&self, event: &KeyEvent, mods: Modifiers) -> Option<Action> {
self.keys
.iter()
.find(|(c, _)| c.matches(event, mods))
.map(|(_, a)| *a)
}
/// The literal bytes bound to this key event, if any.
pub fn text(&self, event: &KeyEvent, mods: Modifiers) -> Option<&[u8]> {
self.text
.iter()
.find(|(c, _)| c.matches(event, mods))
.map(|(_, t)| t.as_slice())
}
/// The action bound to this mouse button + modifiers, if any.
pub fn mouse_action(&self, button: MouseButton, mods: Modifiers) -> Option<Action> {
self.mouse
.iter()
.find(|(c, _)| c.matches(button, mods))
.map(|(_, a)| *a)
}
}
/// Built-in default key bindings (chord, action name).
const DEFAULT_BINDINGS: &[(&str, &str)] = &[
("Ctrl+Shift+C", "copy"),
("Ctrl+Shift+V", "paste"),
("Ctrl+Shift+F", "search"),
("Shift+Page_Up", "scrollback-up"),
("Shift+Page_Down", "scrollback-down"),
("Ctrl+Shift+Home", "scrollback-top"),
("Ctrl+Shift+End", "scrollback-bottom"),
("Ctrl+plus", "font-increase"),
("Ctrl+equal", "font-increase"),
("Ctrl+minus", "font-decrease"),
("Ctrl+0", "font-reset"),
("F11", "fullscreen"),
("Ctrl+Shift+N", "new-window"),
("Ctrl+Shift+Up", "jump-prompt-up"),
("Ctrl+Shift+Down", "jump-prompt-down"),
("Ctrl+Shift+O", "url-mode"),
("Ctrl+Shift+U", "unicode-input"),
];
/// Built-in default mouse bindings (chord, action name). Left-button select and
/// drag are built-in gestures, not bindings, so only the other buttons appear.
const DEFAULT_MOUSE_BINDINGS: &[(&str, &str)] = &[("Middle", "paste-primary")];
/// Map a key token to a keysym: a single character, or a named special key.
fn keysym_from_token(token: &str) -> Option<Keysym> {
let mut chars = token.chars();
if let (Some(c), None) = (chars.next(), chars.clone().next()) {
// A single character; bind case-insensitively via the lowercase keysym.
return Some(Keysym::from_char(c.to_ascii_lowercase()));
}
Some(match token {
"Return" | "Enter" => Keysym::Return,
"Tab" => Keysym::Tab,
"Escape" | "Esc" => Keysym::Escape,
"Space" => Keysym::space,
"BackSpace" => Keysym::BackSpace,
"Delete" => Keysym::Delete,
"Insert" => Keysym::Insert,
"Home" => Keysym::Home,
"End" => Keysym::End,
"Page_Up" | "PageUp" | "Prior" => Keysym::Page_Up,
"Page_Down" | "PageDown" | "Next" => Keysym::Page_Down,
"Up" => Keysym::Up,
"Down" => Keysym::Down,
"Left" => Keysym::Left,
"Right" => Keysym::Right,
"plus" => Keysym::plus,
"minus" => Keysym::minus,
"equal" => Keysym::equal,
"F1" => Keysym::F1,
"F2" => Keysym::F2,
"F3" => Keysym::F3,
"F4" => Keysym::F4,
"F5" => Keysym::F5,
"F6" => Keysym::F6,
"F7" => Keysym::F7,
"F8" => Keysym::F8,
"F9" => Keysym::F9,
"F10" => Keysym::F10,
"F11" => Keysym::F11,
"F12" => Keysym::F12,
other => {
tracing::warn!("unknown key name {other:?} in binding");
return None;
}
})
}
/// Decode the escapes a text binding may contain: `\e \n \r \t \\` and `\xNN`.
fn unescape(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next() {
Some('e') => out.push(0x1b),
Some('n') => out.push(b'\n'),
Some('r') => out.push(b'\r'),
Some('t') => out.push(b'\t'),
Some('\\') => out.push(b'\\'),
Some('x') => {
let hex: String = (0..2).filter_map(|_| chars.next()).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
out.push(byte);
}
}
Some(other) => {
out.push(b'\\');
let mut buf = [0u8; 4];
out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
None => out.push(b'\\'),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const NONE: Modifiers = Modifiers {
ctrl: false,
alt: false,
shift: false,
caps_lock: false,
logo: false,
num_lock: false,
};
fn key(keysym: Keysym) -> KeyEvent {
KeyEvent {
time: 0,
raw_code: 0,
keysym,
utf8: None,
}
}
#[test]
fn default_copy_binding_matches_case_insensitively() {
let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());
let mods = Modifiers {
ctrl: true,
shift: true,
..NONE
};
// Shift yields the uppercase keysym; the binding still matches.
assert_eq!(b.action(&key(Keysym::C), mods), Some(Action::Copy));
// Without the right modifiers, no match.
assert_eq!(b.action(&key(Keysym::C), NONE), None);
}
#[test]
fn config_overrides_and_unbinds() {
let mut kb = HashMap::new();
kb.insert("Ctrl+Shift+C".to_string(), "none".to_string());
kb.insert("Ctrl+y".to_string(), "copy".to_string());
let b = Bindings::from_config(&kb, &HashMap::new(), &HashMap::new());
let cs = Modifiers {
ctrl: true,
shift: true,
..NONE
};
assert_eq!(b.action(&key(Keysym::C), cs), None); // unbound
let c = Modifiers { ctrl: true, ..NONE };
assert_eq!(b.action(&key(Keysym::y), c), Some(Action::Copy));
}
#[test]
fn text_binding_unescapes() {
let mut tb = HashMap::new();
tb.insert("Ctrl+Shift+Return".to_string(), "\\x1b\\r".to_string());
let b = Bindings::from_config(&HashMap::new(), &tb, &HashMap::new());
let mods = Modifiers {
ctrl: true,
shift: true,
..NONE
};
assert_eq!(b.text(&key(Keysym::Return), mods), Some(&[0x1b, b'\r'][..]));
}
#[test]
fn mouse_bindings_default_override_and_unbind() {
// Default: middle button pastes the primary selection.
let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &HashMap::new());
assert_eq!(
b.mouse_action(MouseButton::Middle, NONE),
Some(Action::PastePrimary)
);
assert_eq!(b.mouse_action(MouseButton::Right, NONE), None);
// Config rebinds right to paste and unbinds the middle default.
let mut mb = HashMap::new();
mb.insert("Right".to_string(), "paste".to_string());
mb.insert("Middle".to_string(), "none".to_string());
let b = Bindings::from_config(&HashMap::new(), &HashMap::new(), &mb);
assert_eq!(
b.mouse_action(MouseButton::Right, NONE),
Some(Action::Paste)
);
assert_eq!(b.mouse_action(MouseButton::Middle, NONE), None);
// Modifiers must match exactly.
let shift = Modifiers {
shift: true,
..NONE
};
assert_eq!(b.mouse_action(MouseButton::Right, shift), None);
}
}

297
crates/beer/src/config.rs Normal file
View file

@ -0,0 +1,297 @@
//! User configuration: a TOML file at `$XDG_CONFIG_HOME/beer/beer.toml`
//! deserialized into a typed [`Config`]. A missing file uses defaults; a
//! malformed one warns and falls back to defaults rather than failing to start.
use std::path::{Path, PathBuf};
use serde::Deserialize;
/// Top-level configuration. Unknown keys are ignored so a config written for a
/// newer beer still loads.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Config {
pub main: Main,
pub colors: Colors,
pub cursor: Cursor,
pub scrollback: Scrollback,
pub bell: Bell,
pub mouse: Mouse,
pub shell_integration: ShellIntegration,
pub url: Url,
pub notify: Notify,
/// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;
/// a value of `"none"` unbinds.
pub key_bindings: std::collections::HashMap<String, String>,
/// Chord → literal text to send (supports `\e \n \r \t \\ \xNN`).
pub text_bindings: std::collections::HashMap<String, String>,
/// Mouse chord (e.g. `"Middle"`, `"Shift+Right"`) → action. Merged over the
/// defaults; `"none"` unbinds. Left-button select/drag stays built in.
pub mouse_bindings: std::collections::HashMap<String, String>,
}
/// `[cursor]`: the default cursor presentation (DECSCUSR may override at runtime).
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Cursor {
/// `block`, `beam`/`bar`, or `underline`.
pub style: Option<String>,
/// Whether the cursor blinks by default.
pub blink: bool,
}
/// `[bell]`: what happens on `BEL` (0x07).
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Bell {
/// Briefly flash the screen.
pub visual: bool,
/// Command (argv) to run on the bell, e.g. `["paplay", "/usr/share/.../bell.oga"]`.
pub command: Vec<String>,
/// Request the compositor's attention (xdg-activation) when the bell rings
/// while the window is unfocused.
pub urgent: bool,
}
/// `[notify]`: how desktop notifications (OSC 9/777/99) are delivered.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Notify {
/// Notifier argv; the title and body are appended as the last two arguments.
pub command: Vec<String>,
}
impl Default for Notify {
fn default() -> Self {
Self {
command: vec!["notify-send".to_string()],
}
}
}
/// `[mouse]`: pointer and wheel behaviour.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Mouse {
/// Multiplier applied to the lines scrolled per wheel notch.
pub scroll_multiplier: f64,
/// On the alternate screen, translate the wheel into arrow-key presses so
/// full-screen apps that did not request mouse reporting (less, man, …)
/// still scroll.
pub alternate_scroll: bool,
}
impl Default for Mouse {
fn default() -> Self {
Self {
scroll_multiplier: 1.0,
alternate_scroll: true,
}
}
}
/// `[shell-integration]`: behaviour driven by OSC 7 / OSC 133 marks.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct ShellIntegration {
/// Command the `pipe-command-output` binding feeds the last command's
/// output to on stdin (argv form, e.g. `["less"]`). Empty disables it.
pub pipe_command: Vec<String>,
}
/// `[url]`: opening OSC 8 hyperlinks and detected URLs.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Url {
/// Launcher argv the URL is appended to (e.g. `["xdg-open"]`).
pub launch: Vec<String>,
}
impl Default for Url {
fn default() -> Self {
Self {
launch: vec!["xdg-open".to_string()],
}
}
}
/// `[colors]`: foreground/background, the 16 base palette entries, and accents.
/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries
/// keep the built-in default.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Colors {
pub foreground: Option<String>,
pub background: Option<String>,
pub cursor: Option<String>,
pub selection_foreground: Option<String>,
pub selection_background: Option<String>,
/// The eight regular palette entries (indices 0-7).
pub regular: Option<Vec<String>>,
/// The eight bright palette entries (indices 8-15).
pub bright: Option<Vec<String>>,
pub match_background: Option<String>,
pub match_current_background: Option<String>,
/// Background opacity, 0.0 (transparent) - 1.0 (opaque).
pub alpha: Option<f32>,
/// Render bold text with the bright palette variant.
pub bold_as_bright: Option<bool>,
}
/// `[main]`: fonts, window geometry, padding, and the terminal name.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Main {
/// Primary font family, resolved via fontconfig.
pub font: String,
/// Font size in pixels.
pub font_size: u32,
/// `TERM` value exported to the child shell.
pub term: String,
/// Initial size in character cells.
pub initial_cols: u16,
pub initial_rows: u16,
/// Inner padding in pixels between the window edge and the cell grid.
pub pad_x: u32,
pub pad_y: u32,
/// Characters that break a word for double-click selection. Empty/unset
/// keeps the built-in default.
pub word_delimiters: Option<String>,
/// Hold an idle inhibitor while the window is focused, so the compositor
/// does not blank the screen or start the screensaver. Default off.
pub idle_inhibit: bool,
}
impl Default for Main {
fn default() -> Self {
Self {
font: "monospace".to_string(),
font_size: 16,
term: "beer".to_string(),
initial_cols: 80,
initial_rows: 24,
pad_x: 2,
pad_y: 2,
word_delimiters: None,
idle_inhibit: false,
}
}
}
/// `[scrollback]`: history retention.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct Scrollback {
/// Lines of history retained for the main screen.
pub lines: usize,
}
impl Default for Scrollback {
fn default() -> Self {
Self { lines: 10_000 }
}
}
impl Config {
/// Load configuration from `explicit` if given, else the default path.
/// Any read/parse failure logs a warning and returns defaults.
pub fn load(explicit: Option<&Path>) -> Self {
let Some(path) = explicit.map(Path::to_path_buf).or_else(default_path) else {
return Self::default();
};
if !path.exists() {
return Self::default();
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) => {
tracing::warn!("read config {}: {err}; using defaults", path.display());
return Self::default();
}
};
// Deserialize through serde_ignored so a typo'd key (`font-sze`) is
// reported instead of silently dropped, while still loading: unknown
// keys stay tolerated, keeping forward-compatibility with newer configs.
let de = match toml::Deserializer::parse(&text) {
Ok(de) => de,
Err(err) => {
tracing::warn!("config {}: {err}; using defaults", path.display());
return Self::default();
}
};
match serde_ignored::deserialize(de, |key| {
tracing::warn!("config {}: unknown key `{key}` ignored", path.display());
}) {
Ok(config) => {
tracing::info!("loaded config from {}", path.display());
config
}
Err(err) => {
tracing::warn!("config {}: {err}; using defaults", path.display());
Self::default()
}
}
}
}
/// `$XDG_CONFIG_HOME/beer/beer.toml`, or `~/.config/beer/beer.toml`.
fn default_path() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(dir).join("beer/beer.toml"));
}
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".config/beer/beer.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
let c = Config::default();
assert_eq!(c.main.font, "monospace");
assert_eq!(c.main.font_size, 16);
assert_eq!(c.scrollback.lines, 10_000);
}
#[test]
fn parses_partial_config_and_ignores_unknown() {
let toml = r##"
[main]
font = "JetBrains Mono"
font-size = 14
unknown-key = "tolerated"
[colors]
background = "#000000"
"##;
let c: Config = toml::from_str(toml).unwrap();
assert_eq!(c.main.font, "JetBrains Mono");
assert_eq!(c.main.font_size, 14);
// Unset keys keep defaults; unknown tables/keys are ignored.
assert_eq!(c.main.term, "beer");
assert_eq!(c.scrollback.lines, 10_000);
}
#[test]
fn unknown_keys_are_reported_but_still_load() {
let toml = r##"
[main]
font-sze = 14
font = "JetBrains Mono"
[made-up]
x = 1
"##;
let mut unknown = Vec::new();
let de = toml::Deserializer::parse(toml).unwrap();
let c: Config =
serde_ignored::deserialize(de, |key| unknown.push(key.to_string())).unwrap();
// The typo and the bogus table are both surfaced...
assert!(unknown.iter().any(|k| k == "main.font-sze"), "{unknown:?}");
assert!(unknown.iter().any(|k| k == "made-up"), "{unknown:?}");
// ...yet the valid key still loaded.
assert_eq!(c.main.font, "JetBrains Mono");
}
}

664
crates/beer/src/font.rs Normal file
View file

@ -0,0 +1,664 @@
//! Font discovery, rasterization, and glyph caching.
//!
//! fontconfig resolves family names and performs per-codepoint fallback;
//! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour
//! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own
//! advance is never consulted - only the [`CellMetrics`] taken from the primary
//! face. C interop goes through the `freetype`/`fontconfig` safe wrappers; the
//! sole `unsafe` is reading a face's fixed-strike array (see `nearest_strike`).
use std::collections::HashMap;
use std::fmt;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use fontconfig::{CharSet, Fontconfig, Pattern};
use freetype::bitmap::PixelMode;
use freetype::face::{LoadFlag, StyleFlag};
use freetype::{Face, Library, Matrix, Vector};
use harfbuzz_rs_now as harfbuzz;
use lru::LruCache;
use thiserror::Error;
/// Upper bound on cached glyphs; the working set of a terminal is far smaller,
/// but this caps memory under adversarial all-of-Unicode output.
const GLYPH_CACHE_CAP: usize = 4096;
/// Upper bound on cached shaped clusters (base char + combining marks).
const SHAPE_CACHE_CAP: usize = 1024;
#[derive(Debug, Error)]
pub enum FontError {
#[error("FreeType: {0}")]
FreeType(#[from] freetype::Error),
#[error("could not initialize fontconfig")]
FontconfigInit,
#[error("fontconfig: {0}")]
Fontconfig(#[from] fontconfig::FontconfigError),
#[error("no font matched family {0:?}")]
NoFamily(String),
#[error("font {0:?} reports no size metrics")]
NoMetrics(String),
}
/// Bold/italic selection, used both to pick a face and to key the glyph cache.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
pub struct Style {
pub bold: bool,
pub italic: bool,
}
impl Style {
/// Dense index in `0..4` for array storage.
fn index(self) -> usize {
usize::from(self.bold) | (usize::from(self.italic) << 1)
}
fn fontconfig_style(self) -> &'static str {
match (self.bold, self.italic) {
(false, false) => "Regular",
(true, false) => "Bold",
(false, true) => "Italic",
(true, true) => "Bold Italic",
}
}
}
/// Fixed cell geometry in pixels, derived from the primary face.
#[derive(Clone, Copy, Debug)]
pub struct CellMetrics {
pub width: u32,
pub height: u32,
/// Baseline offset from the top of the cell.
pub ascent: u32,
}
/// A rasterized glyph: its bitmap plus the offsets to place it on the baseline.
#[derive(Clone, Debug)]
pub struct Glyph {
/// Horizontal offset from the pen position to the bitmap's left edge.
pub left: i32,
/// Vertical offset from the baseline up to the bitmap's top edge.
pub top: i32,
pub width: u32,
pub height: u32,
pub data: GlyphData,
}
/// Glyph pixel data. A `Mask` is tinted with the cell's foreground colour; a
/// `Color` bitmap (emoji) is composited directly.
#[derive(Clone, Debug)]
pub enum GlyphData {
/// One coverage byte per pixel.
Mask(Vec<u8>),
/// Pre-multiplied BGRA, four bytes per pixel.
Color(Vec<u8>),
}
/// One shaped glyph in a cluster: a glyph index into a specific face plus the
/// pixel offset, relative to the cell origin and baseline, that HarfBuzz placed
/// it at. `x` grows rightward, `y` upward (away from the baseline).
#[derive(Clone, Copy, Debug)]
pub struct Placed {
pub gid: u32,
pub x: i32,
pub y: i32,
}
/// The result of shaping a base char plus its combining marks: the face the
/// cluster was shaped against and the positioned glyphs to draw, in order.
#[derive(Clone, Debug)]
pub struct ShapedCluster {
pub face_idx: usize,
pub glyphs: Vec<Placed>,
}
/// A loaded face plus where it came from, so HarfBuzz can be handed the same
/// font bytes that FreeType rasterizes from.
struct FaceEntry {
face: Face,
path: PathBuf,
index: u32,
/// HarfBuzz font for this face, built on first shape against it.
hb: Option<harfbuzz::Owned<harfbuzz::Font<'static>>>,
}
/// The font set for one terminal: a primary family with lazily-loaded
/// bold/italic variants and per-codepoint fallback faces, plus glyph caches.
pub struct Fonts {
library: Library,
fontconfig: Fontconfig,
family: String,
size_px: u32,
metrics: CellMetrics,
/// All loaded faces; indices into this vector are stable.
faces: Vec<FaceEntry>,
/// Index of each style variant, by [`Style::index`]; filled on demand.
styled: [Option<usize>; 4],
/// Fallback faces resolved by coverage, deduplicated by file path.
fallbacks: HashMap<PathBuf, usize>,
/// Glyphs keyed by `char` (the common, unshaped path).
cache: LruCache<(char, usize), Glyph>,
/// Glyphs keyed by `(glyph index, face, style)` (the shaped path).
gcache: LruCache<(u32, usize, usize), Glyph>,
/// Shaped clusters keyed by `(cluster string, style)`.
shape_cache: LruCache<(Box<str>, usize), Option<ShapedCluster>>,
}
impl fmt::Debug for Fonts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Fonts")
.field("family", &self.family)
.field("size_px", &self.size_px)
.field("metrics", &self.metrics)
.field("faces", &self.faces.len())
.field("cached", &self.cache.len())
.finish()
}
}
impl Fonts {
/// Resolve `family` at `size_px` and compute the cell metrics.
pub fn new(family: &str, size_px: u32) -> Result<Self, FontError> {
let library = Library::init()?;
let fontconfig = Fontconfig::new().ok_or(FontError::FontconfigInit)?;
let regular = resolve_face(&library, &fontconfig, family, Style::default(), size_px)?;
let metrics = cell_metrics(&regular.face, family)?;
let cap = |n| NonZeroUsize::new(n).expect("cache cap is nonzero");
Ok(Self {
library,
fontconfig,
family: family.to_owned(),
size_px,
metrics,
faces: vec![regular],
styled: [Some(0), None, None, None],
fallbacks: HashMap::new(),
cache: LruCache::new(cap(GLYPH_CACHE_CAP)),
gcache: LruCache::new(cap(GLYPH_CACHE_CAP)),
shape_cache: LruCache::new(cap(SHAPE_CACHE_CAP)),
})
}
pub fn metrics(&self) -> CellMetrics {
self.metrics
}
/// Return the rasterized glyph for `c` in `style`, rasterizing and caching
/// it on first use.
pub fn glyph(&mut self, c: char, style: Style) -> Result<&Glyph, FontError> {
let key = (c, style.index());
if self.cache.get(&key).is_none() {
let idx = self.face_for(c, style)?;
let face = &self.faces[idx].face;
// Synthesize bold/italic only when the resolved face lacks the real
// variant (most monospace families ship both).
let (synth_bold, synth_italic) = synth_flags(face, style);
let glyph = rasterize(face, c, synth_bold, synth_italic)?;
self.cache.put(key, glyph);
}
Ok(self.cache.get(&key).expect("glyph was just inserted"))
}
/// Return the rasterized glyph for glyph index `gid` in `face_idx`,
/// rasterizing and caching on first use. Used by the shaped path, where
/// HarfBuzz has already chosen the face and glyph.
pub fn glyph_indexed(
&mut self,
face_idx: usize,
gid: u32,
style: Style,
) -> Result<&Glyph, FontError> {
let key = (gid, face_idx, style.index());
if self.gcache.get(&key).is_none() {
let face = &self.faces[face_idx].face;
let (synth_bold, synth_italic) = synth_flags(face, style);
let glyph = rasterize_index(face, gid, synth_bold, synth_italic)?;
self.gcache.put(key, glyph);
}
Ok(self.gcache.get(&key).expect("glyph was just inserted"))
}
/// Shape `base` plus its combining `marks` into positioned glyphs using
/// HarfBuzz, so marks land where the font's GPOS table wants them rather
/// than stacked at the origin. Returns `None` when shaping is unavailable or
/// the cluster has glyphs the face does not cover (`.notdef`), so the caller
/// can fall back to drawing the marks stacked. Results are cached.
pub fn shape_cluster(
&mut self,
base: char,
marks: &str,
style: Style,
) -> Option<ShapedCluster> {
let mut cluster = String::with_capacity(base.len_utf8() + marks.len());
cluster.push(base);
cluster.push_str(marks);
let key = (cluster.clone().into_boxed_str(), style.index());
if let Some(cached) = self.shape_cache.get(&key) {
return cached.clone();
}
let shaped = self.shape_uncached(base, &cluster, style);
self.shape_cache.put(key, shaped.clone());
shaped
}
fn shape_uncached(&mut self, base: char, cluster: &str, style: Style) -> Option<ShapedCluster> {
let face_idx = self.face_for(base, style).ok()?;
let font = self.hb_font(face_idx)?;
let buffer = harfbuzz::UnicodeBuffer::new().add_str(cluster);
let output = harfbuzz::shape(font, buffer, &[]);
let infos = output.get_glyph_infos();
let positions = output.get_glyph_positions();
let mut glyphs = Vec::with_capacity(infos.len());
let mut pen = 0i32;
for (info, pos) in infos.iter().zip(positions) {
// A .notdef means this face does not cover part of the cluster; bail
// so the caller stacks the marks via per-char fallback instead.
if info.codepoint == 0 {
return None;
}
glyphs.push(Placed {
gid: info.codepoint,
// HarfBuzz positions are 26.6 fixed point at our pixel scale.
x: (pen + pos.x_offset) >> 6,
y: pos.y_offset >> 6,
});
pen += pos.x_advance;
}
Some(ShapedCluster { face_idx, glyphs })
}
/// Lazily build the HarfBuzz font for `face_idx` from the same file bytes
/// FreeType loaded. The bytes are leaked to `'static`: a face lives for the
/// process, and only the handful actually used to shape clusters allocate.
fn hb_font(&mut self, face_idx: usize) -> Option<&harfbuzz::Owned<harfbuzz::Font<'static>>> {
if self.faces[face_idx].hb.is_none() {
let entry = &self.faces[face_idx];
let bytes = std::fs::read(&entry.path).ok()?;
let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice());
let face = harfbuzz::Face::from_bytes(leaked, entry.index);
let mut font = harfbuzz::Font::new(face);
let scale = self.size_px as i32 * 64;
font.set_scale(scale, scale);
font.set_ppem(self.size_px, self.size_px);
self.faces[face_idx].hb = Some(font);
}
self.faces[face_idx].hb.as_ref()
}
/// Pick the face that should render `c`: the requested style if it has the
/// glyph, then regular, then known fallbacks, then a fresh fontconfig
/// coverage match. Falls back to the styled face (rendering `.notdef`).
fn face_for(&mut self, c: char, style: Style) -> Result<usize, FontError> {
let styled = self.styled_face(style)?;
if face_has_glyph(&self.faces[styled].face, c) {
return Ok(styled);
}
if let Some(regular) = self.styled[0]
&& regular != styled
&& face_has_glyph(&self.faces[regular].face, c)
{
return Ok(regular);
}
for &idx in self.fallbacks.values() {
if face_has_glyph(&self.faces[idx].face, c) {
return Ok(idx);
}
}
Ok(self.load_fallback(c)?.unwrap_or(styled))
}
/// Lazily load the face for `style`, caching regular's index if the variant
/// cannot be resolved so the lookup is not retried per glyph.
fn styled_face(&mut self, style: Style) -> Result<usize, FontError> {
if let Some(idx) = self.styled[style.index()] {
return Ok(idx);
}
let regular = self.styled[0].expect("regular face is loaded at construction");
let idx = match resolve_face(
&self.library,
&self.fontconfig,
&self.family,
style,
self.size_px,
) {
Ok(entry) => {
self.faces.push(entry);
self.faces.len() - 1
}
Err(_) => regular,
};
self.styled[style.index()] = Some(idx);
Ok(idx)
}
/// Ask fontconfig for a font covering `c`, load it, and remember it.
fn load_fallback(&mut self, c: char) -> Result<Option<usize>, FontError> {
let mut charset = CharSet::new(&self.fontconfig)?;
charset.add_char(c)?;
let mut pattern = Pattern::new(&self.fontconfig)?;
pattern.add_string(c"family", c"monospace")?;
pattern.add_charset(charset)?;
let matched = pattern.font_match()?;
let path = PathBuf::from(matched.filename()?);
if let Some(&idx) = self.fallbacks.get(&path) {
return Ok(Some(idx));
}
let index = matched.face_index().unwrap_or(0);
let face = self.library.new_face(&path, index as isize)?;
if size_face(&face, self.size_px).is_err() {
return Ok(None);
}
self.faces.push(FaceEntry {
face,
path: path.clone(),
index: index as u32,
hb: None,
});
let idx = self.faces.len() - 1;
self.fallbacks.insert(path, idx);
Ok(Some(idx))
}
}
fn face_has_glyph(face: &Face, c: char) -> bool {
face.get_char_index(c as usize).is_some_and(|g| g != 0)
}
/// Whether bold/italic must be synthesized: only when the requested style is
/// set but the resolved face lacks the real variant.
fn synth_flags(face: &Face, style: Style) -> (bool, bool) {
let flags = face.style_flags();
let synth_bold = style.bold && !flags.contains(StyleFlag::BOLD);
let synth_italic = style.italic && !flags.contains(StyleFlag::ITALIC);
(synth_bold, synth_italic)
}
fn resolve_face(
library: &Library,
fontconfig: &Fontconfig,
family: &str,
style: Style,
size_px: u32,
) -> Result<FaceEntry, FontError> {
let font = fontconfig
.find(family, Some(style.fontconfig_style()))
.map_err(|_| FontError::NoFamily(family.to_owned()))?;
let index = font.index.unwrap_or(0);
let face = library.new_face(&font.path, index as isize)?;
size_face(&face, size_px)?;
Ok(FaceEntry {
face,
path: font.path,
index: index as u32,
hb: None,
})
}
/// Set a face to `size_px`. Scalable faces size directly; bitmap-strike faces
/// (e.g. colour-emoji fonts) cannot, so select the nearest available strike and
/// let the renderer scale its glyphs into the cell.
fn size_face(face: &Face, size_px: u32) -> Result<(), FontError> {
match face.set_pixel_sizes(0, size_px) {
Ok(()) => Ok(()),
Err(_) if face.has_fixed_sizes() => {
face.select_size(nearest_strike(face, size_px))?;
Ok(())
}
Err(err) => Err(err.into()),
}
}
/// Index of the fixed strike whose pixel height is closest to `target`.
fn nearest_strike(face: &Face, target: u32) -> i32 {
let rec = face.raw();
let target = i32::try_from(target).unwrap_or(i32::MAX);
let mut best = 0;
let mut best_delta = i32::MAX;
for i in 0..rec.num_fixed_sizes {
// SAFETY: `available_sizes` points to `num_fixed_sizes` valid
// `FT_Bitmap_Size` entries for the face's lifetime; `i` is in range.
let height = i32::from(unsafe { (*rec.available_sizes.offset(i as isize)).height });
let delta = (height - target).abs();
if delta < best_delta {
best = i;
best_delta = delta;
}
}
best
}
fn cell_metrics(face: &Face, family: &str) -> Result<CellMetrics, FontError> {
let metrics = face
.size_metrics()
.ok_or_else(|| FontError::NoMetrics(family.to_owned()))?;
// FreeType reports these in 26.6 fixed point.
let ascent = (metrics.ascender >> 6).max(1) as u32;
let height = (metrics.height >> 6).max(1) as u32;
// For a monospace face every advance is equal; measure one ASCII glyph.
face.load_char('M' as usize, LoadFlag::DEFAULT)?;
let width = (face.glyph().advance().x >> 6).max(1) as u32;
Ok(CellMetrics {
width,
height,
ascent,
})
}
fn rasterize(
face: &Face,
c: char,
synth_bold: bool,
synth_italic: bool,
) -> Result<Glyph, FontError> {
rasterize_with(face, synth_bold, synth_italic, |face| {
face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR)
})
}
/// Rasterize by glyph index rather than character (the shaped path).
fn rasterize_index(
face: &Face,
gid: u32,
synth_bold: bool,
synth_italic: bool,
) -> Result<Glyph, FontError> {
rasterize_with(face, synth_bold, synth_italic, |face| {
face.load_glyph(gid, LoadFlag::RENDER | LoadFlag::COLOR)
})
}
fn rasterize_with(
face: &Face,
synth_bold: bool,
synth_italic: bool,
load: impl FnOnce(&Face) -> Result<(), freetype::Error>,
) -> Result<Glyph, FontError> {
// A shear transform fakes italics on a face that has no real oblique. It is
// applied to the outline at load time, so reset it immediately after.
if synth_italic {
face.set_transform(&mut shear_matrix(), &mut Vector { x: 0, y: 0 });
}
let result = load(face);
if synth_italic {
face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 });
}
result?;
let slot = face.glyph();
let bitmap = slot.bitmap();
let width = bitmap.width().max(0) as usize;
let height = bitmap.rows().max(0) as usize;
let pitch = bitmap.pitch();
let src = bitmap.buffer();
let mut data = match bitmap.pixel_mode()? {
PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)),
PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)),
PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)),
_ => GlyphData::Mask(vec![0; width * height]),
};
// Fake bold by widening coverage one pixel to the right (colour glyphs are
// left alone - there is no such thing as a bold emoji).
if synth_bold && let GlyphData::Mask(mask) = &mut data {
embolden(mask, width, height);
}
Ok(Glyph {
left: slot.bitmap_left(),
top: slot.bitmap_top(),
width: width as u32,
height: height as u32,
data,
})
}
fn shear_matrix() -> Matrix {
// ~0.2 horizontal shear in 16.16 fixed point.
Matrix {
xx: 0x1_0000,
xy: 0x3333,
yx: 0,
yy: 0x1_0000,
}
}
fn identity_matrix() -> Matrix {
Matrix {
xx: 0x1_0000,
xy: 0,
yx: 0,
yy: 0x1_0000,
}
}
/// Widen each row's coverage by one pixel (synthetic bold).
fn embolden(mask: &mut [u8], width: usize, height: usize) {
for y in 0..height {
let row = &mut mask[y * width..y * width + width];
for x in (1..width).rev() {
row[x] = row[x].max(row[x - 1]);
}
}
}
/// Copy `height` rows of `row_bytes` each out of FreeType's padded buffer,
/// honouring pitch sign (positive = top-down).
fn pack_rows(src: &[u8], row_bytes: usize, pitch: i32, height: usize) -> Vec<u8> {
let stride = pitch.unsigned_abs() as usize;
let take = row_bytes.min(stride);
let mut out = vec![0u8; row_bytes * height];
for row in 0..height {
let src_row = if pitch >= 0 { row } else { height - 1 - row };
let start = src_row * stride;
if start + take <= src.len() {
out[row * row_bytes..row * row_bytes + take].copy_from_slice(&src[start..start + take]);
}
}
out
}
/// Expand a 1-bit-per-pixel mono bitmap to one coverage byte per pixel.
fn expand_mono(src: &[u8], width: usize, pitch: i32, height: usize) -> Vec<u8> {
let stride = pitch.unsigned_abs() as usize;
let mut out = vec![0u8; width * height];
for row in 0..height {
let src_row = if pitch >= 0 { row } else { height - 1 - row };
let base = src_row * stride;
for x in 0..width {
let byte = base + x / 8;
if byte < src.len() && src[byte] & (0x80 >> (x % 8)) != 0 {
out[row * width + x] = 0xff;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fonts() -> Fonts {
Fonts::new("monospace", 16).expect("system has a monospace font")
}
#[test]
fn cell_metrics_are_sane() {
let m = fonts().metrics();
assert!(m.width >= 1 && m.height >= 1);
assert!(m.ascent >= 1 && m.ascent <= m.height);
}
#[test]
fn ascii_glyph_has_ink() {
let mut f = fonts();
let glyph = f.glyph('M', Style::default()).expect("rasterize M");
assert!(glyph.width > 0 && glyph.height > 0);
match &glyph.data {
GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "M should have coverage"),
GlyphData::Color(_) => {}
}
}
#[test]
fn space_is_blank_but_ok() {
let mut f = fonts();
// Space resolves without error; it simply carries no ink.
f.glyph(' ', Style::default()).expect("rasterize space");
}
#[test]
fn embolden_widens_coverage() {
// 3x2 mask, one lit pixel per row at x=1.
let mut mask = vec![0, 255, 0, 0, 200, 0];
embolden(&mut mask, 3, 2);
// Each lit pixel bleeds one column to the right; the left edge is unchanged.
assert_eq!(mask, vec![0, 255, 255, 0, 200, 200]);
}
#[test]
fn shapes_a_simple_cluster() {
let mut f = fonts();
// Shaping a bare base char yields exactly its one glyph, and that glyph
// index rasterizes to ink through the shaped path.
let shaped = f
.shape_cluster('a', "", Style::default())
.expect("monospace shapes 'a'");
assert_eq!(shaped.glyphs.len(), 1);
let g = f
.glyph_indexed(shaped.face_idx, shaped.glyphs[0].gid, Style::default())
.expect("rasterize shaped glyph");
match &g.data {
GlyphData::Mask(px) => assert!(px.iter().any(|&p| p > 0), "'a' should have ink"),
GlyphData::Color(_) => {}
}
}
#[test]
fn shapes_combining_cluster_without_notdef() {
// 'e' + combining acute: a covering face shapes it (>=1 glyph, never a
// .notdef, which `shape_cluster` rejects by returning None); a face
// missing the mark returns None so the renderer stacks instead. Either
// outcome is fine - the point is no panic and no notdef leaking through.
let mut f = fonts();
if let Some(shaped) = f.shape_cluster('e', "\u{0301}", Style::default()) {
assert!(!shaped.glyphs.is_empty());
assert!(shaped.glyphs.iter().all(|g| g.gid != 0));
}
}
#[test]
fn glyphs_are_cached() {
let mut f = fonts();
f.glyph('a', Style::default()).unwrap();
let before = f.cache.len();
f.glyph('a', Style::default()).unwrap();
assert_eq!(f.cache.len(), before, "second lookup must hit the cache");
}
}

View file

@ -0,0 +1,136 @@
use std::num::NonZeroU16;
use super::*;
/// A URL detected in the visible viewport, with the `(row, col)` of its first
/// character in viewport coordinates.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UrlHit {
pub url: String,
pub row: usize,
pub col: usize,
}
/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the
/// delimiters that conventionally bound a URL in flowing text).
fn is_url_char(c: char) -> bool {
!c.is_whitespace()
&& !c.is_control()
&& !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')
}
/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.
/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs
/// to the first non-URL character, with trailing sentence punctuation trimmed.
fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut i = 0;
while i + 2 < chars.len() {
if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {
// Backtrack over the scheme.
let mut start = i;
while start > 0 {
let c = chars[start - 1];
if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
start -= 1;
} else {
break;
}
}
if start < i && chars[start].is_ascii_alphabetic() {
let mut end = i + 3;
while end < chars.len() && is_url_char(chars[end]) {
end += 1;
}
// Trim trailing punctuation that is usually sentence-level.
while end > i + 3
&& matches!(
chars[end - 1],
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'
)
{
end -= 1;
}
if end > i + 3 {
out.push((start, end));
i = end;
continue;
}
}
}
i += 1;
}
out
}
impl Grid {
/// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An
/// empty/`None` URI ends the current link.
pub fn set_link(&mut self, uri: Option<&str>) {
self.pen.link = match uri {
Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),
_ => None,
};
}
/// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table
/// is capped so a pathological stream cannot grow it without bound).
fn intern_link(&mut self, uri: &str) -> NonZeroU16 {
if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {
return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");
}
// u16::MAX distinct links is far past any real document; reuse the last
// slot once saturated rather than overflow the id space.
if self.links.len() < usize::from(u16::MAX) - 1 {
self.links.push(uri.into());
} else {
*self.links.last_mut().expect("table is non-empty when full") = uri.into();
}
NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")
}
/// The URI for a hyperlink id, if it is still in the table.
pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {
self.links
.get(usize::from(id.get()) - 1)
.map(|s| s.as_ref())
}
/// The hyperlink id of the cell at an absolute `(row, col)`, if any.
pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {
self.abs_row(abs_row).get(col).and_then(|c| c.link)
}
/// Detect plain-text URLs across the visible viewport, returning each with
/// the viewport `(row, col)` of its first character. Soft-wrapped rows are
/// joined so a URL split across a wrap is found whole; hard line breaks end
/// a URL.
pub fn visible_urls(&self) -> Vec<UrlHit> {
let mut chars: Vec<char> = Vec::new();
let mut pos: Vec<(usize, usize)> = Vec::new();
for y in 0..self.rows {
let line = self.line_at_abs(self.view_to_abs(y));
for (x, cell) in line.cells.iter().enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) {
continue;
}
chars.push(cell.c);
pos.push((y, x));
}
if !line.wrapped {
chars.push('\n'); // a hard break terminates any URL
pos.push((y, usize::MAX));
}
}
find_urls(&chars)
.into_iter()
.map(|(s, e)| {
let (row, col) = pos[s];
UrlHit {
url: chars[s..e].iter().collect(),
row,
col,
}
})
.collect()
}
}

1451
crates/beer/src/grid/mod.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
use super::*;
/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Match {
row: usize,
col: usize,
len: usize,
}
/// Incremental scrollback search: the query, every hit, and the focused one.
#[derive(Clone, Debug, Default)]
pub(super) struct SearchState {
query: String,
matches: Vec<Match>,
current: usize,
}
impl Grid {
// --- scrollback search ---
/// Set the search query and recompute matches over scrollback + the live
/// screen, focusing the most recent hit and scrolling it into view. An
/// empty query keeps search mode active but clears the hit list.
pub fn set_search(&mut self, query: &str) {
let matches = if query.is_empty() {
Vec::new()
} else {
self.compute_matches(query)
};
let current = matches.len().saturating_sub(1);
self.search = Some(SearchState {
query: query.to_string(),
matches,
current,
});
self.jump_to_current();
}
/// Move the focused match `forward` (toward newer) or back (toward older),
/// wrapping, and scroll it into view.
pub fn search_step(&mut self, forward: bool) {
if let Some(s) = self.search.as_mut()
&& !s.matches.is_empty()
{
let n = s.matches.len();
s.current = if forward {
(s.current + 1) % n
} else {
(s.current + n - 1) % n
};
}
self.jump_to_current();
}
pub fn clear_search(&mut self) {
self.search = None;
}
/// The current query, while search mode is active.
pub fn search_query(&self) -> Option<&str> {
self.search.as_ref().map(|s| s.query.as_str())
}
/// `(focused match index 1-based, total matches)` for the search prompt.
pub fn search_count(&self) -> (usize, usize) {
self.search.as_ref().map_or((0, 0), |s| {
let total = s.matches.len();
(if total == 0 { 0 } else { s.current + 1 }, total)
})
}
/// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.
pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {
let Some(s) = self.search.as_ref() else {
return Vec::new();
};
s.matches
.iter()
.enumerate()
.filter(|(_, m)| m.row == row && m.len > 0)
.map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))
.collect()
}
fn compute_matches(&self, query: &str) -> Vec<Match> {
// Smart case: an uppercase letter in the query forces case sensitivity.
let sensitive = query.chars().any(|c| c.is_ascii_uppercase());
let fold = |c: char| {
if sensitive { c } else { c.to_ascii_lowercase() }
};
let needle: Vec<char> = query.chars().map(fold).collect();
if needle.is_empty() {
return Vec::new();
}
let total = self.scrollback.len() + self.rows;
let mut matches = Vec::new();
for row in 0..total {
let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();
let mut i = 0;
while i + needle.len() <= hay.len() {
if hay[i..i + needle.len()] == needle[..] {
matches.push(Match {
row,
col: i,
len: needle.len(),
});
i += needle.len();
} else {
i += 1;
}
}
}
matches
}
/// Scroll the viewport so the focused match is on screen, centering it only
/// when it would otherwise be off the visible range.
fn jump_to_current(&mut self) {
let Some(abs) = self
.search
.as_ref()
.and_then(|s| s.matches.get(s.current))
.map(|m| m.row)
else {
return;
};
let sb = self.scrollback.len();
let top = sb - self.view_offset;
let bottom = top + self.rows - 1;
if abs < top || abs > bottom {
let target = sb as isize + self.rows as isize / 2 - abs as isize;
self.view_offset = target.clamp(0, sb as isize) as usize;
}
}
/// Slide search hits up by `n` rows after scrollback eviction, dropping any
/// that scrolled off the top.
pub(super) fn shift_search(&mut self, n: usize) {
if let Some(s) = self.search.as_mut() {
s.matches.retain(|m| m.row >= n);
for m in &mut s.matches {
m.row -= n;
}
s.current = s.current.min(s.matches.len().saturating_sub(1));
}
}
}

View file

@ -0,0 +1,190 @@
use super::*;
impl Grid {
/// Slide an active selection up by `n` rows after scrollback eviction,
/// dropping it if either endpoint scrolled off the top.
pub(super) fn shift_selection(&mut self, n: usize) {
if let Some((a, b)) = self.selection {
if a.row < n || b.row < n {
self.selection = None;
} else {
self.selection = Some((
Point {
row: a.row - n,
..a
},
Point {
row: b.row - n,
..b
},
));
}
}
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
/// Begin a linear selection at an absolute point (drag anchor).
pub fn start_selection(&mut self, row: usize, col: usize) {
let p = Point { row, col };
self.selection = Some((p, p));
self.selection_block = false;
}
/// Begin a rectangular (block) selection at an absolute point.
pub fn start_block_selection(&mut self, row: usize, col: usize) {
let p = Point { row, col };
self.selection = Some((p, p));
self.selection_block = true;
}
/// Move the selection head (drag), keeping the anchor fixed.
pub fn extend_selection(&mut self, row: usize, col: usize) {
if let Some((_, head)) = self.selection.as_mut() {
*head = Point { row, col };
}
}
/// Select the word at an absolute point, breaking on whitespace and the
/// default delimiter set.
pub fn select_word(&mut self, row: usize, col: usize) {
let delims = &self.word_delimiters;
let line = self.abs_row(row);
if col >= line.len() || !is_word(line[col].c, delims) {
self.start_selection(row, col);
return;
}
let mut lo = col;
while lo > 0 && is_word(line[lo - 1].c, delims) {
lo -= 1;
}
let mut hi = col;
while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {
hi += 1;
}
self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));
self.selection_block = false;
}
/// Select the whole line at an absolute row.
pub fn select_line(&mut self, row: usize) {
let last = self.abs_row(row).len().saturating_sub(1);
self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));
self.selection_block = false;
}
/// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block
/// selection.
fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {
let (a, b) = self.selection?;
Some((
a.row.min(b.row),
a.row.max(b.row),
a.col.min(b.col),
a.col.max(b.col),
))
}
/// Normalized selection (start <= end in reading order), if any.
fn ordered_selection(&self) -> Option<(Point, Point)> {
self.selection.map(|(a, b)| {
if (a.row, a.col) <= (b.row, b.col) {
(a, b)
} else {
(b, a)
}
})
}
/// Whether the cell at an absolute `(row, col)` falls inside the selection.
pub fn is_selected(&self, row: usize, col: usize) -> bool {
if self.selection_block {
let Some((r0, r1, c0, c1)) = self.block_rect() else {
return false;
};
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
}
let Some((start, end)) = self.ordered_selection() else {
return false;
};
if row < start.row || row > end.row {
return false;
}
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row { end.col } else { usize::MAX };
col >= lo && col <= hi
}
/// The inclusive `(lo, hi)` column span selected on absolute row `row`, if
/// any part of that row is selected.
pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {
if self.selection_block {
let (r0, r1, c0, c1) = self.block_rect()?;
return (row >= r0 && row <= r1).then_some((c0, c1));
}
let (start, end) = self.ordered_selection()?;
if row < start.row || row > end.row {
return None;
}
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
end.col
} else {
self.abs_row(row).len().saturating_sub(1)
};
Some((lo, hi))
}
/// The selected text, with trailing blanks trimmed per line and rows joined
/// by newlines. `None` if there is no selection.
pub fn selection_text(&self) -> Option<String> {
if self.selection_block {
let (r0, r1, c0, c1) = self.block_rect()?;
let mut out = String::new();
for row in r0..=r1 {
out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());
if row != r1 {
out.push('\n');
}
}
return Some(out);
}
let (start, end) = self.ordered_selection()?;
let mut out = String::new();
for row in start.row..=end.row {
let line = self.abs_row(row);
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
(end.col + 1).min(line.len())
} else {
line.len()
};
out.push_str(self.row_slice_text(row, lo, hi).trim_end());
if row != end.row {
out.push('\n');
}
}
Some(out)
}
/// The characters of an absolute row in `[from, to)`, skipping wide
/// continuation cells.
pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
let mut out = String::new();
for cell in self
.abs_row(row)
.get(from..to.min(self.abs_row(row).len()))
.unwrap_or(&[])
.iter()
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
{
out.push(cell.c);
if let Some(marks) = &cell.combining {
out.push_str(marks);
}
}
out
}
}

65
crates/beer/src/main.rs Normal file
View file

@ -0,0 +1,65 @@
//! beer, a fast, software-rendered, Wayland-native terminal emulator.
mod bindings;
mod config;
mod font;
mod grid;
mod pty;
mod render;
mod theme;
mod vt;
mod wayland;
use std::path::PathBuf;
use std::process::ExitCode;
use pound::Parse;
use crate::config::Config;
/// A fast, software-rendered, Wayland-native terminal emulator.
#[derive(Parse)]
#[pound(name = "beer", version = "0.2.0")]
struct Cli {
/// Run as a daemon hosting multiple windows.
#[pound(long)]
server: bool,
/// Path to a config file (default: $XDG_CONFIG_HOME/beer/beer.toml).
#[pound(long)]
config: Option<PathBuf>,
}
fn main() -> ExitCode {
init_logging();
match run(Cli::parse()) {
Ok(code) => code,
Err(err) => {
tracing::error!("{err:#}");
eprintln!("beer: {err:#}");
ExitCode::FAILURE
}
}
}
fn init_logging() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_env("BEER_LOG")
.or_else(|_| EnvFilter::try_from_default_env())
.unwrap_or_else(|_| EnvFilter::new("warn"));
fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
fn run(cli: Cli) -> anyhow::Result<ExitCode> {
if cli.server {
anyhow::bail!("server mode is not implemented yet");
}
let config = Config::load(cli.config.as_deref());
tracing::info!("starting beer");
wayland::run(config, cli.config)
}

117
crates/beer/src/pty.rs Normal file
View file

@ -0,0 +1,117 @@
//! Pseudo-terminal: open a master/slave pair and run the user's shell on it.
use std::ffi::OsStr;
use std::io;
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Stdio};
use anyhow::Context;
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcsetwinsize};
/// A running child process attached to a PTY master.
#[derive(Debug)]
pub struct Pty {
master: OwnedFd,
child: Child,
}
impl Pty {
/// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on
/// the slave end with `TERM=term`.
pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)
.context("open pty master")?;
grantpt(&master).context("grantpt")?;
unlockpt(&master).context("unlockpt")?;
let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY)
.context("open pty slave")?;
set_winsize(&master, cols, rows)?;
let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());
let argv0 = login_argv0(&shell);
// Hand the slave to the child's stdio. try_clone gives O_CLOEXEC dups, so
// the parent's copies and the controlling-tty handle vanish at exec.
let ctty = slave.try_clone().context("dup slave")?;
let (stdin, stdout, stderr) = (
slave.try_clone().context("dup slave")?,
slave.try_clone().context("dup slave")?,
slave,
);
let mut cmd = Command::new(&shell);
cmd.arg0(&argv0)
.env("TERM", term)
.env_remove("COLUMNS")
.env_remove("LINES")
.env_remove("TERMCAP")
.stdin(Stdio::from(stdin))
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
// SAFETY: setsid and the TIOCSCTTY ioctl are async-signal-safe raw
// syscalls; ctty is a valid fd captured by move. We touch no parent
// heap state, satisfying pre_exec's contract.
unsafe {
cmd.pre_exec(move || {
rustix::process::setsid()?;
rustix::process::ioctl_tiocsctty(&ctty)?;
Ok(())
});
}
let child = cmd.spawn().context("spawn shell")?;
Ok(Self { master, child })
}
/// The PTY master, for reading child output and writing input.
pub fn master(&self) -> &OwnedFd {
&self.master
}
/// Inform the kernel (and thus the child) of a new terminal size.
pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {
set_winsize(&self.master, cols, rows)
}
/// Reap the child if it has exited.
pub fn wait(&mut self) -> io::Result<ExitStatus> {
self.child.wait()
}
}
fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> {
let ws = Winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
tcsetwinsize(master.as_fd(), ws).context("set pty winsize")
}
/// Login-shell argv[0] is the shell's basename with a leading '-'.
fn login_argv0(shell: &OsStr) -> std::ffi::OsString {
let name = Path::new(shell)
.file_name()
.unwrap_or_else(|| OsStr::new("sh"));
let mut argv0 = std::ffi::OsString::from("-");
argv0.push(name);
argv0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argv0_is_dash_prefixed_basename() {
assert_eq!(login_argv0(OsStr::new("/usr/bin/bash")), "-bash");
assert_eq!(login_argv0(OsStr::new("zsh")), "-zsh");
assert_eq!(login_argv0(OsStr::new("")), "-sh");
}
}

683
crates/beer/src/render.rs Normal file
View file

@ -0,0 +1,683 @@
//! Software renderer: compose the grid into an ARGB8888 buffer.
//!
//! The target is a `wl_shm` buffer in `Argb8888`, which on little-endian is
//! `[B, G, R, A]` per pixel. Rendering is two passes per frame - backgrounds
//! then glyphs - so a wide glyph that overflows its cell is not clipped by the
//! neighbouring cell's background fill.
use std::num::NonZeroU16;
use crate::font::{CellMetrics, Fonts, Glyph, GlyphData, Style};
use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};
use crate::theme::{Plane, Rgb, Theme};
/// A mutable view over a BGRA pixel buffer.
struct Canvas<'a> {
pixels: &'a mut [u8],
width: usize,
height: usize,
}
impl Canvas<'_> {
fn index(&self, x: i32, y: i32) -> Option<usize> {
if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height {
return None;
}
Some((y as usize * self.width + x as usize) * 4)
}
fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {
self.fill_rect_a(x0, y0, w, h, c, 0xff);
}
/// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is
/// premultiplied ARGB, so a translucent fill stores `rgb * alpha`.
fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) {
let x_start = x0.max(0) as usize;
let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);
let y_start = y0.max(0) as usize;
let y_end = ((y0 + h as i32).max(0) as usize).min(self.height);
if x_start >= x_end {
return;
}
let a = u32::from(alpha);
let pm = |v: u8| ((u32::from(v) * a) / 255) as u8;
let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha];
for y in y_start..y_end {
let row =
&mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4];
for px in row.chunks_exact_mut(4) {
px.copy_from_slice(&bytes);
}
}
}
/// Alpha-blend `fg` over the existing pixel with coverage `a`.
fn blend(&mut self, x: i32, y: i32, fg: Rgb, a: u8) {
let Some(i) = self.index(x, y) else { return };
let (a, inv) = (u32::from(a), u32::from(255 - a));
let mix = |src: u8, dst: u8| ((u32::from(src) * a + u32::from(dst) * inv) / 255) as u8;
self.pixels[i] = mix(fg.2, self.pixels[i]);
self.pixels[i + 1] = mix(fg.1, self.pixels[i + 1]);
self.pixels[i + 2] = mix(fg.0, self.pixels[i + 2]);
self.pixels[i + 3] = 0xff;
}
/// Set a single opaque pixel.
fn put(&mut self, x: i32, y: i32, c: Rgb) {
if let Some(i) = self.index(x, y) {
self.pixels[i] = c.2;
self.pixels[i + 1] = c.1;
self.pixels[i + 2] = c.0;
self.pixels[i + 3] = 0xff;
}
}
fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) {
self.fill_rect(x0, y, w, 1, c);
}
/// Composite one pre-multiplied BGRA source pixel over the destination.
fn over(&mut self, x: i32, y: i32, src: &[u8]) {
let Some(i) = self.index(x, y) else { return };
let inv = u32::from(255 - src[3]);
let comp = |s: u8, dst: u8| (u32::from(s) + u32::from(dst) * inv / 255).min(255) as u8;
self.pixels[i] = comp(src[0], self.pixels[i]);
self.pixels[i + 1] = comp(src[1], self.pixels[i + 1]);
self.pixels[i + 2] = comp(src[2], self.pixels[i + 2]);
self.pixels[i + 3] = 0xff;
}
}
/// Per-frame constants shared by every row: the colour scheme, focus, and the
/// current blink phase.
#[derive(Clone, Copy, Debug)]
pub struct Frame<'a> {
pub theme: &'a Theme,
pub focused: bool,
pub blink_on: bool,
/// Hyperlink currently under the pointer; its cells get a hover underline.
pub hovered_link: Option<NonZeroU16>,
}
#[derive(Debug)]
pub struct Renderer {
fonts: Fonts,
/// Inner padding `(x, y)` in pixels between the window edge and the grid.
pad: (i32, i32),
}
impl Renderer {
pub fn new(fonts: Fonts) -> Self {
Self { fonts, pad: (0, 0) }
}
pub fn metrics(&self) -> CellMetrics {
self.fonts.metrics()
}
pub fn set_padding(&mut self, pad_x: u32, pad_y: u32) {
self.pad = (pad_x as i32, pad_y as i32);
}
/// Rebuild the font set at a new size (font-resize bindings).
pub fn set_font(&mut self, family: &str, size_px: u32) -> Result<(), crate::font::FontError> {
self.fonts = Fonts::new(family, size_px)?;
Ok(())
}
/// Fill the whole buffer (including the padding margins) with the background
/// colour. Called once per fresh shm buffer; per-row repaints then leave the
/// margins untouched.
pub fn clear(&self, pixels: &mut [u8], dims: (usize, usize), theme: &Theme) {
let (width, height) = dims;
let mut canvas = Canvas {
pixels,
width,
height,
};
canvas.fill_rect_a(0, 0, width as u32, height as u32, theme.bg, theme.alpha);
}
/// Repaint a single grid row `y` into `pixels` (BGRA, `width`×`height` px):
/// clear the row band, fill backgrounds (and selection), draw glyphs and
/// decorations, then the cursor if it sits on this row. `blink_on` is the
/// current blink phase; blinking cells and a blinking cursor vanish when it
/// is `false`. Painting one row at a time is what lets the caller damage
/// only the rows that actually changed.
pub fn render_row(
&mut self,
pixels: &mut [u8],
dims: (usize, usize),
grid: &Grid,
frame: &Frame,
y: usize,
) {
let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on);
let (width, height) = dims;
let mut canvas = Canvas {
pixels,
width,
height,
};
let m = self.fonts.metrics();
let (pad_x, pad_y) = self.pad;
let cols = grid.cols();
let row_top = pad_y + y as i32 * m.height as i32;
canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha);
// Rows come through the scrollback viewport and may be shorter than
// `cols` after a resize, so clamp with `take`.
let abs = grid.view_to_abs(y);
let cells = grid.view_row(y);
let search = grid.search_spans_on(abs);
let match_at = |x: usize| -> Option<bool> {
search
.iter()
.find(|(lo, hi, _)| x >= *lo && x <= *hi)
.map(|(_, _, current)| *current)
};
for (x, cell) in cells.iter().take(cols).enumerate() {
// Focused match > selection > other match > the cell's own bg.
let bg = match match_at(x) {
Some(true) => theme.current_match_bg,
_ if grid.is_selected(abs, x) => theme.selection_bg,
Some(false) => theme.match_bg,
None => cell_colors(cell, theme).1,
};
if bg != theme.bg {
canvas.fill_rect(
pad_x + x as i32 * m.width as i32,
row_top,
m.width,
m.height,
bg,
);
}
}
for (x, cell) in cells.iter().take(cols).enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) {
continue;
}
if cell.flags.contains(Flags::BLINK) && !blink_on {
continue;
}
let (fg, _) = cell_colors(cell, theme);
let origin_x = pad_x + x as i32 * m.width as i32;
let style = cell_style(cell);
// A cell carrying combining marks is shaped as a cluster so the
// marks land where the font's GPOS table wants them. Shaping returns
// None for braille (drawn directly) and for clusters the face does
// not fully cover, both of which fall through to the legacy path.
let shaped = match &cell.combining {
Some(marks) if !is_braille(cell.c) => {
self.fonts.shape_cluster(cell.c, marks, style)
}
_ => None,
};
if let Some(shaped) = shaped {
for placed in &shaped.glyphs {
if let Ok(glyph) = self.fonts.glyph_indexed(shaped.face_idx, placed.gid, style)
{
blit_glyph(
&mut canvas,
glyph,
m,
origin_x + placed.x,
row_top,
placed.y,
fg,
);
}
}
} else if is_braille(cell.c) {
// Drawn directly so the dots are crisp and fill the cell, the
// way tools like btop expect, rather than however the fallback
// font happens to size its braille glyphs.
draw_braille(&mut canvas, cell.c, origin_x, row_top, m, fg);
} else {
if cell.c != ' ' {
self.draw_glyph(&mut canvas, cell.c, style, origin_x, row_top, fg);
}
// No shaper available for this cluster: stack the marks over the
// base using each mark glyph's own bearings.
if let Some(marks) = &cell.combining {
for mark in marks.chars() {
self.draw_glyph(&mut canvas, mark, style, origin_x, row_top, fg);
}
}
}
draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);
// Underline an OSC 8 hyperlink while the pointer hovers over it.
if cell.link.is_some() && cell.link == frame.hovered_link {
canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg);
}
}
// The cursor belongs to the live screen; hide it while scrolled back.
if grid.view_at_bottom() && grid.cursor().1 == y {
self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on);
}
}
/// Draw the incremental-search prompt across the bottom row, over whatever
/// grid content was there. The caller marks the bottom row dirty so this
/// repaints whenever the query or match count changes.
pub fn render_search_bar(
&mut self,
pixels: &mut [u8],
dims: (usize, usize),
theme: &Theme,
row: usize,
text: &str,
) {
let (width, height) = dims;
let mut canvas = Canvas {
pixels,
width,
height,
};
let m = self.fonts.metrics();
let (pad_x, pad_y) = self.pad;
let row_top = pad_y + row as i32 * m.height as i32;
canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);
let style = Style {
bold: false,
italic: false,
};
let mut x = pad_x;
for c in text.chars() {
if x as usize + m.width as usize > width {
break;
}
if c != ' ' {
self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);
}
x += m.width as i32;
}
}
/// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at
/// viewport cell `(row, col)`, over whatever was there.
pub fn render_label(
&mut self,
pixels: &mut [u8],
dims: (usize, usize),
theme: &Theme,
row: usize,
col: usize,
text: &str,
) {
let (width, height) = dims;
let mut canvas = Canvas {
pixels,
width,
height,
};
let m = self.fonts.metrics();
let (pad_x, pad_y) = self.pad;
let row_top = pad_y + row as i32 * m.height as i32;
let style = Style {
bold: true,
italic: false,
};
let mut x = pad_x + col as i32 * m.width as i32;
for c in text.chars() {
if x as usize + m.width as usize > width {
break;
}
canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg);
if c != ' ' {
self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg);
}
x += m.width as i32;
}
}
/// Draw the IME preedit string inline, starting at grid cell `start_col` of
/// row `row`, over whatever was there. The preedit sits on the selection
/// background and is underlined so it reads as uncommitted, in-flight text.
pub fn render_preedit(
&mut self,
pixels: &mut [u8],
dims: (usize, usize),
theme: &Theme,
row: usize,
start_col: usize,
text: &str,
) {
let (width, height) = dims;
let mut canvas = Canvas {
pixels,
width,
height,
};
let m = self.fonts.metrics();
let (pad_x, pad_y) = self.pad;
let row_top = pad_y + row as i32 * m.height as i32;
let style = Style {
bold: false,
italic: false,
};
let mut x = pad_x + start_col as i32 * m.width as i32;
for c in text.chars() {
if x < 0 || x as usize + m.width as usize > width {
break;
}
canvas.fill_rect(x, row_top, m.width, m.height, theme.selection_bg);
if c != ' ' {
self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);
}
// Underline the run a row above the cell bottom.
canvas.hline(x, row_top + m.height as i32 - 2, m.width, theme.fg);
x += m.width as i32;
}
}
/// Draw the cursor: a solid block/underline/beam when focused, a hollow
/// outline when not. A blinking cursor shape is only drawn while `blink_on`.
fn draw_cursor(
&mut self,
canvas: &mut Canvas,
grid: &Grid,
theme: &Theme,
m: CellMetrics,
focused: bool,
blink_on: bool,
) {
if !grid.cursor_visible() || (grid.cursor_blink() && !blink_on) {
return;
}
let (cx, cy) = grid.cursor();
let x0 = self.pad.0 + cx as i32 * m.width as i32;
let top = self.pad.1 + cy as i32 * m.height as i32;
// OSC 12 cursor colour wins, then the configured cursor colour, then fg.
let color = grid
.cursor_color()
.map(|(r, g, b)| Rgb(r, g, b))
.or(theme.cursor)
.unwrap_or(theme.fg);
if !focused {
let right = x0 + m.width as i32 - 1;
let bottom = top + m.height as i32 - 1;
canvas.hline(x0, top, m.width, color);
canvas.hline(x0, bottom, m.width, color);
canvas.fill_rect(x0, top, 1, m.height, color);
canvas.fill_rect(right, top, 1, m.height, color);
return;
}
match grid.cursor_shape() {
CursorShape::Block => {
canvas.fill_rect(x0, top, m.width, m.height, color);
let cell = grid.cell(cx, cy);
if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) {
let (_, bg) = cell_colors(cell, theme);
self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg);
}
}
CursorShape::Underline => {
canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color);
}
CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color),
}
}
fn draw_glyph(
&mut self,
canvas: &mut Canvas,
c: char,
style: Style,
origin_x: i32,
cell_top: i32,
fg: Rgb,
) {
let m = self.fonts.metrics();
let glyph = match self.fonts.glyph(c, style) {
Ok(glyph) => glyph,
Err(err) => {
tracing::debug!("glyph {c:?}: {err}");
return;
}
};
blit_glyph(canvas, glyph, m, origin_x, cell_top, 0, fg);
}
}
/// Composite a rasterized glyph into the canvas. `origin_x`/`cell_top` are the
/// cell's top-left; `rise` lifts the glyph above the baseline (HarfBuzz's
/// vertical offset, 0 for the unshaped path).
fn blit_glyph(
canvas: &mut Canvas,
glyph: &Glyph,
m: CellMetrics,
origin_x: i32,
cell_top: i32,
rise: i32,
fg: Rgb,
) {
let (gw, gh) = (glyph.width as i32, glyph.height as i32);
match &glyph.data {
GlyphData::Mask(mask) => {
let baseline = cell_top + m.ascent as i32 - rise;
for gy in 0..gh {
for gx in 0..gw {
let a = mask[(gy * gw + gx) as usize];
if a != 0 {
canvas.blend(origin_x + glyph.left + gx, baseline - glyph.top + gy, fg, a);
}
}
}
}
// Colour glyphs (emoji) come from a fixed strike at native size;
// scale them to the line height with nearest-neighbour sampling.
GlyphData::Color(bgra) if gh > 0 => {
let scale = m.height as f32 / gh as f32;
let target_w = (gw as f32 * scale) as i32;
for ty in 0..m.height as i32 {
let sy = ((ty as f32 / scale) as i32).min(gh - 1);
for tx in 0..target_w {
let sx = ((tx as f32 / scale) as i32).min(gw - 1);
let i = ((sy * gw + sx) * 4) as usize;
canvas.over(origin_x + tx, cell_top + ty, &bgra[i..i + 4]);
}
}
}
GlyphData::Color(_) => {}
}
}
fn cell_style(cell: &Cell) -> Style {
Style {
bold: cell.flags.contains(Flags::BOLD),
italic: cell.flags.contains(Flags::ITALIC),
}
}
/// Resolve a cell's (foreground, background) RGB, applying reverse video,
/// bold-as-bright, dim, and hidden.
fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) {
let bold = cell.flags.contains(Flags::BOLD);
let mut fg = theme.resolve(cell.fg, Plane::Fg, bold);
let mut bg = theme.resolve(cell.bg, Plane::Bg, false);
if cell.flags.contains(Flags::REVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg = blend_rgb(fg, bg);
}
if cell.flags.contains(Flags::HIDDEN) {
fg = bg;
}
(fg, bg)
}
/// Mix `c` two-thirds of the way from `toward`, used for the dim attribute.
fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb {
let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8;
Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2))
}
/// Whether `c` is a Braille Patterns codepoint (U+2800-U+28FF).
fn is_braille(c: char) -> bool {
('\u{2800}'..='\u{28ff}').contains(&c)
}
/// Braille dot geometry for a `width`×`height` cell: the square dot side `w`,
/// the two column origins, and the four row origins. Ported verbatim from
/// foot's `box-drawing.c` `draw_braille` - base size and spacing from the cell,
/// then leftover pixels distributed (dot → margin → spacing → margin → dot) so
/// dots land on exact pixels with no rounding drift.
fn braille_geometry(width: i32, height: i32) -> (u32, [i32; 2], [i32; 4]) {
let mut w = (width / 4).min(height / 8);
let mut x_spacing = width / 4;
let mut y_spacing = height / 8;
let mut x_margin = x_spacing / 2;
let mut y_margin = y_spacing / 2;
let mut x_left = width - 2 * x_margin - x_spacing - 2 * w;
let mut y_left = height - 2 * y_margin - 3 * y_spacing - 4 * w;
// First, try hard to ensure a non-zero dot width.
if x_left >= 2 && y_left >= 4 && w == 0 {
w += 1;
x_left -= 2;
y_left -= 4;
}
// Second, prefer a non-zero margin.
if x_left >= 2 && x_margin == 0 {
x_margin = 1;
x_left -= 2;
}
if y_left >= 2 && y_margin == 0 {
y_margin = 1;
y_left -= 2;
}
// Third, increase spacing.
if x_left >= 1 {
x_spacing += 1;
x_left -= 1;
}
if y_left >= 3 {
y_spacing += 1;
y_left -= 3;
}
// Fourth, the side margins.
if x_left >= 2 {
x_margin += 1;
x_left -= 2;
}
if y_left >= 2 {
y_margin += 1;
y_left -= 2;
}
// Last, increase the dot width.
if x_left >= 2 && y_left >= 4 {
w += 1;
}
let xs = [x_margin, x_margin + w + x_spacing];
let ys = [
y_margin,
y_margin + w + y_spacing,
y_margin + 2 * (w + y_spacing),
y_margin + 3 * (w + y_spacing),
];
(w.max(0) as u32, xs, ys)
}
/// Draw a braille pattern as a 2×4 grid of `w`×`w` square dots. Geometry ported
/// from foot's `box-drawing.c` `draw_braille`: a dot size and base spacing are
/// derived from the cell, then leftover pixels are distributed (dot width →
/// margins → spacing → …) so dots land on exact pixels with no rounding drift.
/// The low eight bits of the codepoint select dots: bits 0-2 are the left
/// column rows 0-2, bits 3-5 the right column rows 0-2, bits 6-7 the bottom row.
fn draw_braille(canvas: &mut Canvas, c: char, x0: i32, top: i32, m: CellMetrics, fg: Rgb) {
let (w, xs, ys) = braille_geometry(m.width as i32, m.height as i32);
let sym = ((c as u32) - 0x2800) as u8;
// (bit mask, column index, row index).
const DOTS: [(u8, usize, usize); 8] = [
(0x01, 0, 0),
(0x02, 0, 1),
(0x04, 0, 2),
(0x08, 1, 0),
(0x10, 1, 1),
(0x20, 1, 2),
(0x40, 0, 3),
(0x80, 1, 3),
];
for (mask, col, row) in DOTS {
if sym & mask != 0 {
canvas.fill_rect(x0 + xs[col], top + ys[row], w, w, fg);
}
}
}
/// Draw underline, strikethrough, and overline for one cell.
fn draw_decorations(
canvas: &mut Canvas,
cell: &Cell,
theme: &Theme,
x0: i32,
top: i32,
m: CellMetrics,
fg: Rgb,
) {
let w = m.width;
let baseline = top + m.ascent as i32;
let uy = (baseline + 1).min(top + m.height as i32 - 1);
// A `Default` underline colour follows the cell's foreground.
let uc = match cell.underline_color {
crate::grid::Color::Default => fg,
other => theme.resolve(other, Plane::Fg, false),
};
match cell.underline {
Underline::None => {}
Underline::Single => canvas.hline(x0, uy, w, uc),
Underline::Double => {
canvas.hline(x0, uy, w, uc);
canvas.hline(x0, (uy - 2).max(top), w, uc);
}
Underline::Curly => {
for dx in 0..w as i32 {
let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 };
canvas.put(x0 + dx, uy - wobble, uc);
}
}
Underline::Dotted => {
for dx in (0..w as i32).step_by(2) {
canvas.put(x0 + dx, uy, uc);
}
}
Underline::Dashed => {
for dx in 0..w as i32 {
if (dx / 3) % 2 == 0 {
canvas.put(x0 + dx, uy, uc);
}
}
}
}
if cell.flags.contains(Flags::OVERLINE) {
canvas.hline(x0, top, w, fg);
}
if cell.flags.contains(Flags::STRIKE) {
canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);
}
}
#[cfg(test)]
mod tests {
use super::braille_geometry;
// Pinned to foot box-drawing.c draw_braille output (cross-checked numerically
// identical across cell sizes 4..30 x 6..48); guards against drift.
#[test]
fn braille_geometry_matches_foot() {
assert_eq!(braille_geometry(8, 18), (2, [1, 5], [2, 6, 10, 14]));
assert_eq!(braille_geometry(10, 20), (2, [1, 6], [1, 6, 11, 16]));
assert_eq!(braille_geometry(12, 27), (3, [1, 8], [1, 8, 15, 22]));
assert_eq!(braille_geometry(7, 15), (1, [1, 4], [2, 5, 8, 11]));
}
}

256
crates/beer/src/theme.rs Normal file
View file

@ -0,0 +1,256 @@
//! Runtime colours: the foreground/background, the 256-entry palette, and the
//! selection/search/cursor accents. Seeded from config and mutated at runtime
//! by OSC colour escapes (4/104, 10/11, 17/19, and their resets).
use crate::config::Colors as ColorConfig;
/// An 8-bit-per-channel RGB colour.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Rgb(pub u8, pub u8, pub u8);
/// Where a `Color::Default` resolves to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Plane {
Fg,
Bg,
}
/// The active colour scheme.
#[derive(Clone, Debug)]
pub struct Theme {
pub fg: Rgb,
pub bg: Rgb,
/// Cursor body colour; `None` follows the cell under the cursor.
pub cursor: Option<Rgb>,
pub selection_fg: Option<Rgb>,
pub selection_bg: Rgb,
pub match_bg: Rgb,
pub current_match_bg: Rgb,
pub search_bar_bg: Rgb,
pub palette: [Rgb; 256],
pub bold_as_bright: bool,
/// Background opacity, 0 (transparent) - 255 (opaque).
pub alpha: u8,
/// Configured defaults, restored by OSC reset escapes (110/111/104).
default_fg: Rgb,
default_bg: Rgb,
default_palette: [Rgb; 256],
}
impl Default for Theme {
fn default() -> Self {
let palette = default_palette();
Self {
fg: DEFAULT_FG,
bg: DEFAULT_BG,
cursor: None,
selection_fg: None,
selection_bg: SELECTION_BG,
match_bg: MATCH_BG,
current_match_bg: CURRENT_MATCH_BG,
search_bar_bg: SEARCH_BAR_BG,
palette,
bold_as_bright: true,
alpha: 255,
default_fg: DEFAULT_FG,
default_bg: DEFAULT_BG,
default_palette: palette,
}
}
}
impl Theme {
/// Build a theme from the `[colors]` config table, falling back to the
/// built-in defaults for any unset entry.
pub fn from_config(c: &ColorConfig) -> Self {
let mut theme = Self::default();
let set = |dst: &mut Rgb, spec: &Option<String>| {
if let Some(rgb) = spec.as_deref().and_then(parse_color) {
*dst = rgb;
}
};
set(&mut theme.fg, &c.foreground);
set(&mut theme.bg, &c.background);
set(&mut theme.selection_bg, &c.selection_background);
set(&mut theme.match_bg, &c.match_background);
set(&mut theme.current_match_bg, &c.match_current_background);
theme.cursor = c.cursor.as_deref().and_then(parse_color);
theme.selection_fg = c.selection_foreground.as_deref().and_then(parse_color);
if let Some(regular) = &c.regular {
apply_palette(&mut theme.palette, 0, regular);
}
if let Some(bright) = &c.bright {
apply_palette(&mut theme.palette, 8, bright);
}
if let Some(a) = c.alpha {
theme.alpha = (a.clamp(0.0, 1.0) * 255.0).round() as u8;
}
if let Some(b) = c.bold_as_bright {
theme.bold_as_bright = b;
}
// Remember the resolved scheme so OSC resets return here, not to the
// compiled-in defaults.
theme.default_fg = theme.fg;
theme.default_bg = theme.bg;
theme.default_palette = theme.palette;
theme
}
/// Resolve a cell colour to RGB on the given plane, applying bold-as-bright
/// to the low 8 palette indices.
pub fn resolve(&self, color: crate::grid::Color, plane: Plane, bold: bool) -> Rgb {
use crate::grid::Color;
match color {
Color::Default => match plane {
Plane::Fg => self.fg,
Plane::Bg => self.bg,
},
Color::Indexed(i) => {
let idx = if bold && self.bold_as_bright && i < 8 {
i + 8
} else {
i
};
self.palette[idx as usize]
}
Color::Rgb(r, g, b) => Rgb(r, g, b),
}
}
pub fn set_palette(&mut self, index: u8, rgb: Rgb) {
self.palette[index as usize] = rgb;
}
/// Reset palette entry `index` to its configured value.
pub fn reset_palette_index(&mut self, index: u8) {
self.palette[index as usize] = self.default_palette[index as usize];
}
/// Reset the whole palette to its configured values.
pub fn reset_palette(&mut self) {
self.palette = self.default_palette;
}
pub fn reset_fg(&mut self) {
self.fg = self.default_fg;
}
pub fn reset_bg(&mut self) {
self.bg = self.default_bg;
}
/// A copy with foreground and background swapped, for the visual bell flash.
pub fn inverted(&self) -> Self {
let mut t = self.clone();
std::mem::swap(&mut t.fg, &mut t.bg);
t
}
}
/// Foreground/background used for `Color::Default`.
const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);
const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);
const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);
const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);
const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);
/// Overwrite palette entries `[base, base+specs.len())` from hex specs.
fn apply_palette(palette: &mut [Rgb; 256], base: usize, specs: &[String]) {
for (i, spec) in specs.iter().take(8).enumerate() {
if let Some(rgb) = parse_color(spec) {
palette[base + i] = rgb;
}
}
}
/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.
fn default_palette() -> [Rgb; 256] {
const BASE: [Rgb; 16] = [
Rgb(0x00, 0x00, 0x00),
Rgb(0xcd, 0x00, 0x00),
Rgb(0x00, 0xcd, 0x00),
Rgb(0xcd, 0xcd, 0x00),
Rgb(0x00, 0x00, 0xee),
Rgb(0xcd, 0x00, 0xcd),
Rgb(0x00, 0xcd, 0xcd),
Rgb(0xe5, 0xe5, 0xe5),
Rgb(0x7f, 0x7f, 0x7f),
Rgb(0xff, 0x00, 0x00),
Rgb(0x00, 0xff, 0x00),
Rgb(0xff, 0xff, 0x00),
Rgb(0x5c, 0x5c, 0xff),
Rgb(0xff, 0x00, 0xff),
Rgb(0x00, 0xff, 0xff),
Rgb(0xff, 0xff, 0xff),
];
let cube = |step: u8| -> u8 { if step == 0 { 0 } else { 55 + 40 * step } };
std::array::from_fn(|i| match i {
0..=15 => BASE[i],
16..=231 => {
let i = i as u8 - 16;
Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))
}
_ => {
let v = 8 + 10 * (i as u8 - 232);
Rgb(v, v, v)
}
})
}
/// Parse an X11 colour spec: `rgb:rr/gg/bb` (1-4 hex digits per channel) or
/// `#rrggbb`.
pub fn parse_color(spec: &str) -> Option<Rgb> {
if let Some(rest) = spec.strip_prefix("rgb:") {
let mut it = rest.split('/');
let chan = |s: &str| -> Option<u8> {
let v = u32::from_str_radix(s, 16).ok()?;
let max = (1u32 << (4 * s.len() as u32)) - 1;
Some((v * 255 / max) as u8)
};
return Some(Rgb(chan(it.next()?)?, chan(it.next()?)?, chan(it.next()?)?));
}
let hex = spec.strip_prefix('#')?;
if hex.len() == 6 {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
return Some(Rgb(byte(0)?, byte(2)?, byte(4)?));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_color_forms() {
assert_eq!(parse_color("#ff0080"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("rgb:ff/00/80"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("nope"), None);
}
#[test]
fn palette_cube_and_grey() {
let p = default_palette();
assert_eq!(p[16], Rgb(0, 0, 0));
assert_eq!(p[231], Rgb(255, 255, 255));
assert_eq!(p[232], Rgb(8, 8, 8));
}
#[test]
fn config_overrides_and_resets() {
let cfg = ColorConfig {
background: Some("#000000".to_string()),
regular: Some(vec!["#111111".to_string()]),
..ColorConfig::default()
};
let mut theme = Theme::from_config(&cfg);
assert_eq!(theme.bg, Rgb(0, 0, 0));
assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));
// OSC reset returns to the configured value, not the compiled default.
theme.set_palette(0, Rgb(9, 9, 9));
theme.reset_palette_index(0);
assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));
}
}

772
crates/beer/src/vt/mod.rs Normal file
View file

@ -0,0 +1,772 @@
//! VT emulation: feed bytes through `vte` and drive the [`Grid`].
mod perform;
use std::io::Write as _;
use vte::Params;
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;
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)]
enum DaLevel {
Primary,
Secondary,
Tertiary,
}
/// A clipboard request from the application (OSC 52), for the front-end to act
/// on since it owns the Wayland selections.
#[derive(Clone, Debug)]
pub enum ClipboardOp {
/// Set the clipboard (or primary) to `text`.
Set { primary: bool, text: String },
/// Report the current clipboard (or primary) contents back to the app.
Query { primary: bool },
}
/// A desktop notification an application requested (OSC 9 / 777 / 99), for the
/// front-end to deliver via the configured notifier.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notification {
pub title: Option<String>,
pub body: String,
}
/// Which dynamic colour an OSC 10/11/17/19 escape targets.
#[derive(Clone, Copy, Debug)]
enum Dynamic {
Fg,
Bg,
SelBg,
SelFg,
}
/// DECRQM mode-state code: 1 = set, 2 = reset.
fn set_reset(on: bool) -> u8 {
if on { 1 } else { 2 }
}
/// Parse an OSC colour spec into an [`Rgb`].
fn parse_spec(spec: &[u8]) -> Option<Rgb> {
std::str::from_utf8(spec)
.ok()
.and_then(crate::theme::parse_color)
}
/// Parse a decimal palette index (0-255).
fn parse_index(b: &[u8]) -> Option<u8> {
std::str::from_utf8(b).ok()?.parse().ok()
}
fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {
(rgb.0, rgb.1, rgb.2)
}
/// Select `protocol` when a mouse mode is set, else turn reporting off.
fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {
if on { protocol } else { MouseProtocol::Off }
}
/// Select `encoding` when its mode is set, else fall back to the default form.
fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {
if on { encoding } else { MouseEncoding::X10 }
}
/// The terminal model: a grid plus the escape-sequence state around it.
#[derive(Debug)]
pub struct Term {
grid: Grid,
title: Option<String>,
title_stack: Vec<Option<String>>,
response: Vec<u8>,
g0: Charset,
g1: Charset,
shift_out: bool,
/// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.
xtgettcap: Option<Vec<u8>>,
/// Pending OSC 52 clipboard requests, drained by the front-end.
clipboard_ops: Vec<ClipboardOp>,
/// The active colour scheme (seeded from config, mutated by OSC escapes).
theme: Theme,
/// Set when the child rings the bell (`BEL`); cleared by the front-end.
bell: bool,
/// Working directory reported by the shell via OSC 7, for new windows.
cwd: Option<String>,
/// Desktop notifications requested via OSC 9/777/99, drained by the front-end.
notifications: Vec<Notification>,
}
impl Term {
pub fn new(cols: usize, rows: usize) -> Self {
Self {
grid: Grid::new(cols, rows),
title: None,
title_stack: Vec::new(),
response: Vec::new(),
g0: Charset::Ascii,
g1: Charset::Ascii,
shift_out: false,
xtgettcap: None,
clipboard_ops: Vec::new(),
theme: Theme::default(),
bell: false,
cwd: None,
notifications: Vec::new(),
}
}
/// The working directory last reported by the shell (OSC 7), if any.
pub fn cwd(&self) -> Option<&str> {
self.cwd.as_deref()
}
/// Drain the desktop notifications requested since the last call.
pub fn take_notifications(&mut self) -> Vec<Notification> {
std::mem::take(&mut self.notifications)
}
/// Take and clear the pending bell flag.
pub fn take_bell(&mut self) -> bool {
std::mem::take(&mut self.bell)
}
/// Drain the OSC 52 clipboard requests accumulated since the last call.
pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {
std::mem::take(&mut self.clipboard_ops)
}
pub fn theme(&self) -> &Theme {
&self.theme
}
/// Replace the colour scheme (config load / reload).
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Answer an XTGETTCAP query: for each hex-encoded capability name, reply
/// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.
fn answer_xtgettcap(&mut self, payload: &[u8]) {
for name_hex in payload.split(|&b| b == b';') {
let value = decode_hex(name_hex).and_then(|name| cap_value(&name));
match value {
Some(value) => {
self.response.extend_from_slice(b"\x1bP1+r");
self.response.extend_from_slice(name_hex);
self.response.push(b'=');
for byte in value.bytes() {
let _ = write!(self.response, "{byte:02x}");
}
self.response.extend_from_slice(b"\x1b\\");
}
None => {
self.response.extend_from_slice(b"\x1bP0+r");
self.response.extend_from_slice(name_hex);
self.response.extend_from_slice(b"\x1b\\");
}
}
}
}
pub fn grid(&self) -> &Grid {
&self.grid
}
pub fn grid_mut(&mut self) -> &mut Grid {
&mut self.grid
}
pub fn resize(&mut self, cols: usize, rows: usize) {
self.grid.resize(cols, rows);
}
pub fn scroll_view(&mut self, delta: isize) {
self.grid.scroll_view(delta);
}
pub fn scroll_to_bottom(&mut self) {
self.grid.scroll_to_bottom();
}
/// Lines per page, for page-scroll bindings.
pub fn page(&self) -> usize {
self.grid.page()
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
/// Bytes the terminal needs to send back to the application (DA, CPR, ...).
pub fn take_response(&mut self) -> Vec<u8> {
std::mem::take(&mut self.response)
}
fn active_charset(&self) -> Charset {
if self.shift_out { self.g1 } else { self.g0 }
}
fn set_mode(&mut self, params: &Params, private: bool, on: bool) {
for p in params.iter() {
let Some(&code) = p.first() else { continue };
match (private, code) {
(true, 6) => self.grid.set_origin(on),
(true, 7) => self.grid.set_autowrap(on),
(true, 1049) => {
if on {
self.grid.save_cursor();
self.grid.enter_alt_screen();
self.grid.erase_display(2);
} else {
self.grid.leave_alt_screen();
self.grid.restore_cursor();
}
}
(true, 47 | 1047) => {
if on {
self.grid.enter_alt_screen();
} else {
self.grid.leave_alt_screen();
}
}
(false, 4) => self.grid.set_insert(on),
(true, 1) => self.grid.set_app_cursor(on),
(true, 25) => self.grid.set_cursor_visible(on),
(true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),
(true, 1000) => self
.grid
.set_mouse_protocol(proto(on, MouseProtocol::Normal)),
(true, 1002) => self
.grid
.set_mouse_protocol(proto(on, MouseProtocol::Button)),
(true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),
(true, 1004) => self.grid.set_focus_events(on),
(true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),
(true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),
(true, 2004) => self.grid.set_bracketed_paste(on),
(true, 2026) => self.grid.set_sync(on),
_ => tracing::trace!("unhandled mode {code} private={private} on={on}"),
}
}
}
fn sgr(&mut self, params: &Params) {
let items: Vec<&[u16]> = params.iter().collect();
if items.is_empty() {
self.grid.reset_pen();
return;
}
let mut i = 0;
while i < items.len() {
let p = items[i];
let code = p.first().copied().unwrap_or(0);
let pen = self.grid.pen_mut();
let mut step = 1;
match code {
0 => *pen = Default::default(),
1 => pen.flags.insert(Flags::BOLD),
2 => pen.flags.insert(Flags::DIM),
3 => pen.flags.insert(Flags::ITALIC),
4 => pen.underline = underline_from(p),
5 | 6 => pen.flags.insert(Flags::BLINK),
7 => pen.flags.insert(Flags::REVERSE),
8 => pen.flags.insert(Flags::HIDDEN),
9 => pen.flags.insert(Flags::STRIKE),
21 => pen.underline = Underline::Double,
22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),
23 => pen.flags.remove(Flags::ITALIC),
24 => pen.underline = Underline::None,
25 => pen.flags.remove(Flags::BLINK),
27 => pen.flags.remove(Flags::REVERSE),
28 => pen.flags.remove(Flags::HIDDEN),
29 => pen.flags.remove(Flags::STRIKE),
30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
39 => pen.fg = Color::Default,
40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
49 => pen.bg = Color::Default,
53 => pen.flags.insert(Flags::OVERLINE),
55 => pen.flags.remove(Flags::OVERLINE),
90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
38 | 48 | 58 => {
let (color, consumed) = ext_color(&items, i);
if let Some(color) = color {
match code {
38 => pen.fg = color,
48 => pen.bg = color,
_ => pen.underline_color = color,
}
}
step = consumed;
}
59 => pen.underline_color = Color::Default,
_ => {}
}
i += step;
}
}
/// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic
/// firmware level; DA3 a (zero) unit ID.
fn device_attrs(&mut self, level: DaLevel) {
match level {
DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),
DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),
DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),
}
}
/// Reply to a kitty-keyboard flags query (`CSI ? u`) with `CSI ? flags u`.
fn report_kitty_flags(&mut self) {
let _ = write!(self.response, "\x1b[?{}u", self.grid.kitty_flags());
}
/// XTVERSION (`CSI > q`): report the terminal name and version.
fn report_version(&mut self) {
let _ = write!(
self.response,
"\x1bP>|beer({})\x1b\\",
env!("CARGO_PKG_VERSION")
);
}
/// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).
fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {
let Rgb(r, g, b) = rgb;
let _ = write!(
self.response,
"\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",
u16::from(r) * 0x101,
u16::from(g) * 0x101,
u16::from(b) * 0x101,
);
self.response
.extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });
}
/// OSC 4: set or query palette entries, given as `index;spec` pairs.
fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {
let mut rest = params[1..].iter();
while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {
let Some(idx) = parse_index(idx_raw) else {
continue;
};
if *spec == b"?" {
let rgb = self.theme.palette[idx as usize];
self.reply_color(&format!("4;{idx}"), rgb, bell);
} else if let Some(rgb) = parse_spec(spec) {
self.theme.set_palette(idx, rgb);
}
}
}
/// OSC 10/11/17/19: set or query a dynamic colour.
fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {
let Some(spec) = spec else { return };
if **spec == b"?"[..] {
let rgb = match kind {
Dynamic::Fg => self.theme.fg,
Dynamic::Bg => self.theme.bg,
Dynamic::SelBg => self.theme.selection_bg,
Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),
};
let code = match kind {
Dynamic::Fg => "10",
Dynamic::Bg => "11",
Dynamic::SelBg => "17",
Dynamic::SelFg => "19",
};
self.reply_color(code, rgb, bell);
} else if let Some(rgb) = parse_spec(spec) {
match kind {
Dynamic::Fg => self.theme.fg = rgb,
Dynamic::Bg => self.theme.bg = rgb,
Dynamic::SelBg => self.theme.selection_bg = rgb,
Dynamic::SelFg => self.theme.selection_fg = Some(rgb),
}
}
}
fn device_status(&mut self, params: &Params) {
match params.iter().next().and_then(|p| p.first().copied()) {
Some(5) => self.response.extend_from_slice(b"\x1b[0n"),
Some(6) => {
let (x, y) = self.grid.cursor();
let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);
}
_ => {}
}
}
/// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),
/// or unrecognized (0). Only the modes we actually track are reported.
fn report_mode(&mut self, params: &Params, private: bool) {
let code = raw(params, 0);
let state = match (private, code) {
(true, 6) => set_reset(self.grid.origin()),
(true, 7) => set_reset(self.grid.autowrap()),
(true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),
(true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),
(true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),
(true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),
(true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),
(true, 1004) => set_reset(self.grid.focus_events()),
(true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),
(true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),
(true, 2004) => set_reset(self.grid.bracketed_paste()),
(true, 2026) => set_reset(self.grid.sync_active()),
(false, 4) => set_reset(self.grid.insert()),
_ => 0,
};
let prefix = if private { "?" } else { "" };
let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");
}
/// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.
fn title_stack_op(&mut self, params: &Params) {
match raw(params, 0) {
22 => self.title_stack.push(self.title.clone()),
23 => {
if let Some(title) = self.title_stack.pop() {
self.title = title;
}
}
_ => {}
}
}
}
/// First param value, with 0/absent folded to `default` (xterm convention for
/// cursor movement and counts).
fn n(params: &Params, idx: usize, default: usize) -> usize {
match params.iter().nth(idx).and_then(|p| p.first().copied()) {
Some(0) | None => default,
Some(v) => v as usize,
}
}
/// Raw first param value (0 is meaningful), defaulting to 0 when absent.
fn raw(params: &Params, idx: usize) -> u16 {
params
.iter()
.nth(idx)
.and_then(|p| p.first().copied())
.unwrap_or(0)
}
/// 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())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid::{MouseEncoding, MouseProtocol};
fn feed(term: &mut Term, bytes: &[u8]) {
let mut parser = vte::Parser::new();
parser.advance(term, bytes);
}
#[test]
fn plain_text_lands_in_the_grid() {
let mut t = Term::new(20, 4);
feed(&mut t, b"hello");
assert_eq!(t.grid().row_text(0), "hello");
}
#[test]
fn cursor_position_and_erase() {
let mut t = Term::new(20, 4);
feed(&mut t, b"abcde\x1b[Hxyz");
assert_eq!(t.grid().row_text(0), "xyzde");
}
#[test]
fn newline_sequence() {
let mut t = Term::new(20, 4);
feed(&mut t, b"one\r\ntwo");
assert_eq!(t.grid().row_text(0), "one");
assert_eq!(t.grid().row_text(1), "two");
}
#[test]
fn device_attributes_levels() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b[c");
assert_eq!(t.take_response(), b"\x1b[?62;22c");
feed(&mut t, b"\x1b[>c");
assert_eq!(t.take_response(), b"\x1b[>0;276;0c");
feed(&mut t, b"\x1b[=c");
assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");
}
#[test]
fn xtversion_reports_name() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b[>q");
let resp = t.take_response();
assert!(resp.starts_with(b"\x1bP>|beer("));
assert!(resp.ends_with(b")\x1b\\"));
}
#[test]
fn decrqm_reports_known_modes() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default
assert_eq!(t.take_response(), b"\x1b[?7;1$y");
feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query
assert_eq!(t.take_response(), b"\x1b[?7;2$y");
feed(&mut t, b"\x1b[?9999$p"); // unknown mode
assert_eq!(t.take_response(), b"\x1b[?9999;0$y");
}
#[test]
fn sgr_underline_styles_and_lines() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[4:3;58;5;1;53mX");
let cell = t.grid().cell(0, 0);
assert_eq!(cell.underline, Underline::Curly);
assert_eq!(cell.underline_color, Color::Indexed(1));
assert!(cell.flags.contains(Flags::OVERLINE));
// 4:0 turns the underline back off.
feed(&mut t, b"\x1b[4:0mY");
assert_eq!(t.grid().cell(1, 0).underline, Underline::None);
}
#[test]
fn decscusr_and_cursor_visibility() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[4 q");
assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);
feed(&mut t, b"\x1b[6 q");
assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);
feed(&mut t, b"\x1b[0 q");
assert_eq!(t.grid().cursor_shape(), CursorShape::Block);
feed(&mut t, b"\x1b[?25l");
assert!(!t.grid().cursor_visible());
feed(&mut t, b"\x1b[?25h");
assert!(t.grid().cursor_visible());
}
#[test]
fn osc12_sets_and_resets_cursor_color() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b]12;#ff0000\x07");
assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));
feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");
assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));
feed(&mut t, b"\x1b]112\x07");
assert_eq!(t.grid().cursor_color(), None);
}
#[test]
fn decscusr_and_cursor_color() {
use crate::grid::CursorShape;
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[5 q"); // blinking bar
assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);
feed(&mut t, b"\x1b[4 q"); // steady underline
assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);
feed(&mut t, b"\x1b]12;#ff3030\x07");
assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));
feed(&mut t, b"\x1b]112\x07");
assert_eq!(t.grid().cursor_color(), None);
feed(&mut t, b"\x1b[?25l"); // hide cursor
assert!(!t.grid().cursor_visible());
}
#[test]
fn osc_palette_and_dynamic_colors() {
use crate::theme::Rgb;
let mut t = Term::new(20, 2);
// Set palette index 1 and foreground via OSC, then query them back.
feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");
assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));
feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");
assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));
feed(&mut t, b"\x1b]11;?\x07");
let resp = t.take_response();
assert!(resp.starts_with(b"\x1b]11;rgb:"));
// Reset returns the palette entry to its default.
feed(&mut t, b"\x1b]104;1\x1b\\");
assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));
}
#[test]
fn xtgettcap_known_and_unknown() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"
assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"
feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown
assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");
}
#[test]
fn bracketed_paste_and_sync_modes() {
let mut t = Term::new(20, 2);
feed(&mut t, b"\x1b[?2004h");
assert!(t.grid().bracketed_paste());
feed(&mut t, b"\x1b[?2004$p");
assert_eq!(t.take_response(), b"\x1b[?2004;1$y");
feed(&mut t, b"\x1b[?2026h");
assert!(t.grid().sync_active());
feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");
assert!(!t.grid().sync_active());
assert_eq!(t.take_response(), b"\x1b[?2026;2$y");
}
#[test]
fn osc52_set_and_query() {
let mut t = Term::new(20, 2);
// Set clipboard to "hi" (base64 "aGk=").
feed(&mut t, b"\x1b]52;c;aGk=\x07");
let ops = t.take_clipboard_ops();
match ops.as_slice() {
[
ClipboardOp::Set {
primary: false,
text,
},
] => assert_eq!(text, "hi"),
other => panic!("unexpected ops: {other:?}"),
}
// Query the primary selection.
feed(&mut t, b"\x1b]52;p;?\x07");
let ops = t.take_clipboard_ops();
assert!(matches!(
ops.as_slice(),
[ClipboardOp::Query { primary: true }]
));
}
#[test]
fn mouse_modes_track_protocol_and_encoding() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b[?1002h\x1b[?1006h");
assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);
assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);
feed(&mut t, b"\x1b[?1002$p");
assert_eq!(t.take_response(), b"\x1b[?1002;1$y");
feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event
assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);
feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting
assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);
feed(&mut t, b"\x1b[?1004h");
assert!(t.grid().focus_events());
}
#[test]
fn title_stack_push_pop() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b]0;first\x07");
feed(&mut t, b"\x1b[22t"); // push "first"
feed(&mut t, b"\x1b]0;second\x07");
assert_eq!(t.title(), Some("second"));
feed(&mut t, b"\x1b[23t"); // pop -> "first"
assert_eq!(t.title(), Some("first"));
}
#[test]
fn sgr_sets_pen_colours() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[31;1mX");
let cell = t.grid().cell(0, 0);
assert_eq!(cell.fg, Color::Indexed(1));
assert!(cell.flags.contains(Flags::BOLD));
}
#[test]
fn truecolor_semicolon_and_colon() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[38;2;10;20;30mA");
assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));
feed(&mut t, b"\x1b[38:2:40:50:60mB");
assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));
}
#[test]
fn device_status_reports_cursor() {
let mut t = Term::new(20, 4);
feed(&mut t, b"\x1b[3;5H\x1b[6n");
assert_eq!(t.take_response(), b"\x1b[3;5R");
}
#[test]
fn line_drawing_charset() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b(0qx\x1b(B");
assert_eq!(t.grid().row_text(0), "─│");
}
#[test]
fn osc133_marks_capture_last_command_output() {
let mut t = Term::new(12, 6);
feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command
feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output
feed(&mut t, b"\x1b]133;D\x07"); // command finished
assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));
}
#[test]
fn osc_notifications_collected() {
let mut t = Term::new(20, 2);
feed(&mut t, b"\x1b]9;hello\x07");
feed(&mut t, b"\x1b]777;notify;Title;Body\x07");
let n = t.take_notifications();
assert_eq!(
n,
vec![
Notification {
title: None,
body: "hello".into()
},
Notification {
title: Some("Title".into()),
body: "Body".into()
},
]
);
assert!(t.take_notifications().is_empty());
}
#[test]
fn osc7_tracks_cwd_and_decodes_percent() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");
assert_eq!(t.cwd(), Some("/home/user/my dir"));
// A non-file or relative URI leaves the previous value untouched? It
// simply does not match a path, so cwd stays None here.
let mut t2 = Term::new(20, 1);
feed(&mut t2, b"\x1b]7;file://host\x07");
assert_eq!(t2.cwd(), None);
}
#[test]
fn title_via_osc() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b]0;hello\x07");
assert_eq!(t.title(), Some("hello"));
}
#[test]
fn alt_screen_preserves_primary() {
let mut t = Term::new(20, 2);
feed(&mut t, b"main");
feed(&mut t, b"\x1b[?1049h");
assert_eq!(t.grid().row_text(0), "");
feed(&mut t, b"\x1b[?1049l");
assert_eq!(t.grid().row_text(0), "main");
}
}

View file

@ -0,0 +1,250 @@
use vte::Perform;
use super::*;
impl Perform for Term {
fn print(&mut self, c: char) {
let c = if self.active_charset() == Charset::DecSpecial {
dec_special(c)
} else {
c
};
self.grid.print(c);
}
fn execute(&mut self, byte: u8) {
match byte {
0x07 => self.bell = true,
0x08 => self.grid.backspace(),
0x09 => self.grid.tab(),
0x0A..=0x0C => self.grid.line_feed(),
0x0D => self.grid.carriage_return(),
0x0E => self.shift_out = true,
0x0F => self.shift_out = false,
_ => {}
}
}
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
let private = intermediates.first() == Some(&b'?');
match action {
'A' => self.grid.cursor_up(n(params, 0, 1)),
'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),
'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),
'D' => self.grid.cursor_back(n(params, 0, 1)),
'E' => {
self.grid.cursor_down(n(params, 0, 1));
self.grid.carriage_return();
}
'F' => {
self.grid.cursor_up(n(params, 0, 1));
self.grid.carriage_return();
}
'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),
'd' => self.grid.move_to_row(n(params, 0, 1) - 1),
'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),
'J' => self.grid.erase_display(raw(params, 0)),
'K' => self.grid.erase_line(raw(params, 0)),
'@' => self.grid.insert_chars(n(params, 0, 1)),
'P' => self.grid.delete_chars(n(params, 0, 1)),
'L' => self.grid.insert_lines(n(params, 0, 1)),
'M' => self.grid.delete_lines(n(params, 0, 1)),
'X' => self.grid.erase_chars(n(params, 0, 1)),
'S' => self.grid.scroll_up(n(params, 0, 1)),
'T' => self.grid.scroll_down(n(params, 0, 1)),
'm' => self.sgr(params),
'r' => {
let top = n(params, 0, 1) - 1;
let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {
Some(0) | None => self.grid.rows() - 1,
Some(v) => (v as usize).saturating_sub(1),
};
self.grid.set_scroll_region(top, bottom);
}
'h' => self.set_mode(params, private, true),
'l' => self.set_mode(params, private, false),
'c' => self.device_attrs(match intermediates.first() {
Some(b'>') => DaLevel::Secondary,
Some(b'=') => DaLevel::Tertiary,
_ => DaLevel::Primary,
}),
'q' if intermediates.first() == Some(&b'>') => self.report_version(),
'q' if intermediates.first() == Some(&b' ') => {
let code = raw(params, 0);
self.grid.set_cursor_shape(match code {
3 | 4 => CursorShape::Underline,
5 | 6 => CursorShape::Beam,
_ => CursorShape::Block,
});
// Even codes are steady; 0/1 and other odd codes blink.
self.grid.set_cursor_blink(code == 0 || code % 2 == 1);
}
'p' if intermediates.contains(&b'$') => self.report_mode(params, private),
'n' => self.device_status(params),
's' => self.grid.save_cursor(),
// `CSI u` is SCORC, but the kitty keyboard protocol overloads it with
// private prefixes: `?` query, `>` push, `<` pop, `=` set flags.
'u' => match intermediates.first() {
Some(b'?') => self.report_kitty_flags(),
Some(b'>') => self.grid.kitty_push(n(params, 0, 0) as u8),
Some(b'<') => self.grid.kitty_pop(n(params, 0, 1)),
Some(b'=') => self
.grid
.kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),
_ => self.grid.restore_cursor(),
},
't' => self.title_stack_op(params),
'g' => match raw(params, 0) {
3 => self.grid.clear_all_tabs(),
_ => self.grid.clear_tab(),
},
_ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),
}
}
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
match (intermediates.first().copied(), byte) {
(None, b'D') => self.grid.line_feed(),
(None, b'M') => self.grid.reverse_index(),
(None, b'E') => self.grid.next_line(),
(None, b'7') => self.grid.save_cursor(),
(None, b'8') => self.grid.restore_cursor(),
(None, b'H') => self.grid.set_tab(),
(None, b'c') => {
self.grid.reset_pen();
self.grid.set_scroll_region(0, self.grid.rows() - 1);
self.grid.set_autowrap(true);
self.grid.set_origin(false);
self.grid.erase_display(2);
self.grid.move_to(0, 0);
self.g0 = Charset::Ascii;
self.g1 = Charset::Ascii;
self.shift_out = false;
}
(Some(b'('), c) => self.g0 = charset(c),
(Some(b')'), c) => self.g1 = charset(c),
_ => {}
}
}
fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {
match params.first() {
Some(&n) if n == b"0" || n == b"2" => {
if let Some(text) = params.get(1) {
self.title = Some(String::from_utf8_lossy(text).into_owned());
}
}
// OSC 7: the shell reports its cwd as a `file://host/path` URI.
Some(&n) if n == b"7" => {
if let Some(uri) = params.get(1) {
self.cwd = file_uri_path(uri);
}
}
// OSC 133: shell-integration prompt marks (A/B/C/D, with optional
// `;key=value` attributes we ignore).
Some(&n) if n == b"133" => {
if let Some(kind) = params
.get(1)
.and_then(|p| p.first())
.and_then(|&b| prompt_kind(b))
{
self.grid.set_prompt_mark(kind);
}
}
// OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the
// link. The URI is everything after the second field, rejoined since
// a URI may itself contain ';'.
Some(&n) if n == b"8" => {
let uri_bytes = params
.get(2..)
.map(|parts| parts.join(&b';'))
.unwrap_or_default();
let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");
self.grid.set_link((!uri.is_empty()).then_some(uri));
}
// OSC 9: iTerm2-style notification (`OSC 9 ; body`).
Some(&n) if n == b"9" => {
if let Some(body) = osc_text(params.get(1)) {
self.notifications.push(Notification { title: None, body });
}
}
// OSC 777: `OSC 777 ; notify ; title ; body`.
Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {
let title = osc_text(params.get(2));
if let Some(body) = osc_text(params.get(3)) {
self.notifications.push(Notification { title, body });
}
}
// OSC 99: kitty desktop-notification protocol. We honour the common
// single-chunk form, taking the payload as the body and ignoring the
// metadata key=value field.
Some(&n) if n == b"99" => {
if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {
self.notifications.push(Notification { title: None, body });
}
}
// OSC 4: set/query palette entries (pairs of index;spec).
Some(&n) if n == b"4" => self.osc_palette(params, bell),
// OSC 104: reset palette (all, or the listed indices).
Some(&n) if n == b"104" => {
if params.len() <= 1 {
self.theme.reset_palette();
} else {
for p in &params[1..] {
if let Some(i) = parse_index(p) {
self.theme.reset_palette_index(i);
}
}
}
}
// OSC 10/11: foreground / background; OSC 110/111 reset them.
Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),
Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),
Some(&n) if n == b"110" => self.theme.reset_fg(),
Some(&n) if n == b"111" => self.theme.reset_bg(),
// OSC 17/19: selection (highlight) background / foreground.
Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),
Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),
// OSC 12: set cursor colour; OSC 112: reset to default.
Some(&n) if n == b"12" => {
self.grid
.set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));
}
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
// OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or
// `?` to query. We only touch `c` (clipboard) and `p` (primary).
Some(&n) if n == b"52" => {
let target = params.get(1).copied().unwrap_or(b"");
let data = params.get(2).copied().unwrap_or(b"");
let primary = target.first() == Some(&b'p');
if data == b"?" {
self.clipboard_ops.push(ClipboardOp::Query { primary });
} else if let Some(text) =
base64_decode(data).and_then(|b| String::from_utf8(b).ok())
{
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
}
}
_ => {}
}
}
fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {
// XTGETTCAP arrives as `DCS + q <names> ST`.
if action == 'q' && intermediates == [b'+'] {
self.xtgettcap = Some(Vec::new());
}
}
fn put(&mut self, byte: u8) {
if let Some(buf) = self.xtgettcap.as_mut() {
buf.push(byte);
}
}
fn unhook(&mut self) {
if let Some(payload) = self.xtgettcap.take() {
self.answer_xtgettcap(&payload);
}
}
}

View file

@ -0,0 +1,883 @@
use super::*;
use crate::bindings::MouseButton;
impl CompositorHandler for App {
fn scale_factor_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
factor: i32,
) {
// Integer fallback for compositors without fractional-scale-v1; ignored
// when the fractional-scale object drives the scale instead.
if self.fractional_scale.is_none() {
self.set_scale((factor.max(1) as u32) * 120);
}
}
fn transform_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: wl_output::Transform,
) {
}
fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
// The compositor is ready for another frame; `flush` will repaint if the
// grid has changed since the last present.
self.frame_pending = false;
}
fn surface_enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
}
impl WindowHandler for App {
fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {
self.exit = true;
}
fn configure(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &Window,
configure: WindowConfigure,
_serial: u32,
) {
if let (Some(w), Some(h)) = configure.new_size {
self.width = w.get();
self.height = h.get();
if let Some(vp) = &self.viewport {
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
}
}
self.focused = configure.is_activated();
self.sync_idle_inhibit();
if self.session.is_none() {
self.spawn_session();
} else {
self.resize_grid();
}
self.needs_draw = true;
}
}
impl ShmHandler for App {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
impl SeatHandler for App {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat_state
}
fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
// Clipboard/primary devices are seat-scoped, not capability-scoped.
let i = self.seat_index(&seat);
self.ensure_clipboard_devices(qh, &seat, i);
}
fn new_capability(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let i = self.seat_index(&seat);
// A pre-existing seat may reach us via its capabilities without a
// `new_seat` call, so make sure the clipboard devices are attached.
self.ensure_clipboard_devices(qh, &seat, i);
if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {
// get_keyboard_with_repeat drives key repeat off a calloop timer and
// delivers each repeat through the callback.
let loop_handle = self.loop_handle.clone();
let keyboard = self.seat_state.get_keyboard_with_repeat(
qh,
&seat,
None,
loop_handle,
Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),
);
match keyboard {
Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),
Err(err) => tracing::warn!("get keyboard: {err}"),
}
if self.seats[i].text_input.is_none()
&& let Some(mgr) = self.text_input_manager.as_ref()
{
self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));
}
}
if capability == Capability::Pointer && self.seats[i].pointer.is_none() {
match self.seat_state.get_pointer(qh, &seat) {
Ok(pointer) => {
self.seats[i].cursor_shape_device = self
.cursor_shape_manager
.as_ref()
.map(|m| m.get_shape_device(&pointer, qh));
self.seats[i].pointer = Some(pointer);
}
Err(err) => tracing::warn!("get pointer: {err}"),
}
}
if capability == Capability::Touch && self.seats[i].touch.is_none() {
match self.seat_state.get_touch(qh, &seat) {
Ok(touch) => self.seats[i].touch = Some(touch),
Err(err) => tracing::warn!("get touch: {err}"),
}
}
}
fn remove_capability(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {
return;
};
match capability {
Capability::Keyboard => {
if let Some(keyboard) = s.keyboard.take() {
keyboard.release();
}
}
Capability::Pointer => {
s.cursor_shape_device = None;
if let Some(pointer) = s.pointer.take() {
pointer.release();
}
}
Capability::Touch => {
if let Some(touch) = s.touch.take() {
touch.release();
}
self.touch_scroll = None;
}
_ => {}
}
}
fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
self.seats.retain(|s| s.seat != seat);
self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));
}
}
impl KeyboardHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
serial: u32,
_: &[u32],
_: &[Keysym],
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.focused = true;
self.sync_idle_inhibit();
self.report_focus(true);
self.needs_draw = true;
}
fn leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
) {
self.focused = false;
self.sync_idle_inhibit();
// Drop held-key state so a key released while unfocused can't leak a
// stale kitty release event later.
self.keys_down.clear();
self.report_focus(false);
self.needs_draw = true;
}
fn press_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
serial: u32,
event: KeyEvent,
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.handle_key(&event);
}
fn repeat_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
// Repeats are delivered through the get_keyboard_with_repeat callback;
// this non-calloop hook is unused.
}
fn release_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
event: KeyEvent,
) {
self.handle_key_release(&event);
}
fn update_modifiers(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
modifiers: Modifiers,
_: RawModifiers,
_: u32,
) {
self.modifiers = modifiers;
}
fn update_repeat_info(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: RepeatInfo,
) {
}
}
/// Parse a configured cursor-style name into a [`CursorShape`].
pub(super) fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {
match style? {
"block" => Some(CursorShape::Block),
"beam" | "bar" => Some(CursorShape::Beam),
"underline" => Some(CursorShape::Underline),
other => {
tracing::warn!("unknown cursor style {other:?}");
None
}
}
}
/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the
/// same length so prefix matching is unambiguous.
pub(super) fn hint_labels(n: usize) -> Vec<String> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
if n == 0 {
return Vec::new();
}
let (mut width, mut capacity) = (1usize, 26usize);
while capacity < n {
width += 1;
capacity *= 26;
}
(0..n)
.map(|i| {
let mut idx = i;
let mut chars = vec![b'a'; width];
for slot in chars.iter_mut().rev() {
*slot = ALPHABET[idx % 26];
idx /= 26;
}
String::from_utf8(chars).expect("ascii labels are valid utf-8")
})
.collect()
}
/// Map a Wayland button code to the terminal mouse base code, if reportable.
fn button_code(button: u32) -> Option<u8> {
match button {
BTN_LEFT => Some(0),
BTN_MIDDLE => Some(1),
BTN_RIGHT => Some(2),
_ => None,
}
}
/// Map a Wayland button code to a bindable [`MouseButton`].
fn mouse_button(button: u32) -> Option<MouseButton> {
match button {
BTN_LEFT => Some(MouseButton::Left),
BTN_MIDDLE => Some(MouseButton::Middle),
BTN_RIGHT => Some(MouseButton::Right),
_ => None,
}
}
impl PointerHandler for App {
fn pointer_frame(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
pointer: &wl_pointer::WlPointer,
events: &[PointerEvent],
) {
self.activate_pointer(pointer);
let cell_h = f64::from(self.renderer.metrics().height);
for event in events {
match &event.kind {
PointerEventKind::Enter { serial } => {
self.pointer_pos = event.position;
self.pointer_enter_serial = *serial;
self.update_hover(pointer);
self.pointer_drag();
}
PointerEventKind::Motion { .. } => {
self.pointer_pos = event.position;
if self.try_report_motion() {
continue;
}
if !self.selecting {
self.update_hover(pointer);
}
self.pointer_drag();
}
PointerEventKind::Press {
button,
serial,
time,
..
} => {
self.serial = *serial;
self.pointer_pos = event.position;
if let Some(code) = button_code(*button)
&& self.try_report_button(code, true)
{
self.pressed_button = Some(code);
continue;
}
// A configured `[mouse-bindings]` action (Middle defaults to
// primary paste) fires before the built-in left-drag select.
if let Some(mb) = mouse_button(*button)
&& let Some(action) = self.bindings.mouse_action(mb, self.modifiers)
{
self.dispatch_action(action);
continue;
}
if *button == BTN_LEFT {
self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);
self.pointer_press(*time);
}
}
PointerEventKind::Release { button, .. } => {
self.pointer_pos = event.position;
let code = button_code(*button);
if let Some(code) = code
&& self.try_report_button(code, false)
{
if self.pressed_button == Some(code) {
self.pressed_button = None;
}
continue;
}
if *button == BTN_LEFT {
self.maybe_open_clicked_link();
self.pointer_release(qh);
}
}
PointerEventKind::Axis { vertical, .. } => {
// Wheel notches arrive as value120 (÷120) or legacy discrete
// steps, ~3 lines each; touchpads send pixels per cell height.
let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 {
(f64::from(vertical.discrete), 3.0)
} else if cell_h > 0.0 {
(vertical.absolute / cell_h, 1.0)
} else {
continue;
};
if raw == 0.0 {
continue;
}
let mult = self.config.mouse.scroll_multiplier.max(0.0);
let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;
let up = raw < 0.0;
// Reporting apps get wheel buttons (64 up / 65 down) as
// presses, one per line, capped so a flick cannot flood.
if self.mouse_reporting() {
let code = if up { 64 } else { 65 };
for _ in 0..lines.clamp(1, 8) {
self.try_report_button(code, true);
}
continue;
}
// On the alternate screen there is no scrollback to move, so
// (when enabled) translate the wheel into cursor-key presses
// for apps that did not request mouse reporting.
let alt = self
.session
.as_ref()
.is_some_and(|s| s.term.grid().alt_active());
if alt && self.config.mouse.alternate_scroll {
self.alternate_scroll(up, lines.clamp(1, 8));
continue;
}
// Positive axis = scroll down (toward live); the viewport
// scrolls the opposite way (negative offset delta).
let delta = if up { lines } else { -lines };
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta);
self.needs_draw = true;
}
}
_ => {}
}
}
}
}
// Touch drives one-finger drag-to-scroll of the scrollback viewport. Taps and
// multi-finger gestures are ignored; only the first touch point is tracked.
impl TouchHandler for App {
fn down(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_touch::WlTouch,
_serial: u32,
_time: u32,
_surface: wl_surface::WlSurface,
id: i32,
position: (f64, f64),
) {
if self.touch_scroll.is_none() {
self.touch_scroll = Some(TouchScroll {
id,
last_y: position.1,
acc: 0.0,
});
}
}
fn up(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_touch::WlTouch,
_serial: u32,
_time: u32,
id: i32,
) {
if self.touch_scroll.as_ref().is_some_and(|t| t.id == id) {
self.touch_scroll = None;
}
}
fn motion(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_touch::WlTouch,
_time: u32,
id: i32,
position: (f64, f64),
) {
let cell_h = self.renderer.metrics().height as f64;
let Some(touch) = self.touch_scroll.as_mut().filter(|t| t.id == id) else {
return;
};
touch.acc += position.1 - touch.last_y;
touch.last_y = position.1;
// Dragging down (positive delta) reveals older lines, matching the
// viewport's "positive scrolls back" convention.
let lines = (touch.acc / cell_h) as isize;
if lines != 0 {
touch.acc -= lines as f64 * cell_h;
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(lines);
self.needs_draw = true;
}
}
}
fn shape(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_touch::WlTouch,
_: i32,
_: f64,
_: f64,
) {
}
fn orientation(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_touch::WlTouch,
_: i32,
_: f64,
) {
}
fn cancel(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_touch::WlTouch) {
self.touch_scroll = None;
}
}
impl OutputHandler for App {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
}
impl ProvidesRegistryState for App {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState, SeatState];
}
/// Serve the held clipboard text when a paste target requests it.
fn serve(text: &str, fd: WritePipe) {
let mut file = File::from(OwnedFd::from(fd));
let _ = file.write_all(text.as_bytes());
}
impl DataDeviceHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataDevice,
_: f64,
_: f64,
_: &wl_surface::WlSurface,
) {
}
fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}
fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
}
impl DataOfferHandler for App {
fn source_actions(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
fn selected_action(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
}
impl DataSourceHandler for App {
fn accept_mime(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataSource,
_: Option<String>,
) {
}
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &WlDataSource,
_mime: String,
fd: WritePipe,
) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.clipboard, fd);
}
}
fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.copy_source = None;
}
}
fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
}
impl PrimarySelectionDeviceHandler for App {
fn selection(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &ZwpPrimarySelectionDeviceV1,
) {
}
}
impl PrimarySelectionSourceHandler for App {
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
_mime: String,
fd: WritePipe,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.primary_clip, fd);
}
}
fn cancelled(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.primary_source = None;
}
}
}
// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by
// hand. Only the fractional-scale object carries an event we act on.
impl Dispatch<WpFractionalScaleV1, ()> for App {
fn event(
state: &mut Self,
_: &WpFractionalScaleV1,
event: wp_fractional_scale_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {
state.set_scale(scale);
}
}
}
impl Dispatch<WpFractionalScaleManagerV1, ()> for App {
fn event(
_: &mut Self,
_: &WpFractionalScaleManagerV1,
_: <WpFractionalScaleManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewporter, ()> for App {
fn event(
_: &mut Self,
_: &WpViewporter,
_: <WpViewporter as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewport, ()> for App {
fn event(
_: &mut Self,
_: &WpViewport,
_: <WpViewport as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
// idle-inhibit and content-type are likewise raw protocol objects; none of them
// emit events we act on.
impl Dispatch<ZwpIdleInhibitManagerV1, ()> for App {
fn event(
_: &mut Self,
_: &ZwpIdleInhibitManagerV1,
_: <ZwpIdleInhibitManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ZwpIdleInhibitorV1, ()> for App {
fn event(
_: &mut Self,
_: &ZwpIdleInhibitorV1,
_: zwp_idle_inhibitor_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpContentTypeManagerV1, ()> for App {
fn event(
_: &mut Self,
_: &WpContentTypeManagerV1,
_: <WpContentTypeManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpContentTypeV1, ()> for App {
fn event(
_: &mut Self,
_: &WpContentTypeV1,
_: wp_content_type_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl ActivationHandler for App {
type RequestData = RequestData;
fn new_token(&mut self, token: String, _: &RequestData) {
// The compositor granted an activation token; use it to draw attention.
if let Some(activation) = self.activation.as_ref() {
activation.activate::<App>(self.window.wl_surface(), token);
}
}
}
impl Dispatch<ZwpTextInputManagerV3, ()> for App {
fn event(
_: &mut Self,
_: &ZwpTextInputManagerV3,
_: <ZwpTextInputManagerV3 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the
// accumulated transaction on `done` and re-enable on focus enter.
impl Dispatch<ZwpTextInputV3, ()> for App {
fn event(
state: &mut Self,
ti: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use zwp_text_input_v3::Event;
match event {
Event::Enter { .. } => {
ti.enable();
ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);
state.ime_set_cursor_rect(ti);
ti.commit();
}
Event::Leave { .. } => {
ti.disable();
ti.commit();
state.preedit.clear();
state.ime_preedit_pending.clear();
state.ime_commit_pending.clear();
state.needs_draw = true;
}
Event::PreeditString { text, .. } => {
state.ime_preedit_pending = text.unwrap_or_default();
}
Event::CommitString { text } => {
state.ime_commit_pending.push_str(&text.unwrap_or_default());
}
Event::Done { .. } => state.ime_done(ti),
// We do not expose surrounding text, so nothing to delete.
Event::DeleteSurroundingText { .. } => {}
_ => {}
}
}
}
delegate_compositor!(App);
delegate_output!(App);
delegate_shm!(App);
delegate_seat!(App);
delegate_keyboard!(App);
delegate_pointer!(App);
delegate_touch!(App);
delegate_xdg_shell!(App);
delegate_xdg_window!(App);
delegate_data_device!(App);
delegate_primary_selection!(App);
delegate_registry!(App);
delegate_activation!(App);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,231 @@
use super::*;
/// What determines one rendered row's pixels: its cells, the cursor on it, the
/// selection span over it, and the blink phase. Two equal `RowSnap`s render
/// identically, so a buffer holding an equal snapshot needs no repaint.
#[derive(Clone, PartialEq, Debug)]
struct RowSnap {
cells: Vec<Cell>,
/// `(col, shape, focused)` when the cursor is drawn on this row.
cursor: Option<(usize, CursorShape, bool)>,
/// Inclusive selected column span on this row.
sel: Option<(usize, usize)>,
/// Search-match spans `(lo, hi, is_current)` highlighted on this row.
search: Vec<(usize, usize, bool)>,
/// Search-prompt text drawn over this row (only the bottom row, when active).
overlay: Option<String>,
/// IME preedit `(start_col, text)` drawn inline over this row (cursor row).
preedit: Option<(usize, String)>,
/// Blink phase, but only varied when the row actually has blinking ink, so
/// non-blinking rows stay equal across phase toggles.
blink: bool,
}
/// One shm buffer plus the per-row snapshot of what it currently displays.
#[derive(Debug)]
pub(super) struct FrameBuf {
buffer: Buffer,
rows: Vec<RowSnap>,
}
/// Snapshot the determinants of viewport row `y`'s pixels.
fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {
let abs = grid.view_to_abs(y);
let cells = grid.view_row(y).to_vec();
let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {
let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);
visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))
} else {
None
};
let has_blink = cells
.iter()
.any(|c| c.flags.contains(crate::grid::Flags::BLINK));
RowSnap {
cells,
cursor,
sel: grid.selection_span_on(abs),
search: grid.search_spans_on(abs),
overlay: None,
preedit: None,
blink: if has_blink { blink_on } else { true },
}
}
impl App {
/// Render only the rows that changed since the chosen buffer last displayed
/// them, damage just those rows, and commit with a frame-callback request.
pub(super) fn present(&mut self) {
self.needs_draw = false;
// URL hint labels overlay the grid but are not part of the row snapshot,
// so force a full redraw while the labels are showing.
if self.url_mode {
self.frames.clear();
}
// Render into a buffer sized in physical pixels (logical × scale); the
// viewport presents it back at the logical surface size.
let (w, h) = self.phys_dims();
let m = self.renderer.metrics();
let (focused, blink_on) = (self.focused, self.blink_on);
// A resize invalidates every buffer's contents and size.
if self.buf_dims != (w, h) {
self.frames.clear();
self.buf_dims = (w, h);
}
let Some(session) = self.session.as_ref() else {
return;
};
let grid = session.term.grid();
// The visual bell inverts fg/bg for the duration of the flash.
let flashed = self.flashing.then(|| session.term.theme().inverted());
let theme = flashed.as_ref().unwrap_or(session.term.theme());
let rows = grid.rows();
let mut cur: Vec<RowSnap> = (0..rows)
.map(|y| row_snap(grid, y, focused, blink_on))
.collect();
// The search prompt occupies the bottom row while search mode is active.
// Recording it in the snapshot keeps the row's damage/diff correct.
let bar_text = if let Some(hex) = &self.unicode_input {
Some(format!("unicode: U+{}", hex.to_uppercase()))
} else {
self.searching.then(|| {
let (n, total) = grid.search_count();
format!(
"search: {} [{n}/{total}]",
grid.search_query().unwrap_or("")
)
})
};
if let Some(text) = &bar_text
&& rows > 0
{
cur[rows - 1].overlay = Some(text.clone());
}
// The IME preedit is drawn inline at the cursor while composing.
if !self.preedit.is_empty() && grid.view_at_bottom() {
let (cx, cy) = grid.cursor();
if cy < rows {
cur[cy].preedit = Some((cx, self.preedit.clone()));
}
}
// Reuse a buffer the compositor has released, else grow the ring.
let stride = w as i32 * 4;
let mut idx = None;
for i in 0..self.frames.len() {
if self.pool.canvas(&self.frames[i].buffer).is_some() {
idx = Some(i);
break;
}
}
let idx = match idx {
Some(i) => i,
None if self.frames.len() < MAX_BUFFERS => {
match self
.pool
.create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)
{
Ok((buffer, _)) => {
self.frames.push(FrameBuf {
buffer,
rows: Vec::new(),
});
self.frames.len() - 1
}
Err(err) => {
tracing::error!("allocate shm buffer: {err}");
return;
}
}
}
// All buffers are still held by the compositor; a release event will
// wake us and `needs_draw` (re-set below) retries then.
None => {
self.needs_draw = true;
return;
}
};
// Rows that differ from what this buffer last showed (all, if fresh).
let prev = &self.frames[idx].rows;
let dirty: Vec<usize> = (0..rows)
.filter(|&y| prev.get(y) != Some(&cur[y]))
.collect();
if dirty.is_empty() {
return;
}
// A buffer used for the first time has uninitialized margins; paint the
// whole thing (background + padding) once, then damage it in full below.
let fresh = self.frames[idx].rows.is_empty();
let pad_y = self.to_phys(self.config.main.pad_y) as i32;
let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {
return;
};
let dims = (w as usize, h as usize);
let frame = crate::render::Frame {
theme,
focused,
blink_on,
hovered_link: self.hovered_link,
};
if fresh {
self.renderer.clear(canvas, dims, theme);
}
for &y in &dirty {
self.renderer.render_row(canvas, dims, grid, &frame, y);
}
// Draw the search prompt over the (now repainted) bottom row.
if let Some(text) = &bar_text
&& dirty.contains(&(rows - 1))
{
self.renderer
.render_search_bar(canvas, dims, theme, rows - 1, text);
}
// Draw the IME preedit inline over its (repainted) cursor row.
for &y in &dirty {
if let Some((col, text)) = &cur[y].preedit {
self.renderer
.render_preedit(canvas, dims, theme, y, *col, text);
}
}
// Draw URL hint labels on top, narrowing to those matching the input.
if self.url_mode {
for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {
if label.starts_with(&self.url_input) {
self.renderer
.render_label(canvas, dims, theme, hit.row, hit.col, label);
}
}
}
self.frames[idx].rows = cur;
let surface = self.window.wl_surface();
if let Err(err) = self.frames[idx].buffer.attach_to(surface) {
tracing::error!("attach buffer: {err}");
return;
}
// With a viewport the buffer is presented at the logical destination, so
// its own scale stays 1; without one, fall back to integer buffer scale.
if let Some(vp) = &self.viewport {
surface.set_buffer_scale(1);
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
} else {
surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);
}
if fresh {
surface.damage_buffer(0, 0, w as i32, h as i32);
} else {
for &y in &dirty {
let top = pad_y + y as i32 * m.height as i32;
surface.damage_buffer(0, top, w as i32, m.height as i32);
}
}
surface.frame(&self.qh, surface.clone());
self.window.commit();
self.frame_pending = true;
}
}