返回 CodeWhale
keys.rs
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
204 lines RUST