Skip to main content

max / alloy_tui

6.0 KB · 175 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' | '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!(
89 classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)),
90 Action::Save
91 );
92 assert_eq!(
93 classify(key(KeyCode::Char('s'), KeyModifiers::NONE)),
94 Action::Passthrough
95 );
96 }
97
98 // Terminals disagree on how they report Shift-Tab: some send BackTab with no
99 // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
100 // navigation silently becomes one-directional on half the terminal emulators
101 // in the stack.
102 #[test]
103 fn both_shift_tab_encodings_move_focus_backward() {
104 assert_eq!(
105 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
106 Action::PrevFocus
107 );
108 assert_eq!(
109 classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
110 Action::PrevFocus
111 );
112 assert_eq!(
113 classify(key(KeyCode::Tab, KeyModifiers::SHIFT)),
114 Action::PrevFocus
115 );
116 assert_eq!(
117 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
118 Action::NextFocus
119 );
120 }
121
122 #[test]
123 fn unreserved_keys_pass_through() {
124 assert_eq!(
125 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
126 Action::Passthrough
127 );
128 assert_eq!(
129 classify(key(KeyCode::Down, KeyModifiers::NONE)),
130 Action::Passthrough
131 );
132 }
133
134 #[test]
135 fn h_and_l_move_between_tabs() {
136 assert_eq!(
137 classify(key(KeyCode::Char('l'), KeyModifiers::NONE)),
138 Action::NextTab
139 );
140 assert_eq!(
141 classify(key(KeyCode::Char('h'), KeyModifiers::NONE)),
142 Action::PrevTab
143 );
144 }
145
146 // Tabs and focus are two navigation axes and must stay on separate keys.
147 // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
148 // view, so a tab bar claiming Tab would silently redefine it everywhere.
149 #[test]
150 fn tab_key_still_means_focus_not_tabs() {
151 assert_eq!(
152 classify(key(KeyCode::Tab, KeyModifiers::NONE)),
153 Action::NextFocus
154 );
155 assert_eq!(
156 classify(key(KeyCode::BackTab, KeyModifiers::NONE)),
157 Action::PrevFocus
158 );
159 }
160
161 // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
162 // of the vim pair is spoken for.
163 #[test]
164 fn vertical_vim_keys_are_not_claimed_by_tabs() {
165 assert_eq!(
166 classify(key(KeyCode::Char('j'), KeyModifiers::NONE)),
167 Action::Passthrough
168 );
169 assert_eq!(
170 classify(key(KeyCode::Char('k'), KeyModifiers::NONE)),
171 Action::Passthrough
172 );
173 }
174 }
175