input: kitty keyboard protocol and hex codepoint entry

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0f58c82752b9d7a8df35fe78f034c0be6a6a6964
This commit is contained in:
raf 2026-06-25 15:00:39 +03:00
commit e04ffc6649
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
8 changed files with 544 additions and 15 deletions

View file

@ -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.

View file

@ -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);
}
}
@ -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);

View file

@ -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!(

View file

@ -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!(

View file

@ -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(),

View file

@ -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(

View file

@ -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;
}
}
}
}

View file

@ -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 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
{