| 1 | //! Key labels, spelled one way everywhere. |
| 2 | //! |
| 3 | //! Spelling follows the Codewhale engine's help catalog |
| 4 | //! (`crates/tui/src/tui/keybindings.rs`) and platform rules from |
| 5 | //! `key_shortcuts.rs` and `shell_key_routing.rs::display_chord` |
| 6 | //! (`Hmbown/CodeWhale` `58b1dd3dd`; `display_chord` last changed in |
| 7 | //! `da1937a048`): `Ctrl+O`, `Alt+V` (`⌥V` on macOS), `Shift+Tab`, `PgUp`. |
| 8 | //! Arrows are glyphs (`↑↓`), and ASCII-safe terminals get words |
| 9 | //! (`Up/Down`). |
| 10 | |
| 11 | use std::borrow::Cow; |
| 12 | |
| 13 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 14 | |
| 15 | /// Where the labels will be read. |
| 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 17 | pub struct Platform { |
| 18 | pub macos: bool, |
| 19 | pub ascii: bool, |
| 20 | } |
| 21 | |
| 22 | impl Platform { |
| 23 | /// The platform this binary was built for, with ASCII-safety supplied by |
| 24 | /// the caller (usually [`crate::theme::Theme::ascii`]). |
| 25 | #[must_use] |
| 26 | pub const fn current(ascii: bool) -> Self { |
| 27 | Self { |
| 28 | macos: cfg!(target_os = "macos"), |
| 29 | ascii, |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /// Render a catalog chord such as `Alt+V` for the platform: macOS shows |
| 35 | /// `⌥V` and `fn+F1`; ASCII-safe terminals keep the portable spelling. |
| 36 | #[must_use] |
| 37 | pub fn display_chord(chord: &str, platform: Platform) -> Cow<'_, str> { |
| 38 | if platform.ascii || !platform.macos { |
| 39 | return Cow::Borrowed(chord); |
| 40 | } |
| 41 | let rendered = chord.replace("Alt+", "⌥").replace("F1", "fn+F1"); |
| 42 | if rendered == chord { |
| 43 | Cow::Borrowed(chord) |
| 44 | } else { |
| 45 | Cow::Owned(rendered) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | /// The label for one key without modifiers. |
| 50 | #[must_use] |
| 51 | pub fn key_name(code: KeyCode, platform: Platform) -> Cow<'static, str> { |
| 52 | let arrow = |glyph: &'static str, word: &'static str| { |
| 53 | Cow::Borrowed(if platform.ascii { word } else { glyph }) |
| 54 | }; |
| 55 | match code { |
| 56 | KeyCode::Up => arrow("↑", "Up"), |
| 57 | KeyCode::Down => arrow("↓", "Down"), |
| 58 | KeyCode::Left => arrow("←", "Left"), |
| 59 | KeyCode::Right => arrow("→", "Right"), |
| 60 | KeyCode::Enter => Cow::Borrowed("Enter"), |
| 61 | KeyCode::Esc => Cow::Borrowed("Esc"), |
| 62 | KeyCode::Tab => Cow::Borrowed("Tab"), |
| 63 | KeyCode::BackTab => Cow::Borrowed("Shift+Tab"), |
| 64 | KeyCode::Backspace => Cow::Borrowed("Backspace"), |
| 65 | KeyCode::Delete => Cow::Borrowed("Delete"), |
| 66 | KeyCode::Insert => Cow::Borrowed("Insert"), |
| 67 | KeyCode::Home => Cow::Borrowed("Home"), |
| 68 | KeyCode::End => Cow::Borrowed("End"), |
| 69 | KeyCode::PageUp => Cow::Borrowed("PgUp"), |
| 70 | KeyCode::PageDown => Cow::Borrowed("PgDn"), |
| 71 | KeyCode::F(n) => Cow::Owned(format!("F{n}")), |
| 72 | KeyCode::Char(' ') => Cow::Borrowed("Space"), |
| 73 | KeyCode::Char(c) => Cow::Owned(c.to_string()), |
| 74 | _ => Cow::Borrowed("?"), |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// The label for a key event: `Ctrl+O`, `⌥V`, `Ctrl+Shift+E`, `Enter`, `a`. |
| 79 | /// A letter under a modifier is shown in capitals, as the catalog does. |
| 80 | #[must_use] |
| 81 | pub fn chord_label(key: &KeyEvent, platform: Platform) -> String { |
| 82 | let mods = key.modifiers; |
| 83 | let mut out = String::new(); |
| 84 | if mods.contains(KeyModifiers::CONTROL) { |
| 85 | out.push_str("Ctrl+"); |
| 86 | } |
| 87 | if mods.contains(KeyModifiers::ALT) { |
| 88 | out.push_str("Alt+"); |
| 89 | } |
| 90 | if mods.contains(KeyModifiers::SUPER) { |
| 91 | out.push_str(if platform.macos { "Cmd+" } else { "Super+" }); |
| 92 | } |
| 93 | let shifted_letter = matches!(key.code, KeyCode::Char(c) if c.is_ascii_uppercase()); |
| 94 | if mods.contains(KeyModifiers::SHIFT) && !matches!(key.code, KeyCode::BackTab) { |
| 95 | out.push_str("Shift+"); |
| 96 | } |
| 97 | let name = match key.code { |
| 98 | KeyCode::Char(c) if !out.is_empty() || shifted_letter => { |
| 99 | Cow::Owned(c.to_ascii_uppercase().to_string()) |
| 100 | } |
| 101 | code => key_name(code, platform), |
| 102 | }; |
| 103 | out.push_str(&name); |
| 104 | display_chord(&out, platform).into_owned() |
| 105 | } |
| 106 | |
| 107 | /// Two keys that do one thing in opposite directions: `↑↓`, or `Up/Down` |
| 108 | /// when ASCII-safe. |
| 109 | #[must_use] |
| 110 | pub fn pair_label(a: KeyCode, b: KeyCode, platform: Platform) -> String { |
| 111 | let (a, b) = (key_name(a, platform), key_name(b, platform)); |
| 112 | if platform.ascii { |
| 113 | format!("{a}/{b}") |
| 114 | } else { |
| 115 | format!("{a}{b}") |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// `Ctrl` on Linux and Windows, or `Cmd` (SUPER) on macOS. |
| 120 | #[must_use] |
| 121 | pub fn has_control_like_modifier(modifiers: KeyModifiers, platform: Platform) -> bool { |
| 122 | modifiers.contains(KeyModifiers::CONTROL) |
| 123 | || (platform.macos && modifiers.contains(KeyModifiers::SUPER)) |
| 124 | } |
| 125 | |
| 126 | /// `Alt+<key>` navigation: requires Alt, rejects Ctrl and Super so it never |
| 127 | /// collides with clipboard or window-management chords. Shift is allowed. |
| 128 | #[must_use] |
| 129 | pub fn alt_nav_modifiers(modifiers: KeyModifiers) -> bool { |
| 130 | modifiers.contains(KeyModifiers::ALT) |
| 131 | && !modifiers.contains(KeyModifiers::CONTROL) |
| 132 | && !modifiers.contains(KeyModifiers::SUPER) |
| 133 | } |
| 134 | |
| 135 | /// `Ctrl+H` is the ASCII backspace many terminals still send for Backspace. |
| 136 | #[must_use] |
| 137 | pub fn is_ctrl_h_backspace(key: &KeyEvent) -> bool { |
| 138 | matches!(key.code, KeyCode::Char('h')) |
| 139 | && key.modifiers.contains(KeyModifiers::CONTROL) |
| 140 | && !key.modifiers.contains(KeyModifiers::ALT) |
| 141 | && !key.modifiers.contains(KeyModifiers::SUPER) |
| 142 | } |
| 143 | |
| 144 | #[cfg(test)] |
| 145 | mod tests { |
| 146 | use super::*; |
| 147 | |
| 148 | const LINUX: Platform = Platform { |
| 149 | macos: false, |
| 150 | ascii: false, |
| 151 | }; |
| 152 | const MAC: Platform = Platform { |
| 153 | macos: true, |
| 154 | ascii: false, |
| 155 | }; |
| 156 | const ASCII: Platform = Platform { |
| 157 | macos: true, |
| 158 | ascii: true, |
| 159 | }; |
| 160 | |
| 161 | #[test] |
| 162 | fn chords_match_the_engine_catalog_spelling() { |
| 163 | let ctrl_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL); |
| 164 | assert_eq!(chord_label(&ctrl_o, LINUX), "Ctrl+O"); |
| 165 | let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT); |
| 166 | assert_eq!(chord_label(&alt_v, LINUX), "Alt+V"); |
| 167 | assert_eq!(chord_label(&alt_v, MAC), "⌥V"); |
| 168 | assert_eq!(chord_label(&alt_v, ASCII), "Alt+V"); |
| 169 | let ctrl_shift_e = KeyEvent::new( |
| 170 | KeyCode::Char('E'), |
| 171 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 172 | ); |
| 173 | assert_eq!(chord_label(&ctrl_shift_e, LINUX), "Ctrl+Shift+E"); |
| 174 | let back_tab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT); |
| 175 | assert_eq!(chord_label(&back_tab, LINUX), "Shift+Tab"); |
| 176 | let plain = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); |
| 177 | assert_eq!(chord_label(&plain, LINUX), "a"); |
| 178 | let page = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE); |
| 179 | assert_eq!(chord_label(&page, LINUX), "PgDn"); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn arrows_are_one_spelling() { |
| 184 | assert_eq!(pair_label(KeyCode::Up, KeyCode::Down, LINUX), "↑↓"); |
| 185 | assert_eq!(pair_label(KeyCode::Up, KeyCode::Down, ASCII), "Up/Down"); |
| 186 | assert_eq!(display_chord("F1", MAC), "fn+F1"); |
| 187 | assert_eq!(display_chord("Ctrl+O", MAC), "Ctrl+O"); |
| 188 | } |
| 189 | |
| 190 | #[test] |
| 191 | fn platform_predicates() { |
| 192 | assert!(has_control_like_modifier(KeyModifiers::SUPER, MAC)); |
| 193 | assert!(!has_control_like_modifier(KeyModifiers::SUPER, LINUX)); |
| 194 | assert!(alt_nav_modifiers(KeyModifiers::ALT | KeyModifiers::SHIFT)); |
| 195 | assert!(!alt_nav_modifiers( |
| 196 | KeyModifiers::ALT | KeyModifiers::CONTROL |
| 197 | )); |
| 198 | assert!(is_ctrl_h_backspace(&KeyEvent::new( |
| 199 | KeyCode::Char('h'), |
| 200 | KeyModifiers::CONTROL |
| 201 | ))); |
| 202 | } |
| 203 | } |
| 204 |