Skip to main content

max / alloy_tui

10.0 KB · 311 lines History Blame Raw
1 //! Reserved keymap: the constants every Alloy TUI navigates by, and the
2 //! classifier that turns a raw key event into one of them.
3 //!
4 //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so
5 //! apps match against `Action` rather than hardcoding keycodes: `Tab` /
6 //! `Shift-Tab` move focus, `l` / `h` move between tabs, `Enter` activates,
7 //! `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens help, `/` filters, `:`
8 //! opens the command line.
9 //!
10 //! Tabs get `l` / `h` rather than `Tab` because `Tab` already means focus and
11 //! that meaning is documented across every view. Two navigation axes need two
12 //! keys, and the vim pair reads as horizontal movement, which is what a tab bar
13 //! is.
14 //!
15 //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions
16 //! to the full reserved set the console's form surfaces need.
17
18 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
19
20 /// A reserved key's meaning. `Passthrough` means the key is not reserved and
21 /// belongs to whatever view currently holds focus.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 pub enum Action {
24 NextFocus,
25 PrevFocus,
26 NextTab,
27 PrevTab,
28 Activate,
29 Cancel,
30 Save,
31 Help,
32 Quit,
33 Filter,
34 Command,
35 Passthrough,
36 }
37
38 /// One reserved key as a user reads it.
39 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
40 pub struct ReservedKey {
41 /// The keystroke, spelled the way it is printed to the screen.
42 pub key: &'static str,
43 /// What it does, in the imperative and short enough for a footer.
44 pub label: &'static str,
45 /// What [`classify`] turns it into.
46 pub action: Action,
47 }
48
49 /// The reserved keymap as a table, for anything that has to *show* it.
50 ///
51 /// It lives beside [`classify`] rather than in the help widget because a help
52 /// screen that can disagree with the classifier is worse than no help screen: it
53 /// teaches a key that does nothing. `reserved_table_matches_the_classifier`
54 /// holds the two together, so a key cannot be rebound without this list moving
55 /// with it.
56 ///
57 /// Ordered as a reader learns them: move, act, then leave.
58 pub const RESERVED: &[ReservedKey] = &[
59 ReservedKey {
60 key: "Tab",
61 label: "next pane",
62 action: Action::NextFocus,
63 },
64 ReservedKey {
65 key: "Shift-Tab",
66 label: "previous pane",
67 action: Action::PrevFocus,
68 },
69 ReservedKey {
70 key: "l",
71 label: "next tab",
72 action: Action::NextTab,
73 },
74 ReservedKey {
75 key: "h",
76 label: "previous tab",
77 action: Action::PrevTab,
78 },
79 ReservedKey {
80 key: "Enter",
81 label: "activate",
82 action: Action::Activate,
83 },
84 ReservedKey {
85 key: "Esc",
86 label: "cancel",
87 action: Action::Cancel,
88 },
89 ReservedKey {
90 key: "Ctrl-S",
91 label: "save",
92 action: Action::Save,
93 },
94 ReservedKey {
95 key: "/",
96 label: "filter",
97 action: Action::Filter,
98 },
99 ReservedKey {
100 key: ":",
101 label: "command",
102 action: Action::Command,
103 },
104 ReservedKey {
105 key: "?",
106 label: "keys",
107 action: Action::Help,
108 },
109 ReservedKey {
110 key: "q",
111 label: "quit",
112 action: Action::Quit,
113 },
114 ];
115
116 /// Classify a key event against the reserved keymap.
117 ///
118 /// This is a pure classifier with no notion of mode: it reports what a key
119 /// *means* in the reserved map, not whether the app should honor it. Two
120 /// caller obligations follow from that:
121 ///
122 /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both
123 /// press and release for every key, so an unfiltered event loop performs
124 /// each action twice.
125 /// - **Ignore the character actions while text entry has focus.** `q`, `/`,
126 /// `:`, `h`, and `l` are literal characters a user types into a field; a view
127 /// holding an active text input should route keys to the input and consult
128 /// this classifier only for `Cancel`, `Save`, and the focus movers.
129 ///
130 /// `h` and `l` make this obligation sharper than it was. The earlier
131 /// character actions were punctuation and one letter that rarely opens a
132 /// word; `h` and `l` are ordinary letters that appear in almost any typed
133 /// value, so a view that forwards raw keys to an input without this check
134 /// now changes tabs mid-word rather than merely on a stray `q`.
135 pub fn classify(key: KeyEvent) -> Action {
136 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
137 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
138
139 match key.code {
140 KeyCode::Char('s' | 'S') if ctrl => Action::Save,
141 KeyCode::BackTab => Action::PrevFocus,
142 KeyCode::Tab if shift => Action::PrevFocus,
143 KeyCode::Tab => Action::NextFocus,
144 KeyCode::Char('l') => Action::NextTab,
145 KeyCode::Char('h') => Action::PrevTab,
146 KeyCode::Enter => Action::Activate,
147 KeyCode::Esc => Action::Cancel,
148 KeyCode::Char('?') => Action::Help,
149 KeyCode::Char('q') => Action::Quit,
150 KeyCode::Char('/') => Action::Filter,
151 KeyCode::Char(':') => Action::Command,
152 _ => Action::Passthrough,
153 }
154 }
155
156 #[cfg(test)]
157 mod tests {
158 use super::*;
159
160 fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
161 KeyEvent::new(code, mods)
162 }
163
164 /// The keystroke a [`ReservedKey`] prints, read back as an event.
165 fn parse(spelled: &str) -> KeyEvent {
166 match spelled {
167 "Tab" => key(KeyCode::Tab, KeyModifiers::NONE),
168 "Shift-Tab" => key(KeyCode::BackTab, KeyModifiers::NONE),
169 "Enter" => key(KeyCode::Enter, KeyModifiers::NONE),
170 "Esc" => key(KeyCode::Esc, KeyModifiers::NONE),
171 "Ctrl-S" => key(KeyCode::Char('s'), KeyModifiers::CONTROL),
172 other => {
173 let mut chars = other.chars();
174 let c = chars.next().expect("a key spelling is not empty");
175 assert!(chars.next().is_none(), "unhandled spelling: {other}");
176 key(KeyCode::Char(c), KeyModifiers::NONE)
177 }
178 }
179 }
180
181 // The help screen reads RESERVED and the app reads classify(). If they can
182 // drift, the help teaches keys that do nothing, which is worse than showing
183 // no help at all.
184 #[test]
185 fn reserved_table_matches_the_classifier() {
186 for entry in RESERVED {
187 assert_eq!(
188 classify(parse(entry.key)),
189 entry.action,
190 "{} is documented as {:?}",
191 entry.key,
192 entry.action
193 );
194 }
195 }
196
197 // Every action a user can reach by pressing something is in the table.
198 // Passthrough is the exception by definition: it is the absence of a
199 // reserved meaning.
200 #[test]
201 fn every_reserved_action_is_documented() {
202 for action in [
203 Action::NextFocus,
204 Action::PrevFocus,
205 Action::NextTab,
206 Action::PrevTab,
207 Action::Activate,
208 Action::Cancel,
209 Action::Save,
210 Action::Help,
211 Action::Quit,
212 Action::Filter,
213 Action::Command,
214 ] {
215 assert!(
216 RESERVED.iter().any(|r| r.action == action),
217 "{action:?} is reachable but absent from RESERVED"
218 );
219 }
220 }
221
222 #[test]
223 fn ctrl_s_saves_but_bare_s_does_not() {
224 assert_eq!(
225 classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)),
226 Action::Save
227 );
228 assert_eq!(
229 classify(key(KeyCode::Char('s'), KeyModifiers::NONE)),
230 Action::Passthrough
231 );
232 }
233
234 // Terminals disagree on how they report Shift-Tab: some send BackTab with no
235 // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
236 // navigation silently becomes one-directional on half the terminal emulators
237 // in the stack.
238 #[test]
239 fn both_shift_tab_encodings_move_focus_backward() {
240 assert_eq!(
241 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
242 Action::PrevFocus
243 );
244 assert_eq!(
245 classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
246 Action::PrevFocus
247 );
248 assert_eq!(
249 classify(key(KeyCode::Tab, KeyModifiers::SHIFT)),
250 Action::PrevFocus
251 );
252 assert_eq!(
253 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
254 Action::NextFocus
255 );
256 }
257
258 #[test]
259 fn unreserved_keys_pass_through() {
260 assert_eq!(
261 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
262 Action::Passthrough
263 );
264 assert_eq!(
265 classify(key(KeyCode::Down, KeyModifiers::NONE)),
266 Action::Passthrough
267 );
268 }
269
270 #[test]
271 fn h_and_l_move_between_tabs() {
272 assert_eq!(
273 classify(key(KeyCode::Char('l'), KeyModifiers::NONE)),
274 Action::NextTab
275 );
276 assert_eq!(
277 classify(key(KeyCode::Char('h'), KeyModifiers::NONE)),
278 Action::PrevTab
279 );
280 }
281
282 // Tabs and focus are two navigation axes and must stay on separate keys.
283 // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
284 // view, so a tab bar claiming Tab would silently redefine it everywhere.
285 #[test]
286 fn tab_key_still_means_focus_not_tabs() {
287 assert_eq!(
288 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
289 Action::NextFocus
290 );
291 assert_eq!(
292 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
293 Action::PrevFocus
294 );
295 }
296
297 // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
298 // of the vim pair is spoken for.
299 #[test]
300 fn vertical_vim_keys_are_not_claimed_by_tabs() {
301 assert_eq!(
302 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
303 Action::Passthrough
304 );
305 assert_eq!(
306 classify(key(KeyCode::Char('k'), KeyModifiers::NONE)),
307 Action::Passthrough
308 );
309 }
310 }
311