返回 CodeWhale
key_hint.rs
根目录 / crates / tui / src / tui / widgets / key_hint.rs
1 //! Terminal-aware keybinding rendering.
2 //!
3 //! `KeyBinding` is a typed representation of a chord (a [`KeyCode`] plus a
4 //! [`KeyModifiers`] set) that knows how to render itself in a way that matches
5 //! the host platform's conventions. On macOS the Option key renders as `⌥`
6 //! (matching how every other Mac app — including Terminal, iTerm2, and the
7 //! system menu bar — labels Option chords). On Linux and Windows we keep the
8 //! plain-text `alt + X` notation that users coming from other CLIs already
9 //! recognise.
10 //!
11 //! See `codex-rs/tui/src/key_hint.rs` for the original design; this is a
12 //! ratatui-compatible port that exposes a [`std::fmt::Display`] impl plus a
13 //! `KeyBinding -> Span` conversion so call sites can use it equally well in
14 //! plain `format!` calls and inside ratatui [`ratatui::text::Line`] /
15 //! [`ratatui::text::Span`] builders.
16 //!
17 //! Windows AltGr disambiguation: many European keyboard layouts produce
18 //! `Ctrl+Alt` events when AltGr is pressed alone (to type `@`, `\`, etc.).
19 //! [`is_altgr`] returns `true` for that combination on Windows so callers can
20 //! suppress alt-bound shortcut matching when the user is genuinely just
21 //! reaching for a glyph. On non-Windows targets the function always returns
22 //! `false`. See [`has_ctrl_or_alt`] for the convenience predicate that
23 //! shortcut handlers should prefer over a raw `mods.contains(...)` check.
24
25 use std::fmt;
26
27 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
28 use ratatui::{style::Style, text::Span};
29
30 // Compile-time platform detection. The `#[cfg(test)]` arm forces the macOS
31 // rendering during `cargo test` so unit tests are deterministic regardless of
32 // the host they run on (CI hits Ubuntu, macOS, and Windows).
33 #[cfg(test)]
34 const ALT_PREFIX: &str = "⌥+";
35 #[cfg(all(not(test), target_os = "macos"))]
36 const ALT_PREFIX: &str = "⌥+";
37 #[cfg(all(not(test), not(target_os = "macos")))]
38 const ALT_PREFIX: &str = "alt+";
39
40 const CTRL_PREFIX: &str = "ctrl+";
41 const SHIFT_PREFIX: &str = "shift+";
42
43 /// A typed representation of a single chord (key + modifiers).
44 ///
45 /// Construct via [`plain`], [`alt`], [`shift`], [`ctrl`], or [`ctrl_alt`] for
46 /// the common cases, or [`KeyBinding::new`] for arbitrary modifier sets.
47 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
48 pub struct KeyBinding {
49 key: KeyCode,
50 modifiers: KeyModifiers,
51 }
52
53 impl KeyBinding {
54 /// Build a binding from a key code and modifier set.
55 pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
56 Self { key, modifiers }
57 }
58
59 /// `true` if the supplied [`KeyEvent`] matches this binding (key + mods),
60 /// considering only `Press` / `Repeat` events (release events are ignored
61 /// — crossterm only emits them when key-release reporting is on, and we
62 /// never want to fire a shortcut on key-up regardless).
63 pub fn is_press(&self, event: KeyEvent) -> bool {
64 self.key == event.code
65 && self.modifiers == event.modifiers
66 && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
67 }
68 }
69
70 /// A binding with no modifiers.
71 pub const fn plain(key: KeyCode) -> KeyBinding {
72 KeyBinding::new(key, KeyModifiers::NONE)
73 }
74
75 /// `Alt`-modified binding (renders as `⌥` on macOS, `alt+` elsewhere).
76 pub const fn alt(key: KeyCode) -> KeyBinding {
77 KeyBinding::new(key, KeyModifiers::ALT)
78 }
79
80 /// `Shift`-modified binding.
81 pub const fn shift(key: KeyCode) -> KeyBinding {
82 KeyBinding::new(key, KeyModifiers::SHIFT)
83 }
84
85 /// `Ctrl`-modified binding.
86 pub const fn ctrl(key: KeyCode) -> KeyBinding {
87 KeyBinding::new(key, KeyModifiers::CONTROL)
88 }
89
90 /// `Ctrl+Alt`-modified binding.
91 pub const fn ctrl_alt(key: KeyCode) -> KeyBinding {
92 KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
93 }
94
95 fn modifiers_to_string(modifiers: KeyModifiers) -> String {
96 let mut result = String::new();
97 if modifiers.contains(KeyModifiers::CONTROL) {
98 result.push_str(CTRL_PREFIX);
99 }
100 if modifiers.contains(KeyModifiers::SHIFT) {
101 result.push_str(SHIFT_PREFIX);
102 }
103 if modifiers.contains(KeyModifiers::ALT) {
104 result.push_str(ALT_PREFIX);
105 }
106 result
107 }
108
109 fn keycode_to_string(key: &KeyCode) -> String {
110 match key {
111 KeyCode::Enter => "enter".to_string(),
112 KeyCode::Tab => "tab".to_string(),
113 KeyCode::BackTab => "shift+tab".to_string(),
114 KeyCode::Backspace => "backspace".to_string(),
115 KeyCode::Delete => "del".to_string(),
116 KeyCode::Esc => "esc".to_string(),
117 KeyCode::Char(' ') => "space".to_string(),
118 KeyCode::Char(c) => c.to_string().to_ascii_lowercase(),
119 KeyCode::Up => "↑".to_string(),
120 KeyCode::Down => "↓".to_string(),
121 KeyCode::Left => "←".to_string(),
122 KeyCode::Right => "→".to_string(),
123 KeyCode::PageUp => "pgup".to_string(),
124 KeyCode::PageDown => "pgdn".to_string(),
125 KeyCode::Home => "home".to_string(),
126 KeyCode::End => "end".to_string(),
127 KeyCode::F(n) => format!("f{n}"),
128 _ => format!("{key}").to_ascii_lowercase(),
129 }
130 }
131
132 impl fmt::Display for KeyBinding {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(
135 f,
136 "{}{}",
137 modifiers_to_string(self.modifiers),
138 keycode_to_string(&self.key)
139 )
140 }
141 }
142
143 impl From<KeyBinding> for Span<'static> {
144 fn from(binding: KeyBinding) -> Self {
145 (&binding).into()
146 }
147 }
148
149 impl From<&KeyBinding> for Span<'static> {
150 fn from(binding: &KeyBinding) -> Self {
151 Span::styled(binding.to_string(), key_hint_style())
152 }
153 }
154
155 fn key_hint_style() -> Style {
156 Style::default().dim()
157 }
158
159 /// Platform-specific prefix for `Alt`-modified chords, matching how the rest
160 /// of the TUI labels them: `⌥+` on macOS (the Option-key glyph every Mac app
161 /// uses) and `alt+` on Linux/Windows. Callers that build their own key-hint
162 /// strings (e.g. the hotbar slot labels) should use this so the modifier label
163 /// stays consistent with the help overlay and footer hints.
164 pub fn alt_prefix() -> &'static str {
165 ALT_PREFIX
166 }
167
168 /// `true` if `mods` carries Ctrl or Alt — but not the AltGr Ctrl+Alt
169 /// combination on Windows. Shortcut handlers should prefer this predicate
170 /// over `mods.contains(CONTROL) || mods.contains(ALT)` so they don't fire on
171 /// AltGr keypresses (which on European keyboard layouts are how users type
172 /// `@`, `\`, `|`, etc.).
173 pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
174 (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
175 }
176
177 /// On Windows, AltGr is delivered as `Ctrl+Alt`. There's no terminal-portable
178 /// way to tell a real `Ctrl+Alt` chord apart from a layout-emitted AltGr glyph
179 /// — crossterm doesn't expose left-vs-right modifier distinction across all
180 /// backends — so we treat any `Ctrl+Alt` (with no other modifiers) as AltGr.
181 /// This trades the (rare) ability to bind `Ctrl+Alt+<char>` for not
182 /// swallowing accented characters European users type. On non-Windows
183 /// platforms this always returns `false`.
184 #[cfg(windows)]
185 #[inline]
186 pub fn is_altgr(mods: KeyModifiers) -> bool {
187 mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
188 }
189
190 #[cfg(not(windows))]
191 #[inline]
192 pub fn is_altgr(_mods: KeyModifiers) -> bool {
193 false
194 }
195
196 #[cfg(test)]
197 mod tests {
198 use super::*;
199
200 // Tests force ALT_PREFIX = "⌥+" via `cfg(test)`. We verify both
201 // platform-specific renderings explicitly by invoking the helper code
202 // paths the host-OS cfg arms would select.
203
204 #[test]
205 fn plain_renders_just_the_key() {
206 assert_eq!(plain(KeyCode::Enter).to_string(), "enter");
207 assert_eq!(plain(KeyCode::Char(' ')).to_string(), "space");
208 assert_eq!(plain(KeyCode::Up).to_string(), "↑");
209 }
210
211 #[test]
212 fn alt_renders_with_macos_glyph_in_tests() {
213 // Under cfg(test) we force the macOS prefix so test output is
214 // deterministic. The non-macOS rendering is exercised in
215 // `non_macos_alt_prefix` below.
216 assert_eq!(alt(KeyCode::Up).to_string(), "⌥+↑");
217 assert_eq!(alt(KeyCode::Char('p')).to_string(), "⌥+p");
218 }
219
220 #[test]
221 fn shift_and_ctrl_render_in_canonical_order() {
222 // Order is: ctrl, shift, alt — matching codex-rs and what users
223 // expect from cross-tool muscle memory.
224 assert_eq!(ctrl(KeyCode::Char('c')).to_string(), "ctrl+c");
225 assert_eq!(shift(KeyCode::Tab).to_string(), "shift+tab");
226 assert_eq!(
227 KeyBinding::new(
228 KeyCode::Char('x'),
229 KeyModifiers::CONTROL | KeyModifiers::SHIFT
230 )
231 .to_string(),
232 "ctrl+shift+x"
233 );
234 }
235
236 #[test]
237 fn ctrl_alt_combo_renders_both_modifiers() {
238 assert_eq!(ctrl_alt(KeyCode::Char('a')).to_string(), "ctrl+⌥+a");
239 }
240
241 #[test]
242 fn keycode_lowercases_letters() {
243 assert_eq!(plain(KeyCode::Char('A')).to_string(), "a");
244 }
245
246 #[test]
247 fn function_keys_render_as_f_n() {
248 assert_eq!(plain(KeyCode::F(1)).to_string(), "f1");
249 assert_eq!(plain(KeyCode::F(12)).to_string(), "f12");
250 }
251
252 #[test]
253 fn span_conversion_carries_dim_style() {
254 let span: Span<'static> = alt(KeyCode::Up).into();
255 assert_eq!(span.content, "⌥+↑");
256 // The exact `Style` representation in ratatui isn't trivially
257 // comparable, so we just verify the style was set (not default).
258 assert_ne!(span.style, Style::default());
259 }
260
261 #[test]
262 fn is_press_matches_press_and_repeat() {
263 let binding = ctrl(KeyCode::Char('c'));
264 let press = KeyEvent {
265 code: KeyCode::Char('c'),
266 modifiers: KeyModifiers::CONTROL,
267 kind: KeyEventKind::Press,
268 state: crossterm::event::KeyEventState::NONE,
269 };
270 let repeat = KeyEvent {
271 kind: KeyEventKind::Repeat,
272 ..press
273 };
274 let release = KeyEvent {
275 kind: KeyEventKind::Release,
276 ..press
277 };
278 let wrong_mods = KeyEvent {
279 modifiers: KeyModifiers::NONE,
280 ..press
281 };
282 assert!(binding.is_press(press));
283 assert!(binding.is_press(repeat));
284 assert!(!binding.is_press(release));
285 assert!(!binding.is_press(wrong_mods));
286 }
287
288 #[test]
289 fn altgr_only_fires_on_windows() {
290 let altgr_mods = KeyModifiers::ALT | KeyModifiers::CONTROL;
291 if cfg!(windows) {
292 assert!(is_altgr(altgr_mods));
293 assert!(!has_ctrl_or_alt(altgr_mods));
294 } else {
295 assert!(!is_altgr(altgr_mods));
296 assert!(has_ctrl_or_alt(altgr_mods));
297 }
298 // Plain Alt is never AltGr.
299 assert!(!is_altgr(KeyModifiers::ALT));
300 assert!(has_ctrl_or_alt(KeyModifiers::ALT));
301 // No modifiers: never Ctrl/Alt.
302 assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
303 }
304
305 /// Render an alt-prefixed binding the way the Linux/Windows non-test arm
306 /// would. We can't toggle the cfg at runtime, so we rebuild the rendering
307 /// with the alternate prefix to lock in the expected string shape.
308 #[test]
309 fn non_macos_alt_prefix_shape() {
310 let mods = modifiers_to_string(KeyModifiers::ALT);
311 // Under cfg(test), this is "⌥+". Strip and re-render with "alt+" to
312 // demonstrate the shape that ships on Linux/Windows release builds.
313 let linux_shape = mods.replace("⌥+", "alt+");
314 assert_eq!(linux_shape, "alt+");
315
316 let mods_mixed = modifiers_to_string(KeyModifiers::CONTROL | KeyModifiers::ALT);
317 let linux_shape_mixed = mods_mixed.replace("⌥+", "alt+");
318 assert_eq!(linux_shape_mixed, "ctrl+alt+");
319 }
320 }
321
321 lines RUST