brewery
notashelf /
e04ffc6649393709e9a9cc06c7f30fa83a712853

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit e04ffc664939

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:22 UTC

unverified8 files changed+544-15
input: kitty keyboard protocol and hex codepoint entry

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0f58c82752b9d7a8df35fe78f034c0be6a6a6964
diff --git a/src/bindings.rs b/src/bindings.rsindex 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.rsindex 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<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() {diff --git a/src/input.rs b/src/input.rsindex 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<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!(diff --git a/src/vt/mod.rs b/src/vt/mod.rsindex 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.rsindex 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.rsindex 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<Self>,         _: &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.rsindex 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<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;+                }+            }         }     } diff --git a/src/wayland/rendering.rs b/src/wayland/rendering.rsindex 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         {