Skip to main content

max / alloy_tui

9.8 KB · 309 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 the Alloy repo's docs/COMPONENT-LIBRARY.md the reserved keys live in
5 //! exactly one place so apps match against `Action` rather than hardcoding
6 //! keycodes: `Tab` / `Shift-Tab` move focus, `l` / `h` move between tabs,
7 //! `Enter` activates, `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens
8 //! help, `/` filters, `:` 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 sharp. They are ordinary letters that
131 /// appear in almost any typed value, so a view that forwards raw keys to an
132 /// input without this check changes tabs mid-word.
133 pub fn classify(key: KeyEvent) -> Action {
134 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
135 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
136
137 match key.code {
138 KeyCode::Char('s' | 'S') if ctrl => Action::Save,
139 KeyCode::BackTab => Action::PrevFocus,
140 KeyCode::Tab if shift => Action::PrevFocus,
141 KeyCode::Tab => Action::NextFocus,
142 KeyCode::Char('l') => Action::NextTab,
143 KeyCode::Char('h') => Action::PrevTab,
144 KeyCode::Enter => Action::Activate,
145 KeyCode::Esc => Action::Cancel,
146 KeyCode::Char('?') => Action::Help,
147 KeyCode::Char('q') => Action::Quit,
148 KeyCode::Char('/') => Action::Filter,
149 KeyCode::Char(':') => Action::Command,
150 _ => Action::Passthrough,
151 }
152 }
153
154 #[cfg(test)]
155 mod tests {
156 use super::*;
157
158 fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
159 KeyEvent::new(code, mods)
160 }
161
162 /// The keystroke a [`ReservedKey`] prints, read back as an event.
163 fn parse(spelled: &str) -> KeyEvent {
164 match spelled {
165 "Tab" => key(KeyCode::Tab, KeyModifiers::NONE),
166 "Shift-Tab" => key(KeyCode::BackTab, KeyModifiers::NONE),
167 "Enter" => key(KeyCode::Enter, KeyModifiers::NONE),
168 "Esc" => key(KeyCode::Esc, KeyModifiers::NONE),
169 "Ctrl-S" => key(KeyCode::Char('s'), KeyModifiers::CONTROL),
170 other => {
171 let mut chars = other.chars();
172 let c = chars.next().expect("a key spelling is not empty");
173 assert!(chars.next().is_none(), "unhandled spelling: {other}");
174 key(KeyCode::Char(c), KeyModifiers::NONE)
175 }
176 }
177 }
178
179 // The help screen reads RESERVED and the app reads classify(). If they can
180 // drift, the help teaches keys that do nothing, which is worse than showing
181 // no help at all.
182 #[test]
183 fn reserved_table_matches_the_classifier() {
184 for entry in RESERVED {
185 assert_eq!(
186 classify(parse(entry.key)),
187 entry.action,
188 "{} is documented as {:?}",
189 entry.key,
190 entry.action
191 );
192 }
193 }
194
195 // Every action a user can reach by pressing something is in the table.
196 // Passthrough is the exception by definition: it is the absence of a
197 // reserved meaning.
198 #[test]
199 fn every_reserved_action_is_documented() {
200 for action in [
201 Action::NextFocus,
202 Action::PrevFocus,
203 Action::NextTab,
204 Action::PrevTab,
205 Action::Activate,
206 Action::Cancel,
207 Action::Save,
208 Action::Help,
209 Action::Quit,
210 Action::Filter,
211 Action::Command,
212 ] {
213 assert!(
214 RESERVED.iter().any(|r| r.action == action),
215 "{action:?} is reachable but absent from RESERVED"
216 );
217 }
218 }
219
220 #[test]
221 fn ctrl_s_saves_but_bare_s_does_not() {
222 assert_eq!(
223 classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)),
224 Action::Save
225 );
226 assert_eq!(
227 classify(key(KeyCode::Char('s'), KeyModifiers::NONE)),
228 Action::Passthrough
229 );
230 }
231
232 // Terminals disagree on how they report Shift-Tab: some send BackTab with no
233 // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
234 // navigation silently becomes one-directional on half the terminal emulators
235 // in the stack.
236 #[test]
237 fn both_shift_tab_encodings_move_focus_backward() {
238 assert_eq!(
239 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
240 Action::PrevFocus
241 );
242 assert_eq!(
243 classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
244 Action::PrevFocus
245 );
246 assert_eq!(
247 classify(key(KeyCode::Tab, KeyModifiers::SHIFT)),
248 Action::PrevFocus
249 );
250 assert_eq!(
251 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
252 Action::NextFocus
253 );
254 }
255
256 #[test]
257 fn unreserved_keys_pass_through() {
258 assert_eq!(
259 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
260 Action::Passthrough
261 );
262 assert_eq!(
263 classify(key(KeyCode::Down, KeyModifiers::NONE)),
264 Action::Passthrough
265 );
266 }
267
268 #[test]
269 fn h_and_l_move_between_tabs() {
270 assert_eq!(
271 classify(key(KeyCode::Char('l'), KeyModifiers::NONE)),
272 Action::NextTab
273 );
274 assert_eq!(
275 classify(key(KeyCode::Char('h'), KeyModifiers::NONE)),
276 Action::PrevTab
277 );
278 }
279
280 // Tabs and focus are two navigation axes and must stay on separate keys.
281 // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
282 // view, so a tab bar claiming Tab would silently redefine it everywhere.
283 #[test]
284 fn tab_key_still_means_focus_not_tabs() {
285 assert_eq!(
286 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
287 Action::NextFocus
288 );
289 assert_eq!(
290 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
291 Action::PrevFocus
292 );
293 }
294
295 // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
296 // of the vim pair is spoken for.
297 #[test]
298 fn vertical_vim_keys_are_not_claimed_by_tabs() {
299 assert_eq!(
300 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
301 Action::Passthrough
302 );
303 assert_eq!(
304 classify(key(KeyCode::Char('k'), KeyModifiers::NONE)),
305 Action::Passthrough
306 );
307 }
308 }
309