clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit e04ffc664939
tarballunverified8 files changed+544-15
@@ -25,6 +25,7 @@ pub enum Action { JumpPromptDown, PipeCommandOutput, UrlMode,+ UnicodeInput, } impl Action {@@ -47,6 +48,7 @@ impl Action { "jump-prompt-down" => Self::JumpPromptDown, "pipe-command-output" => Self::PipeCommandOutput, "url-mode" => Self::UrlMode,+ "unicode-input" => Self::UnicodeInput, _ => return None, }) }@@ -185,6 +187,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[ ("Ctrl+Shift+Up", "jump-prompt-up"), ("Ctrl+Shift+Down", "jump-prompt-down"), ("Ctrl+Shift+O", "url-mode"),+ ("Ctrl+Shift+U", "unicode-input"), ]; /// Map a key token to a keysym: a single character, or a named special key.@@ -249,6 +249,10 @@ pub struct Grid { last_base: Option<(usize, usize)>, /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this. links: Vec<Box<str>>,+ /// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode).+ kitty_current: u8,+ /// Saved flag values for the kitty push/pop stack.+ kitty_stack: Vec<u8>, } fn default_tabs(cols: usize) -> Vec<bool> {@@ -261,6 +265,10 @@ fn default_tabs(cols: usize) -> Vec<bool> { /// one unit. const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?"; +/// Mask of the kitty-keyboard flags we implement (disambiguate, report events,+/// alternate keys, all-keys-as-escapes, associated text).+const KITTY_ALL: u8 = 0b1_1111;+ /// Whether `c` is part of a word (not whitespace, not in `delims`). fn is_word(c: char, delims: &str) -> bool { !c.is_whitespace() && !delims.contains(c)@@ -304,6 +312,38 @@ impl Grid { scrollback_cap: SCROLLBACK_CAP, last_base: None, links: Vec::new(),+ kitty_current: 0,+ kitty_stack: Vec::new(),+ }+ }++ /// The active kitty-keyboard flags (0 means legacy encoding).+ pub fn kitty_flags(&self) -> u8 {+ self.kitty_current+ }++ /// Apply `CSI = flags ; mode u`: mode 1 replaces, 2 sets bits, 3 clears bits.+ pub fn kitty_set(&mut self, flags: u8, mode: u8) {+ self.kitty_current = match mode {+ 2 => self.kitty_current | flags,+ 3 => self.kitty_current & !flags,+ _ => flags,+ } & KITTY_ALL;+ }++ /// Apply `CSI > flags u`: push the current flags and switch to `flags`.+ pub fn kitty_push(&mut self, flags: u8) {+ if self.kitty_stack.len() >= 32 {+ self.kitty_stack.remove(0);+ }+ self.kitty_stack.push(self.kitty_current);+ self.kitty_current = flags & KITTY_ALL;+ }++ /// Apply `CSI < n u`: pop `n` saved flag values, restoring the last one.+ pub fn kitty_pop(&mut self, n: usize) {+ for _ in 0..n.max(1) {+ self.kitty_current = self.kitty_stack.pop().unwrap_or(0); } } @@ -1171,6 +1211,24 @@ mod tests { use super::*; #[test]+ fn kitty_flag_stack() {+ let mut g = Grid::new(4, 2);+ assert_eq!(g.kitty_flags(), 0);+ g.kitty_set(0b1, 1); // replace+ assert_eq!(g.kitty_flags(), 0b1);+ g.kitty_set(0b100, 2); // set bits+ assert_eq!(g.kitty_flags(), 0b101);+ g.kitty_set(0b1, 3); // clear bits+ assert_eq!(g.kitty_flags(), 0b100);+ g.kitty_push(0b11); // push current, switch+ assert_eq!(g.kitty_flags(), 0b11);+ g.kitty_pop(1); // restore+ assert_eq!(g.kitty_flags(), 0b100);+ g.kitty_pop(5); // underflow is harmless+ assert_eq!(g.kitty_flags(), 0);+ }++ #[test] fn prints_and_wraps() { let mut g = Grid::new(4, 2); for c in "abcde".chars() {@@ -55,6 +55,233 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec Some(seq) } +/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.+#[derive(Clone, Copy, PartialEq, Eq)]+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, caps=64,+/// num=128); 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)+ | (u32::from(mods.caps_lock) << 6)+ | (u32::from(mods.num_lock) << 7)+}++/// 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()+}+ /// 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.@@ -252,6 +479,135 @@ mod tests { ); } + fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {+ Modifiers {+ ctrl,+ shift,+ alt,+ ..NONE+ }+ }++ #[test]+ fn kitty_plain_text_key_stays_text() {+ // Disambiguate only: an unmodified letter is still sent as text.+ 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_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!(@@ -340,6 +340,11 @@ impl Term { } } + /// 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!(@@ -82,7 +82,17 @@ impl Perform for Term { 'p' if intermediates.contains(&b'$') => self.report_mode(params, private), 'n' => self.device_status(params), 's' => self.grid.save_cursor(),- 'u' => self.grid.restore_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(),@@ -204,6 +204,9 @@ impl KeyboardHandler for App { _: u32, ) { self.focused = false;+ // 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; }@@ -239,8 +242,9 @@ impl KeyboardHandler for App { _: &QueueHandle<Self>, _: &wl_keyboard::WlKeyboard, _: u32,- _: KeyEvent,+ event: KeyEvent, ) {+ self.handle_key_release(&event); } fn update_modifiers(@@ -268,6 +268,8 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R url_hits: Vec::new(), url_labels: Vec::new(), url_input: String::new(),+ unicode_input: None,+ keys_down: std::collections::HashSet::new(), focused: true, exit: false, exit_code: ExitCode::SUCCESS,@@ -477,6 +479,11 @@ struct App { url_labels: Vec<String>, /// Label characters typed so far in URL mode. url_input: String,+ /// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.+ unicode_input: Option<String>,+ /// Raw key codes currently held, to tell press from repeat for the kitty+ /// keyboard protocol's event-type reporting.+ keys_down: std::collections::HashSet<u32>, /// Whether the toplevel currently has keyboard focus (drives the cursor). focused: bool, exit: bool,@@ -553,7 +560,19 @@ impl App { /// text bindings, else the byte encoding sent to the shell (which snaps the /// viewport back to the live screen). fn handle_key(&mut self, event: &KeyEvent) {- // URL hint mode and search both capture the keyboard while active.+ // A new arrival of a held key is a repeat; otherwise a fresh press.+ let kind = if self.keys_down.insert(event.raw_code) {+ crate::input::KeyKind::Press+ } else {+ crate::input::KeyKind::Repeat+ };++ // The Unicode-input prompt, URL hint mode, and search each capture the+ // keyboard while active.+ if self.unicode_input.is_some() {+ self.unicode_key(event);+ return;+ } if self.url_mode { self.url_key(event); return;@@ -572,11 +591,36 @@ impl App { return; } - let app_cursor = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().app_cursor());- if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) {+ let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {+ (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+ });+ let bytes = if kitty != 0 {+ crate::input::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)+ } else {+ crate::input::encode(event, self.modifiers, app_cursor)+ };+ if let Some(bytes) = bytes {+ self.send_to_shell(&bytes);+ }+ }++ /// Handle a key release: only the kitty keyboard protocol cares, and only+ /// when it has asked for event reporting.+ fn handle_key_release(&mut self, event: &KeyEvent) {+ self.keys_down.remove(&event.raw_code);+ let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {+ (s.term.grid().app_cursor(), s.term.grid().kitty_flags())+ });+ if kitty == 0 {+ return;+ }+ if let Some(bytes) = crate::input::kitty_encode(+ event,+ self.modifiers,+ kitty,+ crate::input::KeyKind::Release,+ app_cursor,+ ) { self.send_to_shell(&bytes); } }@@ -628,6 +672,51 @@ impl App { Action::JumpPromptDown => self.jump_prompt(false), Action::PipeCommandOutput => self.pipe_command_output(), Action::UrlMode => self.enter_url_mode(),+ Action::UnicodeInput => {+ self.unicode_input = Some(String::new());+ self.needs_draw = true;+ }+ }+ }++ /// Handle a key while Unicode codepoint-input mode is active: accumulate hex+ /// digits, then commit the codepoint as UTF-8 on Enter/Space.+ fn unicode_key(&mut self, event: &KeyEvent) {+ match event.keysym {+ Keysym::Escape => {+ self.unicode_input = None;+ self.needs_draw = true;+ }+ Keysym::BackSpace => {+ if let Some(buf) = self.unicode_input.as_mut() {+ buf.pop();+ }+ self.needs_draw = true;+ }+ Keysym::Return | Keysym::KP_Enter | Keysym::space => {+ let buf = self.unicode_input.take().unwrap_or_default();+ if let Some(c) = u32::from_str_radix(buf.trim(), 16)+ .ok()+ .and_then(char::from_u32)+ {+ let mut bytes = [0u8; 4];+ let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();+ self.send_to_shell(&s);+ }+ self.needs_draw = true;+ }+ _ => {+ if let Some(text) = event.utf8.as_ref() {+ let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();+ // Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.+ if let Some(buf) = self.unicode_input.as_mut()+ && buf.len() + hex.len() <= 6+ {+ buf.push_str(&hex);+ }+ self.needs_draw = true;+ }+ } } } @@ -87,13 +87,17 @@ impl App { // 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 = self.searching.then(|| {- let (n, total) = grid.search_count();- format!(- "search: {} [{n}/{total}]",- grid.search_query().unwrap_or("")- )- });+ 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 {