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(target_os = "macos")]
31const ALT_PREFIX: &str = "⌥ + ";
32#[cfg(not(target_os = "macos"))]
33const ALT_PREFIX: &str = "alt + ";
34const CTRL_PREFIX: &str = "ctrl + ";
35const SHIFT_PREFIX: &str = "shift + ";
36
37/// One concrete key event that can trigger a TUI action.
38///
39/// Matching via `is_press` handles exact equality plus compatibility fallbacks
40/// for terminals that report uppercase letters without SHIFT and Ctrl keys as
41/// raw C0 control characters. This means a binding defined as `shift-a` will
42/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF.
43///
44/// This does not model multi-key chords or partial matches; callers that need
45/// sequences must keep that state outside this type.
46#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
47pub struct KeyBinding {
48    key: KeyCode,
49    modifiers: KeyModifiers,
50}
51
52impl KeyBinding {
53    pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
54        Self { key, modifiers }
55    }
56
57    pub fn from_event(event: KeyEvent) -> Self {
58        let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);
59        Self { key, modifiers }
60    }
61
62    pub fn is_press(&self, event: KeyEvent) -> bool {
63        normalize_key_parts(self.key, self.modifiers)
64            == normalize_key_parts(event.code, event.modifiers)
65            && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
66    }
67
68    pub const fn parts(&self) -> (KeyCode, KeyModifiers) {
69        (self.key, self.modifiers)
70    }
71
72    pub fn display_label(&self) -> String {
73        let modifiers = modifiers_to_string(self.modifiers);
74        let key = match self.key {
75            KeyCode::Enter => "enter".to_string(),
76            KeyCode::Char(' ') => "space".to_string(),
77            KeyCode::Up => "↑".to_string(),
78            KeyCode::Down => "↓".to_string(),
79            KeyCode::Left => "←".to_string(),
80            KeyCode::Right => "→".to_string(),
81            KeyCode::PageUp => "pgup".to_string(),
82            KeyCode::PageDown => "pgdn".to_string(),
83            _ => self.key.to_string().to_ascii_lowercase(),
84        };
85        format!("{modifiers}{key}")
86    }
87}
88
89pub fn normalize_key_parts(key: KeyCode, mut modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
90    let KeyCode::Char(ch) = key else {
91        return (key, modifiers);
92    };
93    if modifiers.is_empty() {
94        if let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch) {
95            return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
96        }
97    }
98    if ch.is_ascii_uppercase() {
99        modifiers.insert(KeyModifiers::SHIFT);
100        return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
101    }
102    (key, modifiers)
103}
104
105fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
106    let code = u32::from(ch);
107    match code {
108        0x00 => Some(' '),
109        0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),
110        0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),
111        _ => None,
112    }
113}
114
115/// Matching helpers for one action's keybinding set.
116///
117/// Implementations are expected to treat the slice as alternatives for one
118/// action. They should not interpret order as priority for dispatch; order is
119/// reserved for UI hint selection via `primary_binding`.
120pub trait KeyBindingListExt {
121    /// True when any binding in this set matches `event`.
122    fn is_pressed(&self, event: KeyEvent) -> bool;
123}
124
125impl KeyBindingListExt for [KeyBinding] {
126    fn is_pressed(&self, event: KeyEvent) -> bool {
127        self.iter().any(|binding| binding.is_press(event))
128    }
129}
130
131/// Returns whether an event should be treated as literal text input.
132///
133/// Searchable pickers use this to avoid stealing plain printable characters for
134/// navigation when the same character might be a valid query. For example, a
135/// list may bind `j` and `k` for movement, but a searchable list must let
136/// plain `j` update the query while still allowing `Ctrl+J` to move. Calling
137/// this after normalizing keybindings would blur that distinction and cause
138/// printable search input to disappear.
139pub fn is_plain_text_key_event(event: KeyEvent) -> bool {
140    matches!(
141        event,
142        KeyEvent {
143            code: KeyCode::Char(ch),
144            modifiers,
145            ..
146        } if !ch.is_ascii_control()
147            && !modifiers.contains(KeyModifiers::CONTROL)
148            && !modifiers.contains(KeyModifiers::ALT)
149    )
150}
151
152pub const fn plain(key: KeyCode) -> KeyBinding {
153    KeyBinding::new(key, KeyModifiers::NONE)
154}
155
156pub const fn alt(key: KeyCode) -> KeyBinding {
157    KeyBinding::new(key, KeyModifiers::ALT)
158}
159
160pub const fn shift(key: KeyCode) -> KeyBinding {
161    KeyBinding::new(key, KeyModifiers::SHIFT)
162}
163
164pub const fn ctrl(key: KeyCode) -> KeyBinding {
165    KeyBinding::new(key, KeyModifiers::CONTROL)
166}
167
168pub const fn ctrl_alt(key: KeyCode) -> KeyBinding {
169    KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
170}
171
172fn modifiers_to_string(modifiers: KeyModifiers) -> String {
173    let mut result = String::new();
174    if modifiers.contains(KeyModifiers::CONTROL) {
175        result.push_str(CTRL_PREFIX);
176    }
177    if modifiers.contains(KeyModifiers::SHIFT) {
178        result.push_str(SHIFT_PREFIX);
179    }
180    if modifiers.contains(KeyModifiers::ALT) {
181        result.push_str(ALT_PREFIX);
182    }
183    result
184}
185
186impl From<KeyBinding> for Span<'static> {
187    fn from(binding: KeyBinding) -> Self {
188        (&binding).into()
189    }
190}
191impl From<&KeyBinding> for Span<'static> {
192    fn from(binding: &KeyBinding) -> Self {
193        Span::styled(binding.display_label(), key_hint_style())
194    }
195}
196
197fn key_hint_style() -> Style {
198    Style::default().dim()
199}
200
201pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
202    (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
203}
204
205#[cfg(windows)]
206#[inline]
207pub fn is_altgr(mods: KeyModifiers) -> bool {
208    mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
209}
210
211#[cfg(not(windows))]
212#[inline]
213pub fn is_altgr(_mods: KeyModifiers) -> bool {
214    false
215}