Skip to main content

supercode_frontend_tui/input/
key_hint.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/key_hint.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Key binding primitives and input matching for the TUI.
8//!
9//! This module provides `KeyBinding`, the runtime representation of a single
10//! keybinding (key code + modifier set), along with matching logic that handles
11//! cross-terminal inconsistencies in how shifted letters and raw C0 control
12//! characters are reported.
13//!
14//! List and picker code should match navigation through these helpers instead
15//! of comparing `KeyEvent` values directly. The matcher owns compatibility for
16//! terminals that report control chords as C0 characters, while
17//! `is_plain_text_key_event` gives searchable pickers a shared boundary between
18//! text input and navigation commands.
19//!
20//! It also supplies rendering helpers that convert bindings into styled
21//! `ratatui::text::Span` values for UI hint display.
22
23use crossterm::event::KeyCode;
24use crossterm::event::KeyEvent;
25use crossterm::event::KeyEventKind;
26use crossterm::event::KeyModifiers;
27use ratatui::style::Style;
28use ratatui::text::Span;
29
30#[cfg(test)]
31const ALT_PREFIX: &str = "⌥ + ";
32#[cfg(all(not(test), target_os = "macos"))]
33const ALT_PREFIX: &str = "⌥ + ";
34#[cfg(all(not(test), not(target_os = "macos")))]
35const ALT_PREFIX: &str = "alt + ";
36const CTRL_PREFIX: &str = "ctrl + ";
37const SHIFT_PREFIX: &str = "shift + ";
38
39/// One concrete key event that can trigger a TUI action.
40///
41/// Matching via `is_press` handles exact equality plus compatibility fallbacks
42/// for terminals that report uppercase letters without SHIFT and Ctrl keys as
43/// raw C0 control characters. This means a binding defined as `shift-a` will
44/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF.
45///
46/// This does not model multi-key chords or partial matches; callers that need
47/// sequences must keep that state outside this type.
48#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
49pub struct KeyBinding {
50    key: KeyCode,
51    modifiers: KeyModifiers,
52}
53
54impl KeyBinding {
55    pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
56        Self { key, modifiers }
57    }
58
59    pub fn from_event(event: KeyEvent) -> Self {
60        let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);
61        Self { key, modifiers }
62    }
63
64    pub fn is_press(&self, event: KeyEvent) -> bool {
65        normalize_key_parts(self.key, self.modifiers)
66            == normalize_key_parts(event.code, event.modifiers)
67            && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
68    }
69
70    pub const fn parts(&self) -> (KeyCode, KeyModifiers) {
71        (self.key, self.modifiers)
72    }
73
74    pub fn display_label(&self) -> String {
75        let modifiers = modifiers_to_string(self.modifiers);
76        let key = match self.key {
77            KeyCode::Enter => "enter".to_string(),
78            KeyCode::Char(' ') => "space".to_string(),
79            KeyCode::Up => "↑".to_string(),
80            KeyCode::Down => "↓".to_string(),
81            KeyCode::Left => "←".to_string(),
82            KeyCode::Right => "→".to_string(),
83            KeyCode::PageUp => "pgup".to_string(),
84            KeyCode::PageDown => "pgdn".to_string(),
85            _ => self.key.to_string().to_ascii_lowercase(),
86        };
87        format!("{modifiers}{key}")
88    }
89}
90
91pub fn normalize_key_parts(key: KeyCode, mut modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
92    let KeyCode::Char(ch) = key else {
93        return (key, modifiers);
94    };
95    if modifiers.is_empty() {
96        if let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) {
97            return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
98        }
99    }
100    if ch.is_ascii_uppercase() {
101        modifiers.insert(KeyModifiers::SHIFT);
102        return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
103    }
104    (key, modifiers)
105}
106
107fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
108    let code = u32::from(ch);
109    match code {
110        0x00 => Some(' '),
111        0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),
112        0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),
113        _ => None,
114    }
115}
116
117/// Matching helpers for one action's keybinding set.
118///
119/// Implementations are expected to treat the slice as alternatives for one
120/// action. They should not interpret order as priority for dispatch; order is
121/// reserved for UI hint selection via `primary_binding`.
122pub trait KeyBindingListExt {
123    /// True when any binding in this set matches `event`.
124    fn is_pressed(&self, event: KeyEvent) -> bool;
125}
126
127impl KeyBindingListExt for [KeyBinding] {
128    fn is_pressed(&self, event: KeyEvent) -> bool {
129        self.iter().any(|binding| binding.is_press(event))
130    }
131}
132
133/// Returns whether an event should be treated as literal text input.
134///
135/// Searchable pickers use this to avoid stealing plain printable characters for
136/// navigation when the same character might be a valid query. For example, a
137/// list may bind `j` and `k` for movement, but a searchable list must let
138/// plain `j` update the query while still allowing `Ctrl+J` to move. Calling
139/// this after normalizing keybindings would blur that distinction and cause
140/// printable search input to disappear.
141pub fn is_plain_text_key_event(event: KeyEvent) -> bool {
142    matches!(
143        event,
144        KeyEvent {
145            code: KeyCode::Char(ch),
146            modifiers,
147            ..
148        } if !ch.is_ascii_control()
149            && !modifiers.contains(KeyModifiers::CONTROL)
150            && !modifiers.contains(KeyModifiers::ALT)
151    )
152}
153
154pub const fn plain(key: KeyCode) -> KeyBinding {
155    KeyBinding::new(key, KeyModifiers::NONE)
156}
157
158pub const fn alt(key: KeyCode) -> KeyBinding {
159    KeyBinding::new(key, KeyModifiers::ALT)
160}
161
162pub const fn shift(key: KeyCode) -> KeyBinding {
163    KeyBinding::new(key, KeyModifiers::SHIFT)
164}
165
166pub const fn ctrl(key: KeyCode) -> KeyBinding {
167    KeyBinding::new(key, KeyModifiers::CONTROL)
168}
169
170pub const fn ctrl_alt(key: KeyCode) -> KeyBinding {
171    KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
172}
173
174fn modifiers_to_string(modifiers: KeyModifiers) -> String {
175    let mut result = String::new();
176    if modifiers.contains(KeyModifiers::CONTROL) {
177        result.push_str(CTRL_PREFIX);
178    }
179    if modifiers.contains(KeyModifiers::SHIFT) {
180        result.push_str(SHIFT_PREFIX);
181    }
182    if modifiers.contains(KeyModifiers::ALT) {
183        result.push_str(ALT_PREFIX);
184    }
185    result
186}
187
188impl From<KeyBinding> for Span<'static> {
189    fn from(binding: KeyBinding) -> Self {
190        (&binding).into()
191    }
192}
193impl From<&KeyBinding> for Span<'static> {
194    fn from(binding: &KeyBinding) -> Self {
195        Span::styled(binding.display_label(), key_hint_style())
196    }
197}
198
199fn key_hint_style() -> Style {
200    Style::default().dim()
201}
202
203pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
204    (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
205}
206
207#[cfg(windows)]
208#[inline]
209pub fn is_altgr(mods: KeyModifiers) -> bool {
210    mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
211}
212
213#[cfg(not(windows))]
214#[inline]
215pub fn is_altgr(_mods: KeyModifiers) -> bool {
216    false
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn is_press_accepts_press_and_repeat_but_rejects_release() {
225        let binding = ctrl(KeyCode::Char('k'));
226        let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
227        let repeat = KeyEvent {
228            kind: KeyEventKind::Repeat,
229            ..press
230        };
231        let release = KeyEvent {
232            kind: KeyEventKind::Release,
233            ..press
234        };
235        let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
236
237        assert!(binding.is_press(press));
238        assert!(binding.is_press(repeat));
239        assert!(!binding.is_press(release));
240        assert!(!binding.is_press(wrong_modifiers));
241    }
242
243    #[test]
244    fn keybinding_list_ext_matches_any_binding() {
245        let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
246
247        assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
248        assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
249        assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
250    }
251
252    #[test]
253    fn shifted_letter_binding_matches_uppercase_char_events() {
254        let binding = shift(KeyCode::Char('a'));
255
256        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)));
257        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));
258        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));
259    }
260
261    #[test]
262    fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() {
263        let binding = KeyBinding::new(
264            KeyCode::Char('i'),
265            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
266        );
267
268        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL)));
269    }
270
271    #[test]
272    fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() {
273        let binding = shift(KeyCode::Char('o'));
274
275        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));
276        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)));
277    }
278
279    #[test]
280    fn ctrl_letter_binding_matches_c0_control_char_events() {
281        let binding = ctrl(KeyCode::Char('p'));
282
283        assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)));
284        assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));
285    }
286
287    #[test]
288    fn ctrl_bindings_match_all_supported_c0_control_char_events() {
289        let cases = [
290            (' ', '\u{0000}'),
291            ('a', '\u{0001}'),
292            ('b', '\u{0002}'),
293            ('c', '\u{0003}'),
294            ('d', '\u{0004}'),
295            ('e', '\u{0005}'),
296            ('f', '\u{0006}'),
297            ('g', '\u{0007}'),
298            ('h', '\u{0008}'),
299            ('i', '\u{0009}'),
300            ('j', '\u{000a}'),
301            ('k', '\u{000b}'),
302            ('l', '\u{000c}'),
303            ('m', '\u{000d}'),
304            ('n', '\u{000e}'),
305            ('o', '\u{000f}'),
306            ('p', '\u{0010}'),
307            ('q', '\u{0011}'),
308            ('r', '\u{0012}'),
309            ('s', '\u{0013}'),
310            ('t', '\u{0014}'),
311            ('u', '\u{0015}'),
312            ('v', '\u{0016}'),
313            ('w', '\u{0017}'),
314            ('x', '\u{0018}'),
315            ('y', '\u{0019}'),
316            ('z', '\u{001a}'),
317            ('4', '\u{001c}'),
318            ('5', '\u{001d}'),
319            ('6', '\u{001e}'),
320            ('7', '\u{001f}'),
321        ];
322
323        for (ctrl_char, c0_char) in cases {
324            assert!(
325                ctrl(KeyCode::Char(ctrl_char))
326                    .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)),
327                "expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}"
328            );
329            assert!(
330                !ctrl(KeyCode::Char(ctrl_char))
331                    .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)),
332                "expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}"
333            );
334        }
335    }
336
337    #[test]
338    fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() {
339        assert!(!ctrl(KeyCode::Char('['))
340            .is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,)));
341        assert!(!ctrl(KeyCode::Char('?'))
342            .is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,)));
343    }
344
345    #[test]
346    fn history_search_ctrl_bindings_match_c0_control_char_events() {
347        assert!(ctrl(KeyCode::Char('r'))
348            .is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE)));
349        assert!(ctrl(KeyCode::Char('s'))
350            .is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE)));
351    }
352
353    #[test]
354    fn ctrl_alt_sets_both_modifiers() {
355        assert_eq!(
356            ctrl_alt(KeyCode::Char('v')).parts(),
357            (
358                KeyCode::Char('v'),
359                KeyModifiers::CONTROL | KeyModifiers::ALT
360            )
361        );
362    }
363
364    #[test]
365    fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
366        assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
367        assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
368        assert!(has_ctrl_or_alt(KeyModifiers::ALT));
369
370        #[cfg(windows)]
371        assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
372        #[cfg(not(windows))]
373        assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
374    }
375}