From e04ffc6649393709e9a9cc06c7f30fa83a712853 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 15:00:39 +0300 Subject: [PATCH] input: kitty keyboard protocol and hex codepoint entry Signed-off-by: NotAShelf Change-Id: I0f58c82752b9d7a8df35fe78f034c0be6a6a6964 --- src/bindings.rs | 3 + src/grid/mod.rs | 58 +++++++ src/input.rs | 356 +++++++++++++++++++++++++++++++++++++++ src/vt/mod.rs | 5 + src/vt/perform.rs | 12 +- src/wayland/handlers.rs | 6 +- src/wayland/mod.rs | 101 ++++++++++- src/wayland/rendering.rs | 18 +- 8 files changed, 544 insertions(+), 15 deletions(-) diff --git a/src/bindings.rs b/src/bindings.rs index cebb6de..46e3ca5 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -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. diff --git a/src/grid/mod.rs b/src/grid/mod.rs index 08defc2..d82ea33 100644 --- a/src/grid/mod.rs +++ b/src/grid/mod.rs @@ -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>, + /// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode). + kitty_current: u8, + /// Saved flag values for the kitty push/pop stack. + kitty_stack: Vec, } fn default_tabs(cols: usize) -> Vec { @@ -261,6 +265,10 @@ fn default_tabs(cols: usize) -> Vec { /// 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); } } @@ -1170,6 +1210,24 @@ impl Grid { 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); diff --git a/src/input.rs b/src/input.rs index 1603492..61907dd 100644 --- a/src/input.rs +++ b/src/input.rs @@ -55,6 +55,233 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option Option { + 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 { + 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 { + let c = keysym.key_char()?; + Some(u32::from(if c.is_ascii_uppercase() { + c.to_ascii_lowercase() + } else { + c + })) +} + +/// Build `CSI [; mod[:event]] [; text] `. 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 { + 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 = 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> { + 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!( diff --git a/src/vt/mod.rs b/src/vt/mod.rs index e4d4f24..af8ac58 100644 --- a/src/vt/mod.rs +++ b/src/vt/mod.rs @@ -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!( diff --git a/src/vt/perform.rs b/src/vt/perform.rs index b9d57b8..202f29f 100644 --- a/src/vt/perform.rs +++ b/src/vt/perform.rs @@ -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(), diff --git a/src/wayland/handlers.rs b/src/wayland/handlers.rs index 4f6a935..3877a48 100644 --- a/src/wayland/handlers.rs +++ b/src/wayland/handlers.rs @@ -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, _: &wl_keyboard::WlKeyboard, _: u32, - _: KeyEvent, + event: KeyEvent, ) { + self.handle_key_release(&event); } fn update_modifiers( diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index fd7b9dc..473ff0d 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -268,6 +268,8 @@ pub fn run(config: Config, config_path: Option) -> 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, /// 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, + /// Raw key codes currently held, to tell press from repeat for the kitty + /// keyboard protocol's event-type reporting. + keys_down: std::collections::HashSet, /// 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; + } + } } } diff --git a/src/wayland/rendering.rs b/src/wayland/rendering.rs index 04c3ac4..9c69731 100644 --- a/src/wayland/rendering.rs +++ b/src/wayland/rendering.rs @@ -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 {