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