Skip to main content

max / alloy_tui

5.5 KB · 133 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 /// Classify a key event against the reserved keymap.
39 ///
40 /// This is a pure classifier with no notion of mode: it reports what a key
41 /// *means* in the reserved map, not whether the app should honor it. Two
42 /// caller obligations follow from that:
43 ///
44 /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both
45 /// press and release for every key, so an unfiltered event loop performs
46 /// each action twice.
47 /// - **Ignore the character actions while text entry has focus.** `q`, `/`,
48 /// `:`, `h`, and `l` are literal characters a user types into a field; a view
49 /// holding an active text input should route keys to the input and consult
50 /// this classifier only for `Cancel`, `Save`, and the focus movers.
51 ///
52 /// `h` and `l` make this obligation sharper than it was. The earlier
53 /// character actions were punctuation and one letter that rarely opens a
54 /// word; `h` and `l` are ordinary letters that appear in almost any typed
55 /// value, so a view that forwards raw keys to an input without this check
56 /// now changes tabs mid-word rather than merely on a stray `q`.
57 pub fn classify(key: KeyEvent) -> Action {
58 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
59 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
60
61 match key.code {
62 KeyCode::Char('s') | KeyCode::Char('S') if ctrl => Action::Save,
63 KeyCode::BackTab => Action::PrevFocus,
64 KeyCode::Tab if shift => Action::PrevFocus,
65 KeyCode::Tab => Action::NextFocus,
66 KeyCode::Char('l') => Action::NextTab,
67 KeyCode::Char('h') => Action::PrevTab,
68 KeyCode::Enter => Action::Activate,
69 KeyCode::Esc => Action::Cancel,
70 KeyCode::Char('?') => Action::Help,
71 KeyCode::Char('q') => Action::Quit,
72 KeyCode::Char('/') => Action::Filter,
73 KeyCode::Char(':') => Action::Command,
74 _ => Action::Passthrough,
75 }
76 }
77
78 #[cfg(test)]
79 mod tests {
80 use super::*;
81
82 fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
83 KeyEvent::new(code, mods)
84 }
85
86 #[test]
87 fn ctrl_s_saves_but_bare_s_does_not() {
88 assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save);
89 assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough);
90 }
91
92 // Terminals disagree on how they report Shift-Tab: some send BackTab with no
93 // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
94 // navigation silently becomes one-directional on half the terminal emulators
95 // in the stack.
96 #[test]
97 fn both_shift_tab_encodings_move_focus_backward() {
98 assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
99 assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus);
100 assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus);
101 assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
102 }
103
104 #[test]
105 fn unreserved_keys_pass_through() {
106 assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
107 assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough);
108 }
109
110 #[test]
111 fn h_and_l_move_between_tabs() {
112 assert_eq!(classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab);
113 assert_eq!(classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab);
114 }
115
116 // Tabs and focus are two navigation axes and must stay on separate keys.
117 // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
118 // view, so a tab bar claiming Tab would silently redefine it everywhere.
119 #[test]
120 fn tab_key_still_means_focus_not_tabs() {
121 assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
122 assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
123 }
124
125 // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
126 // of the vim pair is spoken for.
127 #[test]
128 fn vertical_vim_keys_are_not_claimed_by_tabs() {
129 assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
130 assert_eq!(classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough);
131 }
132 }
133