| 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 |