Codex Handbook
tui/src/key_hint.rs 381 lines
//! Key binding primitives and input matching for the TUI.//!//! This module provides `KeyBinding`, the runtime representation of a single//! keybinding (key code + modifier set), along with matching logic that handles//! cross-terminal inconsistencies in how shifted letters and raw C0 control//! characters are reported.//!//! List and picker code should match navigation through these helpers instead//! of comparing `KeyEvent` values directly. The matcher owns compatibility for//! terminals that report control chords as C0 characters, while//! `is_plain_text_key_event` gives searchable pickers a shared boundary between//! text input and navigation commands.//!//! It also supplies rendering helpers that convert bindings into styled//! `ratatui::text::Span` values for UI hint display.use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;use crossterm::event::KeyModifiers;use ratatui::style::Style;use ratatui::style::Stylize;use ratatui::text::Span;#[cfg(test)]const ALT_PREFIX: &str = "⌥ + ";#[cfg(all(not(test), target_os = "macos"))]const ALT_PREFIX: &str = "⌥ + ";#[cfg(all(not(test), not(target_os = "macos")))]const ALT_PREFIX: &str = "alt + ";const CTRL_PREFIX: &str = "ctrl + ";const SHIFT_PREFIX: &str = "shift + ";/// One concrete key event that can trigger a TUI action.////// Matching via `is_press` handles exact equality plus compatibility fallbacks/// for terminals that report uppercase letters without SHIFT and Ctrl keys as/// raw C0 control characters. This means a binding defined as `shift-a` will/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF.////// This does not model multi-key chords or partial matches; callers that need/// sequences must keep that state outside this type.#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]pub(crate) struct KeyBinding {    key: KeyCode,    modifiers: KeyModifiers,}impl KeyBinding {    pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {        Self { key, modifiers }    }    pub(crate) fn from_event(event: KeyEvent) -> Self {        let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);        Self { key, modifiers }    }    pub fn is_press(&self, event: KeyEvent) -> bool {        normalize_key_parts(self.key, self.modifiers)            == normalize_key_parts(event.code, event.modifiers)            && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)    }    pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) {        (self.key, self.modifiers)    }    pub(crate) fn display_label(&self) -> String {        let modifiers = modifiers_to_string(self.modifiers);        let key = match self.key {            KeyCode::Enter => "enter".to_string(),            KeyCode::Char(' ') => "space".to_string(),            KeyCode::Up => "↑".to_string(),            KeyCode::Down => "↓".to_string(),            KeyCode::Left => "←".to_string(),            KeyCode::Right => "→".to_string(),            KeyCode::PageUp => "pgup".to_string(),            KeyCode::PageDown => "pgdn".to_string(),            _ => self.key.to_string().to_ascii_lowercase(),        };        format!("{modifiers}{key}")    }}pub(crate) fn normalize_key_parts(    key: KeyCode,    mut modifiers: KeyModifiers,) -> (KeyCode, KeyModifiers) {    let KeyCode::Char(ch) = key else {        return (key, modifiers);    };    if modifiers.is_empty()        && let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch)    {        return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);    }    if ch.is_ascii_uppercase() {        modifiers.insert(KeyModifiers::SHIFT);        return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);    }    (key, modifiers)}fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {    let code = u32::from(ch);    match code {        0x00 => Some(' '),        0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),        0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),        _ => None,    }}/// Matching helpers for one action's keybinding set.////// Implementations are expected to treat the slice as alternatives for one/// action. They should not interpret order as priority for dispatch; order is/// reserved for UI hint selection via `primary_binding`.pub(crate) trait KeyBindingListExt {    /// True when any binding in this set matches `event`.    fn is_pressed(&self, event: KeyEvent) -> bool;}impl KeyBindingListExt for [KeyBinding] {    fn is_pressed(&self, event: KeyEvent) -> bool {        self.iter().any(|binding| binding.is_press(event))    }}/// Returns whether an event should be treated as literal text input.////// Searchable pickers use this to avoid stealing plain printable characters for/// navigation when the same character might be a valid query. For example, a/// list may bind `j` and `k` for movement, but a searchable list must let/// plain `j` update the query while still allowing `Ctrl+J` to move. Calling/// this after normalizing keybindings would blur that distinction and cause/// printable search input to disappear.pub(crate) fn is_plain_text_key_event(event: KeyEvent) -> bool {    matches!(        event,        KeyEvent {            code: KeyCode::Char(ch),            modifiers,            ..        } if !ch.is_ascii_control()            && !modifiers.contains(KeyModifiers::CONTROL)            && !modifiers.contains(KeyModifiers::ALT)    )}pub(crate) const fn plain(key: KeyCode) -> KeyBinding {    KeyBinding::new(key, KeyModifiers::NONE)}pub(crate) const fn alt(key: KeyCode) -> KeyBinding {    KeyBinding::new(key, KeyModifiers::ALT)}pub(crate) const fn shift(key: KeyCode) -> KeyBinding {    KeyBinding::new(key, KeyModifiers::SHIFT)}pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding {    KeyBinding::new(key, KeyModifiers::CONTROL)}pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding {    KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))}fn modifiers_to_string(modifiers: KeyModifiers) -> String {    let mut result = String::new();    if modifiers.contains(KeyModifiers::CONTROL) {        result.push_str(CTRL_PREFIX);    }    if modifiers.contains(KeyModifiers::SHIFT) {        result.push_str(SHIFT_PREFIX);    }    if modifiers.contains(KeyModifiers::ALT) {        result.push_str(ALT_PREFIX);    }    result}impl From<KeyBinding> for Span<'static> {    fn from(binding: KeyBinding) -> Self {        (&binding).into()    }}impl From<&KeyBinding> for Span<'static> {    fn from(binding: &KeyBinding) -> Self {        Span::styled(binding.display_label(), key_hint_style())    }}fn key_hint_style() -> Style {    Style::default().dim()}pub(crate) fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {    (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)}#[cfg(windows)]#[inline]pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {    mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)}#[cfg(not(windows))]#[inline]pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {    false}#[cfg(test)]mod tests {    use super::*;    #[test]    fn is_press_accepts_press_and_repeat_but_rejects_release() {        let binding = ctrl(KeyCode::Char('k'));        let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);        let repeat = KeyEvent {            kind: KeyEventKind::Repeat,            ..press        };        let release = KeyEvent {            kind: KeyEventKind::Release,            ..press        };        let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);        assert!(binding.is_press(press));        assert!(binding.is_press(repeat));        assert!(!binding.is_press(release));        assert!(!binding.is_press(wrong_modifiers));    }    #[test]    fn keybinding_list_ext_matches_any_binding() {        let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];        assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));        assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));        assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));    }    #[test]    fn shifted_letter_binding_matches_uppercase_char_events() {        let binding = shift(KeyCode::Char('a'));        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)));        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));    }    #[test]    fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() {        let binding = KeyBinding::new(            KeyCode::Char('i'),            KeyModifiers::CONTROL | KeyModifiers::SHIFT,        );        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL)));    }    #[test]    fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() {        let binding = shift(KeyCode::Char('o'));        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)));    }    #[test]    fn ctrl_letter_binding_matches_c0_control_char_events() {        let binding = ctrl(KeyCode::Char('p'));        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)));        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));    }    #[test]    fn ctrl_bindings_match_all_supported_c0_control_char_events() {        let cases = [            (' ', '\u{0000}'),            ('a', '\u{0001}'),            ('b', '\u{0002}'),            ('c', '\u{0003}'),            ('d', '\u{0004}'),            ('e', '\u{0005}'),            ('f', '\u{0006}'),            ('g', '\u{0007}'),            ('h', '\u{0008}'),            ('i', '\u{0009}'),            ('j', '\u{000a}'),            ('k', '\u{000b}'),            ('l', '\u{000c}'),            ('m', '\u{000d}'),            ('n', '\u{000e}'),            ('o', '\u{000f}'),            ('p', '\u{0010}'),            ('q', '\u{0011}'),            ('r', '\u{0012}'),            ('s', '\u{0013}'),            ('t', '\u{0014}'),            ('u', '\u{0015}'),            ('v', '\u{0016}'),            ('w', '\u{0017}'),            ('x', '\u{0018}'),            ('y', '\u{0019}'),            ('z', '\u{001a}'),            ('4', '\u{001c}'),            ('5', '\u{001d}'),            ('6', '\u{001e}'),            ('7', '\u{001f}'),        ];        for (ctrl_char, c0_char) in cases {            assert!(                ctrl(KeyCode::Char(ctrl_char))                    .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)),                "expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}"            );            assert!(                !ctrl(KeyCode::Char(ctrl_char))                    .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)),                "expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}"            );        }    }    #[test]    fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() {        assert!(            !ctrl(KeyCode::Char('['))                .is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,))        );        assert!(            !ctrl(KeyCode::Char('?'))                .is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,))        );    }    #[test]    fn history_search_ctrl_bindings_match_c0_control_char_events() {        assert!(            ctrl(KeyCode::Char('r'))                .is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE))        );        assert!(            ctrl(KeyCode::Char('s'))                .is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE))        );    }    #[test]    fn ctrl_alt_sets_both_modifiers() {        assert_eq!(            ctrl_alt(KeyCode::Char('v')).parts(),            (                KeyCode::Char('v'),                KeyModifiers::CONTROL | KeyModifiers::ALT            )        );    }    #[test]    fn has_ctrl_or_alt_checks_supported_modifier_combinations() {        assert!(!has_ctrl_or_alt(KeyModifiers::NONE));        assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));        assert!(has_ctrl_or_alt(KeyModifiers::ALT));        #[cfg(windows)]        assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));        #[cfg(not(windows))]        assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));    }}