forked from NotAShelf/beer
meta: split protocol codecs and encoders into a beer-protocols crate
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ib7706308c892e43d2044fbb766505e9e6a6a6964
This commit is contained in:
parent
3e49e94f56
commit
1f1451f108
30 changed files with 910 additions and 484 deletions
17
crates/beer-protocols/Cargo.toml
Normal file
17
crates/beer-protocols/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "beer-protocols"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Terminal protocol codecs and key/mouse wire encoders used by beer"
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
# Provides the keyboard vocabulary (Keysym, Modifiers, KeyEvent) the key
|
||||
# encoder consumes; the version is kept in lockstep with beer so the types
|
||||
# unify to a single definition across the workspace.
|
||||
smithay-client-toolkit = "0.20.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
212
crates/beer-protocols/README.md
Normal file
212
crates/beer-protocols/README.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# beer-protocols
|
||||
|
||||
The terminal-protocol building blocks beer is built on: byte codecs, the
|
||||
terminfo capability table, character-set translation, SGR parsing, and the
|
||||
keyboard/mouse wire encoders. Everything here is a pure function or a plain
|
||||
data type, with no terminal state, so each protocol detail can be read and
|
||||
tested on its own. beer feeds these into its `vte`-driven dispatcher and its
|
||||
grid model.
|
||||
|
||||
This README doubles as the reference for **which escape sequences, OSC
|
||||
commands, and POSIX behaviours beer actually implements** - the parts a user
|
||||
notices. Sequences not listed here are parsed and ignored rather than passed
|
||||
through. Notation: `ESC` is `0x1b`, `CSI` is `ESC [`, `OSC` is `ESC ]`, `DCS`
|
||||
is `ESC P`, `ST` is the string terminator `ESC \` (a `BEL` is also accepted to
|
||||
end an OSC). `Ps` is a numeric parameter, `Pt` a text parameter.
|
||||
|
||||
## Crate layout
|
||||
|
||||
| Module | What it covers |
|
||||
| --- | --- |
|
||||
| `codec` | base64 (OSC 52), hex (XTGETTCAP names), `file://` URI percent-decoding (OSC 7) |
|
||||
| `caps` | terminfo capabilities answered over XTGETTCAP |
|
||||
| `charset` | G0/G1 designation and DEC special-graphics line drawing |
|
||||
| `sgr` | the multi-parameter SGR colour and underline forms |
|
||||
| `key` | legacy xterm/VT and kitty keyboard-protocol key encoding |
|
||||
| `mouse` | X10 / UTF-8 / SGR mouse-report encoding |
|
||||
| `style` | the enums an SGR/DECSET stream selects |
|
||||
|
||||
---
|
||||
|
||||
## C0 control characters
|
||||
|
||||
| Byte | Name | Effect |
|
||||
| --- | --- | --- |
|
||||
| `0x07` | BEL | rings the bell (visual flash / command / urgency, per config) |
|
||||
| `0x08` | BS | backspace |
|
||||
| `0x09` | HT | tab to next stop |
|
||||
| `0x0A`-`0x0C` | LF/VT/FF | line feed |
|
||||
| `0x0D` | CR | carriage return |
|
||||
| `0x0E` | SO | invoke G1 (shift out) |
|
||||
| `0x0F` | SI | invoke G0 (shift in) |
|
||||
|
||||
## Cursor movement and editing (CSI)
|
||||
|
||||
| Sequence | Name | Effect |
|
||||
| --- | --- | --- |
|
||||
| `CSI Ps A` | CUU | cursor up |
|
||||
| `CSI Ps B` / `CSI Ps e` | CUD | cursor down |
|
||||
| `CSI Ps C` / `CSI Ps a` | CUF | cursor forward |
|
||||
| `CSI Ps D` | CUB | cursor back |
|
||||
| `CSI Ps E` | CNL | cursor down, to column 1 |
|
||||
| `CSI Ps F` | CPL | cursor up, to column 1 |
|
||||
| `CSI Ps G` / `CSI Ps \`` | CHA | move to column |
|
||||
| `CSI Ps d` | VPA | move to row |
|
||||
| `CSI Ps ; Ps H` / `f` | CUP/HVP | move to row;col |
|
||||
| `CSI Ps J` | ED | erase in display (0 below, 1 above, 2 all) |
|
||||
| `CSI Ps K` | EL | erase in line (0 right, 1 left, 2 all) |
|
||||
| `CSI Ps @` | ICH | insert blank characters |
|
||||
| `CSI Ps P` | DCH | delete characters |
|
||||
| `CSI Ps L` | IL | insert lines |
|
||||
| `CSI Ps M` | DL | delete lines |
|
||||
| `CSI Ps X` | ECH | erase characters |
|
||||
| `CSI Ps S` | SU | scroll up |
|
||||
| `CSI Ps T` | SD | scroll down |
|
||||
| `CSI Ps ; Ps r` | DECSTBM | set scroll region (top;bottom) |
|
||||
| `CSI Ps g` | TBC | clear tab stop (0) or all tabs (3) |
|
||||
| `CSI s` / `CSI u` | SCOSC/SCORC | save / restore cursor |
|
||||
|
||||
## ESC (non-CSI)
|
||||
|
||||
| Sequence | Name | Effect |
|
||||
| --- | --- | --- |
|
||||
| `ESC D` | IND | line feed |
|
||||
| `ESC M` | RI | reverse index |
|
||||
| `ESC E` | NEL | next line |
|
||||
| `ESC 7` / `ESC 8` | DECSC/DECRC | save / restore cursor |
|
||||
| `ESC H` | HTS | set tab stop |
|
||||
| `ESC c` | RIS | full reset |
|
||||
| `ESC ( c` / `ESC ) c` | SCS | designate G0 / G1 charset (`0` = DEC special graphics, `B` = ASCII) |
|
||||
|
||||
## Graphic rendition (SGR, `CSI Ps m`)
|
||||
|
||||
Bold (1), dim (2), italic (3), underline (4), blink (5/6), reverse (7), hidden
|
||||
(8), strike (9), and their resets (22-29). Overline (53) and its reset (55).
|
||||
Underline styles via `4:x` and SGR 21: single, double, curly, dotted, dashed.
|
||||
|
||||
Colours: the 16 ANSI colours (30-37, 90-97 foreground; 40-47, 100-107
|
||||
background) and default (39/49). Extended colour for foreground (38),
|
||||
background (48), and underline (58/59), in both the legacy semicolon form
|
||||
(`38;5;n`, `38;2;r;g;b`) and the colon-subparameter form (`38:5:n`,
|
||||
`38:2:r:g:b`, with an ignored colour-space id). 256-colour and 24-bit truecolor
|
||||
are fully supported.
|
||||
|
||||
## Modes (DECSET/DECRST `CSI [?] Ps h` / `l`)
|
||||
|
||||
| Mode | Name | Effect |
|
||||
| --- | --- | --- |
|
||||
| `4` (ANSI) | IRM | insert/replace |
|
||||
| `?1` | DECCKM | application cursor keys |
|
||||
| `?6` | DECOM | origin mode |
|
||||
| `?7` | DECAWM | autowrap |
|
||||
| `?25` | DECTCEM | cursor visibility |
|
||||
| `?9` | - | X10 mouse reporting |
|
||||
| `?1000` | - | normal mouse (press/release) |
|
||||
| `?1002` | - | button-event mouse (drag) |
|
||||
| `?1003` | - | any-event mouse (all motion) |
|
||||
| `?1004` | - | focus in/out reporting |
|
||||
| `?1005` | - | UTF-8 mouse coordinates |
|
||||
| `?1006` | - | SGR mouse coordinates |
|
||||
| `?47` / `?1047` | - | alternate screen |
|
||||
| `?1049` | - | alternate screen + save/restore cursor |
|
||||
| `?2004` | - | bracketed paste |
|
||||
| `?2026` | - | synchronized output |
|
||||
|
||||
Mode state is reportable with **DECRQM** (`CSI [?] Ps $ p`), which replies
|
||||
`CSI [?] Ps ; state $ y` (1 set, 2 reset, 0 unrecognized).
|
||||
|
||||
## Cursor style (DECSCUSR, `CSI Ps SP q`)
|
||||
|
||||
`0`/`1` blinking block, `2` steady block, `3` blinking underline, `4` steady
|
||||
underline, `5` blinking bar, `6` steady bar. The configured default applies
|
||||
until an application overrides it.
|
||||
|
||||
## Device reports
|
||||
|
||||
| Sequence | Name | Reply |
|
||||
| --- | --- | --- |
|
||||
| `CSI c` | DA1 | `CSI ?62;22c` (VT220 + ANSI colour) |
|
||||
| `CSI > c` | DA2 | `CSI >0;276;0c` |
|
||||
| `CSI = c` | DA3 | `DCS !\|00000000 ST` |
|
||||
| `CSI 5 n` | DSR | `CSI 0n` (terminal OK) |
|
||||
| `CSI 6 n` | CPR | `CSI row;col R` (cursor position) |
|
||||
| `CSI > q` | XTVERSION | `DCS >\|beer(version) ST` |
|
||||
| `DCS + q <names> ST` | XTGETTCAP | per name, `DCS 1 + r name=value ST` or `DCS 0 + r name ST` |
|
||||
|
||||
XTGETTCAP answers `TN` (terminal name `beer`), `Co`/`colors` (256), and `RGB`
|
||||
(`8/8/8`, i.e. truecolor).
|
||||
|
||||
## Window title
|
||||
|
||||
`OSC 0 ; Pt` and `OSC 2 ; Pt` set the title. The title stack (`CSI 22 t` push,
|
||||
`CSI 23 t` pop) is supported, so full-screen programs can save and restore it.
|
||||
|
||||
## OSC commands
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `OSC 0` / `OSC 2` | set window title |
|
||||
| `OSC 4 ; idx ; spec` | set / query palette entry (`?` queries) |
|
||||
| `OSC 104 [; idx ...]` | reset palette (all, or listed entries) |
|
||||
| `OSC 10` / `OSC 11` | set / query default foreground / background |
|
||||
| `OSC 110` / `OSC 111` | reset foreground / background |
|
||||
| `OSC 12` / `OSC 112` | set / reset cursor colour |
|
||||
| `OSC 17` / `OSC 19` | set / query selection background / foreground |
|
||||
| `OSC 7 ; file://host/path` | report working directory (used for new windows) |
|
||||
| `OSC 8 ; params ; URI` | hyperlink (empty URI ends it) |
|
||||
| `OSC 9 ; Pt` | desktop notification (iTerm2 style) |
|
||||
| `OSC 777 ; notify ; title ; body` | desktop notification (rxvt style) |
|
||||
| `OSC 99 ; metadata ; body` | desktop notification (kitty style, single-chunk) |
|
||||
| `OSC 52 ; target ; data` | clipboard set (base64) or query (`?`) |
|
||||
| `OSC 133 ; A/B/C/D` | shell-integration prompt marks |
|
||||
|
||||
Colour specs follow the X11 forms `#rrggbb` and `rgb:rr/gg/bb` (1-4 hex digits
|
||||
per channel). Colour queries reply in the `rgb:rrrr/gggg/bbbb` form.
|
||||
|
||||
### Clipboard (OSC 52)
|
||||
|
||||
`OSC 52 ; c ; <base64>` sets the clipboard, `; p ;` the primary selection;
|
||||
`; c ; ?` queries. For privacy, a query is answered from the text beer itself
|
||||
last placed there, **not** the live system clipboard - so a remote program
|
||||
cannot read what another application copied.
|
||||
|
||||
### Shell integration (OSC 133 / OSC 7)
|
||||
|
||||
Prompt marks `A` (prompt start), `B` (command start), `C` (output start), and
|
||||
`D` (command end) drive prompt-jumping and "pipe last command output". `OSC 7`
|
||||
tracks the working directory so a new window opens in the same place.
|
||||
|
||||
## Keyboard
|
||||
|
||||
The legacy xterm/VT encoding covers cursor keys (with DECCKM application mode),
|
||||
the editing keypad (Insert/Delete/PageUp/PageDown), F1-F12, and the
|
||||
xterm modifier parameter (`CSI 1 ; m <letter>`), with Alt sending an ESC
|
||||
(meta) prefix.
|
||||
|
||||
The **kitty keyboard protocol** (`CSI > flags u` push, `CSI < flags u` pop,
|
||||
`CSI = flags ; mode u` set, `CSI ? u` query) is implemented with the
|
||||
disambiguate, report-event-types, report-alternate-keys, report-all-keys, and
|
||||
report-associated-text enhancement flags. Keys are encoded as `CSI
|
||||
code[:shifted] ; mod[:event] [; text] u`, with a 32-deep push/pop stack. The
|
||||
common keys (Enter, Tab, Backspace) keep their legacy bytes when unmodified so
|
||||
a plain shell stays usable.
|
||||
|
||||
## Mouse
|
||||
|
||||
Reports are framed in the legacy byte form (`CSI M Cb Cx Cy`), the UTF-8
|
||||
coordinate form (DECSET 1005), or the SGR form (`CSI < Cb ; Cx ; Cy M/m`,
|
||||
DECSET 1006). Shift/Alt/Ctrl modifier bits and the motion bit are encoded;
|
||||
legacy releases collapse to button code 3. Reporting level is chosen by the
|
||||
application via the mouse modes above; with reporting off the pointer drives
|
||||
local selection and scrollback.
|
||||
|
||||
## POSIX / terminal behaviour
|
||||
|
||||
- A real PTY pair (`rustix` `openpt`/`grantpt`/`unlockpt`), with the child's
|
||||
`TERM` set to the configured value (default `beer`) and the window size kept
|
||||
in sync via `TIOCSWINSZ` (`SIGWINCH` reaches the child).
|
||||
- The child's exit status is propagated as beer's own exit code.
|
||||
- Alternate screen, scroll regions, autowrap and reflow on resize, and a
|
||||
scrollback buffer.
|
||||
- Bracketed paste (DECSET 2004) and synchronized output (DECSET 2026) so
|
||||
applications can paste safely and update atomically.
|
||||
25
crates/beer-protocols/src/caps.rs
Normal file
25
crates/beer-protocols/src/caps.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Terminfo capability values reported via XTGETTCAP (`DCS + q <name> ST`).
|
||||
|
||||
/// Look up a terminfo capability beer reports via XTGETTCAP, by its terminfo
|
||||
/// name. `None` means the capability is unknown (the reply is then a negative
|
||||
/// `DCS 0 + r`).
|
||||
pub fn cap_value(name: &[u8]) -> Option<&'static str> {
|
||||
match name {
|
||||
b"TN" => Some("beer"),
|
||||
b"Co" | b"colors" => Some("256"),
|
||||
b"RGB" => Some("8/8/8"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_and_unknown_caps() {
|
||||
assert_eq!(cap_value(b"TN"), Some("beer"));
|
||||
assert_eq!(cap_value(b"colors"), Some("256"));
|
||||
assert_eq!(cap_value(b"nope"), None);
|
||||
}
|
||||
}
|
||||
61
crates/beer-protocols/src/charset.rs
Normal file
61
crates/beer-protocols/src/charset.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//! G0/G1 character-set designation and the DEC special graphics (line-drawing)
|
||||
//! translation.
|
||||
|
||||
/// A designated character set (`ESC ( c` / `ESC ) c`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Charset {
|
||||
Ascii,
|
||||
DecSpecial,
|
||||
}
|
||||
|
||||
/// Map a designation byte to a [`Charset`]. `0` selects DEC special graphics;
|
||||
/// everything else falls back to ASCII.
|
||||
pub fn charset(byte: u8) -> Charset {
|
||||
match byte {
|
||||
b'0' => Charset::DecSpecial,
|
||||
_ => Charset::Ascii,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a byte under the DEC special graphics set (VT100 line drawing).
|
||||
pub fn dec_special(c: char) -> char {
|
||||
match c {
|
||||
'`' => '◆',
|
||||
'a' => '▒',
|
||||
'f' => '°',
|
||||
'g' => '±',
|
||||
'j' => '┘',
|
||||
'k' => '┐',
|
||||
'l' => '┌',
|
||||
'm' => '└',
|
||||
'n' => '┼',
|
||||
'o' => '⎺',
|
||||
'p' => '⎻',
|
||||
'q' => '─',
|
||||
'r' => '⎼',
|
||||
's' => '⎽',
|
||||
't' => '├',
|
||||
'u' => '┤',
|
||||
'v' => '┴',
|
||||
'w' => '┬',
|
||||
'x' => '│',
|
||||
'y' => '≤',
|
||||
'z' => '≥',
|
||||
'~' => '·',
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn line_drawing_translates() {
|
||||
assert_eq!(charset(b'0'), Charset::DecSpecial);
|
||||
assert_eq!(charset(b'B'), Charset::Ascii);
|
||||
assert_eq!(dec_special('q'), '─');
|
||||
assert_eq!(dec_special('x'), '│');
|
||||
assert_eq!(dec_special('A'), 'A'); // unmapped passes through
|
||||
}
|
||||
}
|
||||
154
crates/beer-protocols/src/codec.rs
Normal file
154
crates/beer-protocols/src/codec.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//! Byte codecs the escape-sequence layer leans on: base64 (OSC 52 clipboard),
|
||||
//! hex (XTGETTCAP capability names), and percent-decoding of `file://` URIs
|
||||
//! (OSC 7 working-directory reports).
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Standard base64 encode (used for OSC 52 query replies).
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [
|
||||
chunk[0],
|
||||
*chunk.get(1).unwrap_or(&0),
|
||||
*chunk.get(2).unwrap_or(&0),
|
||||
];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
out.push(B64[(n >> 18 & 63) as usize] as char);
|
||||
out.push(B64[(n >> 12 & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 {
|
||||
B64[(n >> 6 & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
B64[(n & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad
|
||||
/// character.
|
||||
pub fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Some(u32::from(c - b'A')),
|
||||
b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
|
||||
b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let filtered: Vec<u8> = data
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&c| c != b'=' && !c.is_ascii_whitespace())
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(filtered.len() / 4 * 3);
|
||||
for chunk in filtered.chunks(4) {
|
||||
if chunk.len() == 1 {
|
||||
return None; // a lone sextet cannot form a byte
|
||||
}
|
||||
let mut n = 0u32;
|
||||
for &c in chunk {
|
||||
n = (n << 6) | val(c)?;
|
||||
}
|
||||
n <<= 6 * (4 - chunk.len() as u32);
|
||||
out.push((n >> 16) as u8);
|
||||
if chunk.len() >= 3 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if chunk.len() >= 4 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Decode an even-length lowercase/uppercase hex string into bytes (XTGETTCAP
|
||||
/// names arrive hex-encoded).
|
||||
pub fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
|
||||
if s.is_empty() || !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
|
||||
s.chunks_exact(2)
|
||||
.map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.
|
||||
pub fn percent_decode(s: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while i < s.len() {
|
||||
if s[i] == b'%' && i + 2 < s.len() {
|
||||
let hi = (s[i + 1] as char).to_digit(16);
|
||||
let lo = (s[i + 2] as char).to_digit(16);
|
||||
if let (Some(hi), Some(lo)) = (hi, lo) {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(s[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding
|
||||
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
|
||||
/// `None` if it is not a usable absolute path.
|
||||
pub fn file_uri_path(uri: &[u8]) -> Option<String> {
|
||||
let rest = uri.strip_prefix(b"file://").unwrap_or(uri);
|
||||
// Skip the authority (host) up to the first '/', which begins the path.
|
||||
let slash = rest.iter().position(|&b| b == b'/')?;
|
||||
let path_bytes = percent_decode(&rest[slash..]);
|
||||
let path = String::from_utf8(path_bytes).ok()?;
|
||||
path.starts_with('/').then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_round_trips() {
|
||||
for s in [
|
||||
"",
|
||||
"f",
|
||||
"fo",
|
||||
"foo",
|
||||
"foob",
|
||||
"fooba",
|
||||
"foobar",
|
||||
"hi there\n",
|
||||
] {
|
||||
let enc = base64_encode(s.as_bytes());
|
||||
assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));
|
||||
}
|
||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||
assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_hex_rejects_odd_and_nonhex() {
|
||||
assert_eq!(decode_hex(b"544e").as_deref(), Some(&b"TN"[..]));
|
||||
assert_eq!(decode_hex(b"54e"), None);
|
||||
assert_eq!(decode_hex(b"zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_decodes_percent_and_drops_host() {
|
||||
assert_eq!(
|
||||
file_uri_path(b"file://hermes/home/user/my%20dir").as_deref(),
|
||||
Some("/home/user/my dir")
|
||||
);
|
||||
assert_eq!(file_uri_path(b"file://host").as_deref(), None);
|
||||
}
|
||||
}
|
||||
578
crates/beer-protocols/src/key.rs
Normal file
578
crates/beer-protocols/src/key.rs
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
//! Keyboard encoding: translate decoded key events into the byte sequences a
|
||||
//! terminal application expects, in both the legacy xterm/VT form and the kitty
|
||||
//! keyboard progressive-enhancement form.
|
||||
|
||||
use smithay_client_toolkit::seat::keyboard::{KeyEvent, Keysym, Modifiers};
|
||||
|
||||
/// Encode a key press into bytes for the PTY in the legacy xterm/VT form, or
|
||||
/// `None` if it produces no input. `app_cursor` selects the SS3 cursor-key form
|
||||
/// (DECCKM).
|
||||
pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec<u8>> {
|
||||
let seq = match event.keysym {
|
||||
Keysym::Return | Keysym::KP_Enter => prefix_alt(b"\r".to_vec(), mods),
|
||||
Keysym::BackSpace => prefix_alt(b"\x7f".to_vec(), mods),
|
||||
Keysym::Tab if mods.shift => b"\x1b[Z".to_vec(),
|
||||
Keysym::Tab => b"\t".to_vec(),
|
||||
Keysym::Escape => b"\x1b".to_vec(),
|
||||
|
||||
Keysym::Up => csi_letter(b'A', mods, app_cursor),
|
||||
Keysym::Down => csi_letter(b'B', mods, app_cursor),
|
||||
Keysym::Right => csi_letter(b'C', mods, app_cursor),
|
||||
Keysym::Left => csi_letter(b'D', mods, app_cursor),
|
||||
Keysym::Home => csi_letter(b'H', mods, app_cursor),
|
||||
Keysym::End => csi_letter(b'F', mods, app_cursor),
|
||||
|
||||
Keysym::Insert => csi_tilde(2, mods),
|
||||
Keysym::Delete => csi_tilde(3, mods),
|
||||
Keysym::Page_Up => csi_tilde(5, mods),
|
||||
Keysym::Page_Down => csi_tilde(6, mods),
|
||||
|
||||
// F1-F4 are SS3-introduced; F5+ use the CSI tilde forms.
|
||||
Keysym::F1 => fkey(b'P', mods),
|
||||
Keysym::F2 => fkey(b'Q', mods),
|
||||
Keysym::F3 => fkey(b'R', mods),
|
||||
Keysym::F4 => fkey(b'S', mods),
|
||||
Keysym::F5 => csi_tilde(15, mods),
|
||||
Keysym::F6 => csi_tilde(17, mods),
|
||||
Keysym::F7 => csi_tilde(18, mods),
|
||||
Keysym::F8 => csi_tilde(19, mods),
|
||||
Keysym::F9 => csi_tilde(20, mods),
|
||||
Keysym::F10 => csi_tilde(21, mods),
|
||||
Keysym::F11 => csi_tilde(23, mods),
|
||||
Keysym::F12 => csi_tilde(24, mods),
|
||||
|
||||
// Everything else: the xkb-composed text (which already folds in Ctrl),
|
||||
// with Alt sending an ESC prefix (meta).
|
||||
_ => {
|
||||
let text = event.utf8.as_ref()?;
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
prefix_alt(text.as_bytes().to_vec(), mods)
|
||||
}
|
||||
};
|
||||
Some(seq)
|
||||
}
|
||||
|
||||
/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum KeyKind {
|
||||
Press = 1,
|
||||
Repeat = 2,
|
||||
Release = 3,
|
||||
}
|
||||
|
||||
/// How a functional (non-text) key is encoded in the kitty protocol.
|
||||
enum Func {
|
||||
/// `CSI number ... u`.
|
||||
Number(u32),
|
||||
/// `CSI number ... ~`.
|
||||
Tilde(u32),
|
||||
/// `CSI 1 ... LETTER`.
|
||||
Letter(u8),
|
||||
}
|
||||
|
||||
/// Map a keysym to its kitty functional encoding, or `None` for a text key
|
||||
/// (whose code is its Unicode codepoint).
|
||||
fn functional(keysym: Keysym) -> Option<Func> {
|
||||
Some(match keysym {
|
||||
Keysym::Escape => Func::Number(27),
|
||||
Keysym::Return | Keysym::KP_Enter => Func::Number(13),
|
||||
Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),
|
||||
Keysym::BackSpace => Func::Number(127),
|
||||
Keysym::Insert => Func::Tilde(2),
|
||||
Keysym::Delete => Func::Tilde(3),
|
||||
Keysym::Page_Up => Func::Tilde(5),
|
||||
Keysym::Page_Down => Func::Tilde(6),
|
||||
Keysym::Up => Func::Letter(b'A'),
|
||||
Keysym::Down => Func::Letter(b'B'),
|
||||
Keysym::Right => Func::Letter(b'C'),
|
||||
Keysym::Left => Func::Letter(b'D'),
|
||||
Keysym::Home => Func::Letter(b'H'),
|
||||
Keysym::End => Func::Letter(b'F'),
|
||||
Keysym::F1 => Func::Letter(b'P'),
|
||||
Keysym::F2 => Func::Letter(b'Q'),
|
||||
Keysym::F3 => Func::Tilde(13),
|
||||
Keysym::F4 => Func::Letter(b'S'),
|
||||
Keysym::F5 => Func::Tilde(15),
|
||||
Keysym::F6 => Func::Tilde(17),
|
||||
Keysym::F7 => Func::Tilde(18),
|
||||
Keysym::F8 => Func::Tilde(19),
|
||||
Keysym::F9 => Func::Tilde(20),
|
||||
Keysym::F10 => Func::Tilde(21),
|
||||
Keysym::F11 => Func::Tilde(23),
|
||||
Keysym::F12 => Func::Tilde(24),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kitty key code for a lone modifier key, reported only in report-all mode.
|
||||
fn modifier_key(keysym: Keysym) -> Option<u32> {
|
||||
Some(match keysym {
|
||||
Keysym::Caps_Lock => 57358,
|
||||
Keysym::Num_Lock => 57360,
|
||||
Keysym::Shift_L => 57441,
|
||||
Keysym::Control_L => 57442,
|
||||
Keysym::Alt_L => 57443,
|
||||
Keysym::Super_L => 57444,
|
||||
Keysym::Shift_R => 57447,
|
||||
Keysym::Control_R => 57448,
|
||||
Keysym::Alt_R => 57449,
|
||||
Keysym::Super_R => 57450,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8); the wire
|
||||
/// parameter is `1 + this`.
|
||||
fn kitty_mod_bits(mods: Modifiers) -> u32 {
|
||||
u32::from(mods.shift)
|
||||
| (u32::from(mods.alt) << 1)
|
||||
| (u32::from(mods.ctrl) << 2)
|
||||
| (u32::from(mods.logo) << 3)
|
||||
}
|
||||
|
||||
/// The un-shifted Unicode codepoint of a text key (always the lowercase form,
|
||||
/// per the protocol). `None` if the key produces no character.
|
||||
fn base_codepoint(keysym: Keysym) -> Option<u32> {
|
||||
let c = keysym.key_char()?;
|
||||
Some(u32::from(if c.is_ascii_uppercase() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
c
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier
|
||||
/// section is emitted when modifiers/event/text require it; the event sub-field
|
||||
/// only when the event is not a plain press.
|
||||
fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {
|
||||
let mut s = String::from("\x1b[");
|
||||
s.push_str(&field);
|
||||
let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();
|
||||
if needs_mods {
|
||||
s.push(';');
|
||||
s.push_str(&mod_param.to_string());
|
||||
if event != KeyKind::Press {
|
||||
s.push(':');
|
||||
s.push_str(&(event as u8).to_string());
|
||||
}
|
||||
}
|
||||
if let Some(text) = text {
|
||||
s.push(';');
|
||||
let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();
|
||||
s.push_str(&codes.join(":"));
|
||||
}
|
||||
s.push(char::from(term));
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// Encode a key event under the kitty keyboard protocol with the given active
|
||||
/// `flags`. Returns `None` when the event produces nothing (e.g. a text key
|
||||
/// release without report-all mode). `app_cursor` only affects unmodified
|
||||
/// cursor keys, matching legacy behaviour.
|
||||
pub fn kitty_encode(
|
||||
event: &KeyEvent,
|
||||
mods: Modifiers,
|
||||
flags: u8,
|
||||
kind: KeyKind,
|
||||
app_cursor: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let report_all = flags & 0b1000 != 0;
|
||||
let report_events = flags & 0b10 != 0;
|
||||
let report_alt = flags & 0b100 != 0;
|
||||
let report_text = flags & 0b1_0000 != 0;
|
||||
|
||||
let bits = kitty_mod_bits(mods);
|
||||
let mod_param = bits + 1;
|
||||
// Events other than a press are only reported when asked for.
|
||||
let events_ok = report_events || report_all;
|
||||
let kind = if events_ok { kind } else { KeyKind::Press };
|
||||
|
||||
// Lone modifier keys: only in report-all mode.
|
||||
if let Some(code) = modifier_key(event.keysym) {
|
||||
if !report_all {
|
||||
return None;
|
||||
}
|
||||
return Some(csi(code.to_string(), mod_param, kind, None, b'u'));
|
||||
}
|
||||
|
||||
match functional(event.keysym) {
|
||||
Some(func) => {
|
||||
// Enter/Tab/Backspace keep legacy bytes when unmodified and not in
|
||||
// report-all mode, so a shell stays usable.
|
||||
let legacy_special = matches!(
|
||||
event.keysym,
|
||||
Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace
|
||||
);
|
||||
if legacy_special && !report_all && bits == 0 {
|
||||
if kind != KeyKind::Press {
|
||||
return None;
|
||||
}
|
||||
return Some(match event.keysym {
|
||||
Keysym::BackSpace => b"\x7f".to_vec(),
|
||||
Keysym::Tab => b"\t".to_vec(),
|
||||
_ => b"\r".to_vec(),
|
||||
});
|
||||
}
|
||||
if kind != KeyKind::Press && !events_ok {
|
||||
return None;
|
||||
}
|
||||
Some(match func {
|
||||
Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),
|
||||
Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),
|
||||
Func::Letter(l) => {
|
||||
if mod_param == 1 && kind == KeyKind::Press {
|
||||
// Unmodified: legacy CSI/SS3 form, honouring app-cursor.
|
||||
vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]
|
||||
} else {
|
||||
csi("1".to_string(), mod_param, kind, None, l)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
None => {
|
||||
let cp = base_codepoint(event.keysym)?;
|
||||
if report_all {
|
||||
if cp == 0 {
|
||||
return None;
|
||||
}
|
||||
let text = if report_text {
|
||||
event
|
||||
.utf8
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let field = alt_field(cp, event.keysym, mods, report_alt);
|
||||
return Some(csi(field, mod_param, kind, text, b'u'));
|
||||
}
|
||||
// Without report-all, text keys only report presses.
|
||||
if kind != KeyKind::Press {
|
||||
return None;
|
||||
}
|
||||
// Plain or shift-only keys send their text; ctrl/alt/super → CSI u.
|
||||
if bits & !1 == 0 {
|
||||
let text = event.utf8.as_ref()?;
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(text.as_bytes().to_vec());
|
||||
}
|
||||
let field = alt_field(cp, event.keysym, mods, report_alt);
|
||||
Some(csi(field, mod_param, KeyKind::Press, None, b'u'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key-code field, with the shifted alternate appended (`code:shifted`) when
|
||||
/// alternate-key reporting is on and shift is held.
|
||||
fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {
|
||||
if report_alt
|
||||
&& mods.shift
|
||||
&& let Some(shifted) = keysym.key_char().map(u32::from)
|
||||
&& shifted != cp
|
||||
{
|
||||
return format!("{cp}:{shifted}");
|
||||
}
|
||||
cp.to_string()
|
||||
}
|
||||
|
||||
fn prefix_alt(bytes: Vec<u8>, mods: Modifiers) -> Vec<u8> {
|
||||
if mods.alt {
|
||||
let mut out = Vec::with_capacity(bytes.len() + 1);
|
||||
out.push(0x1b);
|
||||
out.extend_from_slice(&bytes);
|
||||
out
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// xterm modifier parameter: 1 + a bitfield of the held modifiers.
|
||||
fn modifier_param(mods: Modifiers) -> u8 {
|
||||
1 + u8::from(mods.shift)
|
||||
+ (u8::from(mods.alt) << 1)
|
||||
+ (u8::from(mods.ctrl) << 2)
|
||||
+ (u8::from(mods.logo) << 3)
|
||||
}
|
||||
|
||||
/// Cursor/edit keys: `ESC [ X` (or `ESC O X` in application-cursor mode), and
|
||||
/// `ESC [ 1 ; m X` when modifiers are held.
|
||||
fn csi_letter(final_byte: u8, mods: Modifiers, app_cursor: bool) -> Vec<u8> {
|
||||
let m = modifier_param(mods);
|
||||
if m == 1 {
|
||||
vec![0x1b, if app_cursor { b'O' } else { b'[' }, final_byte]
|
||||
} else {
|
||||
let mut v = format!("\x1b[1;{m}").into_bytes();
|
||||
v.push(final_byte);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Keypad-style keys: `ESC [ n ~`, with `ESC [ n ; m ~` when modifiers are held.
|
||||
fn csi_tilde(n: u8, mods: Modifiers) -> Vec<u8> {
|
||||
let m = modifier_param(mods);
|
||||
if m == 1 {
|
||||
format!("\x1b[{n}~").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[{n};{m}~").into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn fkey(final_byte: u8, mods: Modifiers) -> Vec<u8> {
|
||||
let m = modifier_param(mods);
|
||||
if m == 1 {
|
||||
vec![0x1b, b'O', final_byte]
|
||||
} else {
|
||||
let mut v = format!("\x1b[1;{m}").into_bytes();
|
||||
v.push(final_byte);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(keysym: Keysym, utf8: Option<&str>) -> KeyEvent {
|
||||
KeyEvent {
|
||||
time: 0,
|
||||
raw_code: 0,
|
||||
keysym,
|
||||
utf8: utf8.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
const NONE: Modifiers = Modifiers {
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
caps_lock: false,
|
||||
logo: false,
|
||||
num_lock: false,
|
||||
};
|
||||
|
||||
fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {
|
||||
Modifiers {
|
||||
ctrl,
|
||||
shift,
|
||||
alt,
|
||||
..NONE
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_passes_through() {
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::a, Some("a")), NONE, false),
|
||||
Some(b"a".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_prefixes_escape() {
|
||||
let m = Modifiers { alt: true, ..NONE };
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::a, Some("a")), m, false),
|
||||
Some(b"\x1ba".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_respect_application_mode() {
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::Up, None), NONE, false),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::Up, None), NONE, true),
|
||||
Some(b"\x1bOA".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_arrow_uses_csi_param() {
|
||||
let m = Modifiers { ctrl: true, ..NONE };
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::Right, None), m, false),
|
||||
Some(b"\x1b[1;5C".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_plain_text_key_stays_text() {
|
||||
// Disambiguate only: an unmodified letter is still sent as text.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"a".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_ctrl_letter_is_csi_u() {
|
||||
// ctrl+a → CSI 97 ; 5 u (5 = 1 + ctrl).
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::a, None),
|
||||
mods(true, false, false),
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97;5u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_escape_disambiguated() {
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[27u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_associated_text_and_alternate() {
|
||||
// shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.
|
||||
let flags = 0b1_1001; // disambiguate | report-all | associated-text
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::A, Some("A")),
|
||||
mods(false, true, false),
|
||||
flags,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97;2;65u".to_vec())
|
||||
);
|
||||
// ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::A, None),
|
||||
mods(true, true, false),
|
||||
0b101,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97:65;6u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_release_only_with_report_all() {
|
||||
let release = |flags| {
|
||||
kitty_encode(
|
||||
&key(Keysym::a, None),
|
||||
mods(true, false, false),
|
||||
flags,
|
||||
KeyKind::Release,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// Event reporting alone does not report text-key release.
|
||||
assert_eq!(release(0b11), None);
|
||||
// Report-all does: CSI 97 ; 5 : 3 u.
|
||||
assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_lock_keys_do_not_leak() {
|
||||
let numlock = Modifiers {
|
||||
num_lock: true,
|
||||
..NONE
|
||||
};
|
||||
// Num lock on must not turn unmodified Enter into a CSI u sequence.
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::Return, None),
|
||||
numlock,
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
// Nor pollute the modifier parameter of a real chord.
|
||||
let ctrl_numlock = Modifiers {
|
||||
ctrl: true,
|
||||
num_lock: true,
|
||||
..NONE
|
||||
};
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::a, None),
|
||||
ctrl_numlock,
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97;5u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_functional_keys() {
|
||||
// Unmodified arrow keeps the legacy form (app-cursor honoured).
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),
|
||||
Some(b"\x1bOA".to_vec())
|
||||
);
|
||||
// Modified arrow → CSI 1 ; mods LETTER.
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::Up, None),
|
||||
mods(true, false, false),
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[1;5A".to_vec())
|
||||
);
|
||||
// F5 keeps its tilde form.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[15~".to_vec())
|
||||
);
|
||||
// Unmodified Enter is still a carriage return; report-all makes it CSI u.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::Return, None),
|
||||
NONE,
|
||||
0b1001,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[13u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_keys() {
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::Return, None), NONE, false),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::BackSpace, None), NONE, false),
|
||||
Some(b"\x7f".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::Delete, None), NONE, false),
|
||||
Some(b"\x1b[3~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode(&key(Keysym::F5, None), NONE, false),
|
||||
Some(b"\x1b[15~".to_vec())
|
||||
);
|
||||
}
|
||||
}
|
||||
36
crates/beer-protocols/src/lib.rs
Normal file
36
crates/beer-protocols/src/lib.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! Terminal protocol building blocks for [beer](https://github.com/NotAShelf/beer).
|
||||
//!
|
||||
//! This crate gathers the self-contained pieces of beer's terminal-protocol
|
||||
//! support: the byte codecs an escape stream needs, the terminfo capability
|
||||
//! table, character-set translation, SGR colour/underline parsing, and the
|
||||
//! keyboard/mouse wire encoders. It deliberately holds no terminal state (no
|
||||
//! grid, no parser loop): every item here is a pure function or a plain data
|
||||
//! type, so each protocol detail can be read and tested on its own. beer wires
|
||||
//! these into its `vte`-driven dispatcher and its [`Grid`] model.
|
||||
//!
|
||||
//! The modules map onto the protocols a terminal user cares about:
|
||||
//!
|
||||
//! - [`codec`] - base64 (OSC 52 clipboard), hex (XTGETTCAP), and `file://` URI
|
||||
//! percent-decoding (OSC 7).
|
||||
//! - [`caps`] - the terminfo capabilities answered over XTGETTCAP.
|
||||
//! - [`charset`] - G0/G1 designation and DEC special-graphics line drawing.
|
||||
//! - [`sgr`] - the multi-parameter SGR colour and underline forms.
|
||||
//! - [`key`] - legacy xterm/VT and kitty keyboard-protocol key encoding.
|
||||
//! - [`mouse`] - X10/UTF-8/SGR mouse-report encoding.
|
||||
//! - [`style`] - the enums an SGR/DECSET stream selects (colour, underline,
|
||||
//! cursor shape, mouse protocol/encoding, shell-integration prompt marks).
|
||||
//!
|
||||
//! See `README.md` for the full inventory of escape sequences, OSC commands,
|
||||
//! and POSIX behaviour beer implements.
|
||||
|
||||
pub mod caps;
|
||||
pub mod charset;
|
||||
pub mod codec;
|
||||
pub mod key;
|
||||
pub mod mouse;
|
||||
pub mod sgr;
|
||||
pub mod style;
|
||||
|
||||
pub use style::{
|
||||
Color, CursorShape, MouseEncoding, MouseProtocol, PromptKind, Underline, prompt_kind,
|
||||
};
|
||||
106
crates/beer-protocols/src/mouse.rs
Normal file
106
crates/beer-protocols/src/mouse.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Mouse-event encoding: frame a button/motion report in the wire form the
|
||||
//! application selected (legacy byte form, UTF-8, or SGR).
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use smithay_client_toolkit::seat::keyboard::Modifiers;
|
||||
|
||||
use crate::style::MouseEncoding;
|
||||
|
||||
/// Encode a mouse event for the application. `button` is the base code (0/1/2
|
||||
/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based
|
||||
/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.
|
||||
pub fn encode_mouse(
|
||||
encoding: MouseEncoding,
|
||||
button: u8,
|
||||
col: usize,
|
||||
row: usize,
|
||||
pressed: bool,
|
||||
motion: bool,
|
||||
mods: Modifiers,
|
||||
) -> Vec<u8> {
|
||||
let mod_bits =
|
||||
(u8::from(mods.shift) << 2) | (u8::from(mods.alt) << 3) | (u8::from(mods.ctrl) << 4);
|
||||
let motion_bit = if motion { 32 } else { 0 };
|
||||
let mut out = Vec::new();
|
||||
match encoding {
|
||||
MouseEncoding::Sgr => {
|
||||
let cb = u32::from(button) + u32::from(motion_bit) + u32::from(mod_bits);
|
||||
let final_byte = if pressed { 'M' } else { 'm' };
|
||||
let _ = write!(out, "\x1b[<{cb};{};{}{final_byte}", col + 1, row + 1);
|
||||
}
|
||||
enc => {
|
||||
// Legacy form collapses every button release to code 3.
|
||||
let base = if !pressed && button <= 2 { 3 } else { button };
|
||||
let cb = 32 + base + motion_bit + mod_bits;
|
||||
out.extend_from_slice(b"\x1b[M");
|
||||
push_coord(&mut out, u32::from(cb), false);
|
||||
let utf8 = enc == MouseEncoding::Utf8;
|
||||
push_coord(&mut out, col as u32 + 33, utf8);
|
||||
push_coord(&mut out, row as u32 + 33, utf8);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Push one legacy mouse coordinate byte, UTF-8 encoding values above 127 when
|
||||
/// the extended (1005) mode is active, and clamping otherwise.
|
||||
fn push_coord(out: &mut Vec<u8>, value: u32, utf8: bool) {
|
||||
if utf8 {
|
||||
let v = value.min(0x7ff);
|
||||
if v >= 0x80 {
|
||||
out.push(0xc0 | (v >> 6) as u8);
|
||||
out.push(0x80 | (v & 0x3f) as u8);
|
||||
} else {
|
||||
out.push(v as u8);
|
||||
}
|
||||
} else {
|
||||
out.push(value.min(255) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NONE: Modifiers = Modifiers {
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
caps_lock: false,
|
||||
logo: false,
|
||||
num_lock: false,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mouse_sgr_press_and_release() {
|
||||
// Left press at column 3, row 5 (0-based) -> SGR with 1-based coords.
|
||||
assert_eq!(
|
||||
encode_mouse(MouseEncoding::Sgr, 0, 3, 5, true, false, NONE),
|
||||
b"\x1b[<0;4;6M".to_vec()
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse(MouseEncoding::Sgr, 0, 3, 5, false, false, NONE),
|
||||
b"\x1b[<0;4;6m".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_legacy_release_collapses_to_three() {
|
||||
// Legacy release: button bits become 3; values offset by 32.
|
||||
assert_eq!(
|
||||
encode_mouse(MouseEncoding::X10, 0, 0, 0, false, false, NONE),
|
||||
vec![0x1b, b'[', b'M', 32 + 3, 33, 33]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_motion_and_modifiers_set_bits() {
|
||||
let mods = Modifiers { ctrl: true, ..NONE };
|
||||
// Drag with the left button and ctrl held: 0 + 32(motion) + 16(ctrl).
|
||||
assert_eq!(
|
||||
encode_mouse(MouseEncoding::Sgr, 0, 0, 0, true, true, mods),
|
||||
b"\x1b[<48;1;1M".to_vec()
|
||||
);
|
||||
}
|
||||
}
|
||||
97
crates/beer-protocols/src/sgr.rs
Normal file
97
crates/beer-protocols/src/sgr.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//! SGR (Select Graphic Rendition) parsing: the colour and underline forms that
|
||||
//! need more than a single parameter. Plain attribute toggles (bold, reverse,
|
||||
//! ...) stay in the dispatcher; what lives here is the multi-parameter parsing
|
||||
//! that benefits from being testable in isolation.
|
||||
|
||||
use crate::style::{Color, Underline};
|
||||
|
||||
/// Map an SGR 4 parameter (`4` or `4:x`) to an underline style.
|
||||
pub fn underline_from(param: &[u16]) -> Underline {
|
||||
match param.get(1).copied().unwrap_or(1) {
|
||||
0 => Underline::None,
|
||||
2 => Underline::Double,
|
||||
3 => Underline::Curly,
|
||||
4 => Underline::Dotted,
|
||||
5 => Underline::Dashed,
|
||||
_ => Underline::Single,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an SGR 38/48/58 extended colour, given the full parameter list and the
|
||||
/// index of the introducer. Returns the colour and how many top-level
|
||||
/// parameters it consumed (1 for the colon-subparameter form, more for the
|
||||
/// legacy semicolon form).
|
||||
pub fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {
|
||||
let head = items[i];
|
||||
if head.len() >= 2 {
|
||||
return (color_from_subparams(&head[1..]), 1);
|
||||
}
|
||||
match items.get(i + 1).and_then(|s| s.first().copied()) {
|
||||
Some(5) => {
|
||||
let idx = items
|
||||
.get(i + 2)
|
||||
.and_then(|s| s.first().copied())
|
||||
.unwrap_or(0);
|
||||
(Some(Color::Indexed(idx as u8)), 3)
|
||||
}
|
||||
Some(2) => {
|
||||
let get = |k: usize| {
|
||||
items
|
||||
.get(i + k)
|
||||
.and_then(|s| s.first().copied())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
(
|
||||
Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),
|
||||
5,
|
||||
)
|
||||
}
|
||||
_ => (None, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the colon-subparameter colour form: `5:idx` (indexed) or `2[:cs]:r:g:b`
|
||||
/// (direct RGB, with an optional colour-space id that is ignored).
|
||||
fn color_from_subparams(sub: &[u16]) -> Option<Color> {
|
||||
match sub.first().copied() {
|
||||
Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),
|
||||
Some(2) => {
|
||||
// Either `2:r:g:b` or `2:colorspace:r:g:b`.
|
||||
let rgb = if sub.len() >= 5 {
|
||||
&sub[2..5]
|
||||
} else {
|
||||
&sub[1..]
|
||||
};
|
||||
match rgb {
|
||||
[r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn underline_styles() {
|
||||
assert_eq!(underline_from(&[4]), Underline::Single);
|
||||
assert_eq!(underline_from(&[4, 3]), Underline::Curly);
|
||||
assert_eq!(underline_from(&[4, 0]), Underline::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_colour_semicolon_and_colon() {
|
||||
// `38;2;10;20;30` direct RGB.
|
||||
let items: Vec<&[u16]> = vec![&[38], &[2], &[10], &[20], &[30]];
|
||||
assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(10, 20, 30)), 5));
|
||||
// `38:2:40:50:60` colon subparameters.
|
||||
let items: Vec<&[u16]> = vec![&[38, 2, 40, 50, 60]];
|
||||
assert_eq!(ext_color(&items, 0), (Some(Color::Rgb(40, 50, 60)), 1));
|
||||
// `38;5;1` indexed.
|
||||
let items: Vec<&[u16]> = vec![&[38], &[5], &[1]];
|
||||
assert_eq!(ext_color(&items, 0), (Some(Color::Indexed(1)), 3));
|
||||
}
|
||||
}
|
||||
83
crates/beer-protocols/src/style.rs
Normal file
83
crates/beer-protocols/src/style.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! The protocol vocabulary: the enums an SGR/DECSET stream selects, shared by
|
||||
//! the parser, the grid model, and the renderer.
|
||||
|
||||
/// A cell colour: terminal default, a palette index, or direct RGB (SGR 30-49,
|
||||
/// 90-107, and the `38`/`48`/`58` extended forms).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Color {
|
||||
#[default]
|
||||
Default,
|
||||
Indexed(u8),
|
||||
Rgb(u8, u8, u8),
|
||||
}
|
||||
|
||||
/// Underline style (SGR 4 / 4:x / 21).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum Underline {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
Curly,
|
||||
Dotted,
|
||||
Dashed,
|
||||
}
|
||||
|
||||
/// Cursor shape (DECSCUSR, `CSI Ps SP q`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum CursorShape {
|
||||
#[default]
|
||||
Block,
|
||||
Underline,
|
||||
Beam,
|
||||
}
|
||||
|
||||
/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum MouseProtocol {
|
||||
/// No reporting; the pointer drives local selection/scroll.
|
||||
#[default]
|
||||
Off,
|
||||
/// X10 (9): button presses only.
|
||||
X10,
|
||||
/// Normal (1000): button press and release.
|
||||
Normal,
|
||||
/// Button-event (1002): press, release, and motion while a button is held.
|
||||
Button,
|
||||
/// Any-event (1003): press, release, and all pointer motion.
|
||||
Any,
|
||||
}
|
||||
|
||||
/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum MouseEncoding {
|
||||
/// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (<= 223).
|
||||
#[default]
|
||||
X10,
|
||||
/// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).
|
||||
Utf8,
|
||||
/// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).
|
||||
Sgr,
|
||||
}
|
||||
|
||||
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
|
||||
/// the start of typed command input, the start of command output, or the line
|
||||
/// where the command finished.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum PromptKind {
|
||||
PromptStart,
|
||||
CmdStart,
|
||||
OutputStart,
|
||||
CmdEnd,
|
||||
}
|
||||
|
||||
/// Map an OSC 133 mark letter (`A`/`B`/`C`/`D`) to a [`PromptKind`].
|
||||
pub fn prompt_kind(b: u8) -> Option<PromptKind> {
|
||||
match b {
|
||||
b'A' => Some(PromptKind::PromptStart),
|
||||
b'B' => Some(PromptKind::CmdStart),
|
||||
b'C' => Some(PromptKind::OutputStart),
|
||||
b'D' => Some(PromptKind::CmdEnd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
44
crates/beer/Cargo.toml
Normal file
44
crates/beer/Cargo.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[package]
|
||||
name = "beer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "A fast, software-rendered, Wayland-native terminal emulator"
|
||||
readme = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
beer-protocols = { path = "../beer-protocols" }
|
||||
calloop = { version = "0.14.4", features = ["signals"] }
|
||||
calloop-wayland-source = "0.4.1"
|
||||
fontconfig = "0.11.0"
|
||||
freetype-rs = "0.38.0"
|
||||
harfbuzz_rs_now = "2.3.2"
|
||||
lru = "0.18.0"
|
||||
pound = "0.1.6"
|
||||
rustix = { version = "1.1.4", features = [
|
||||
"pty",
|
||||
"process",
|
||||
"termios",
|
||||
"stdio",
|
||||
"fs",
|
||||
] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_ignored = "0.1.14"
|
||||
smithay-client-toolkit = "0.20.0"
|
||||
thiserror = "2.0.18"
|
||||
toml = "1.1.2"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
unicode-width = "0.2.2"
|
||||
vte = "0.15.0"
|
||||
wayland-client = "0.31.14"
|
||||
wayland-protocols = { version = "0.32.13", features = [
|
||||
"client",
|
||||
"staging",
|
||||
"unstable",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
457
crates/beer/src/bindings.rs
Normal file
457
crates/beer/src/bindings.rs
Normal 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
297
crates/beer/src/config.rs
Normal 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
664
crates/beer/src/font.rs
Normal 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(®ular.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");
|
||||
}
|
||||
}
|
||||
136
crates/beer/src/grid/links.rs
Normal file
136
crates/beer/src/grid/links.rs
Normal 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
1451
crates/beer/src/grid/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
148
crates/beer/src/grid/search.rs
Normal file
148
crates/beer/src/grid/search.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
190
crates/beer/src/grid/selection.rs
Normal file
190
crates/beer/src/grid/selection.rs
Normal 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
65
crates/beer/src/main.rs
Normal 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
117
crates/beer/src/pty.rs
Normal 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
683
crates/beer/src/render.rs
Normal 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
256
crates/beer/src/theme.rs
Normal 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
772
crates/beer/src/vt/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
250
crates/beer/src/vt/perform.rs
Normal file
250
crates/beer/src/vt/perform.rs
Normal 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 ¶ms[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
883
crates/beer/src/wayland/handlers.rs
Normal file
883
crates/beer/src/wayland/handlers.rs
Normal 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);
|
||||
1893
crates/beer/src/wayland/mod.rs
Normal file
1893
crates/beer/src/wayland/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
231
crates/beer/src/wayland/rendering.rs
Normal file
231
crates/beer/src/wayland/rendering.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue