supercode_harness/tui/key.rs
1//! P5-4 (§2 module 30): a terminal-library-agnostic key event — the
2//! [`crate::tui::state::TuiState::handle_key`] state machine consumes THIS
3//! type, not `crossterm::event::KeyEvent`, so the whole view-model core
4//! stays free of a `crossterm`/`ratatui` dependency (neither is in
5//! `crates/harness`'s `Cargo.toml` — see that crate's own doc comment for why:
6//! a real terminal can't be driven in a unit test, but this struct can be
7//! constructed by hand). `crates/cli`'s render/event-loop layer is the one
8//! place that translates a real `crossterm::event::KeyEvent` into this
9//! shape before handing it to the state machine.
10
11/// One logical key, independent of any terminal library's own enum.
12/// `#[non_exhaustive]` so a future key (e.g. a specific F-key past `F(12)`,
13/// or a media key) can be added without breaking a downstream `match`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum Key {
17 /// A printable character (already case-correct — `Shift` is folded in
18 /// by the translator, not carried as a separate modifier for letters).
19 Char(char),
20 /// Enter/Return.
21 Enter,
22 /// Escape.
23 Escape,
24 /// Backspace.
25 Backspace,
26 /// Delete (forward-delete).
27 Delete,
28 /// Tab.
29 Tab,
30 /// Shift+Tab.
31 BackTab,
32 /// Left arrow.
33 Left,
34 /// Right arrow.
35 Right,
36 /// Up arrow.
37 Up,
38 /// Down arrow.
39 Down,
40 /// Home.
41 Home,
42 /// End.
43 End,
44 /// Page Up.
45 PageUp,
46 /// Page Down.
47 PageDown,
48 /// A function key, `F(1)..=F(12)`.
49 F(u8),
50}
51
52/// A [`Key`] plus the modifier keys held with it. Named fields (not a
53/// bitflag) so a test/keymap-string reader can construct one by hand
54/// without needing to know a bit layout.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct KeyEvent {
57 /// The key itself.
58 pub key: Key,
59 /// Ctrl held.
60 pub ctrl: bool,
61 /// Alt held.
62 pub alt: bool,
63 /// Shift held. Deliberately still tracked even for [`Key::Char`] (whose
64 /// case already reflects shift) because some non-alpha chars (arrows,
65 /// tab) have a distinct shifted meaning (`BackTab` for Shift+Tab is
66 /// modeled as its own [`Key`] variant by the translator, not via this
67 /// flag — this flag exists for completeness/testability of a caller
68 /// that wants to distinguish, e.g., a raw Shift+Left selection chord in
69 /// a future extension).
70 pub shift: bool,
71}
72
73impl KeyEvent {
74 /// A plain, unmodified key — the common case in tests/default bindings.
75 pub const fn plain(key: Key) -> Self {
76 KeyEvent {
77 key,
78 ctrl: false,
79 alt: false,
80 shift: false,
81 }
82 }
83
84 /// A Ctrl+`key` chord.
85 pub const fn ctrl(key: Key) -> Self {
86 KeyEvent {
87 key,
88 ctrl: true,
89 alt: false,
90 shift: false,
91 }
92 }
93
94 /// A plain printable character — shorthand for
95 /// `KeyEvent::plain(Key::Char(c))`, the single most common test/keymap
96 /// construction.
97 pub const fn ch(c: char) -> Self {
98 KeyEvent::plain(Key::Char(c))
99 }
100
101 /// Parse the small keymap-string syntax `capabilities.tui.keymap.<action>
102 /// = "<key>"` (§3.1) understands: an optional `ctrl+`/`alt+`/`shift+`
103 /// prefix (any order, `+`-joined) followed by either a single
104 /// character or a named key (`enter`, `esc`/`escape`, `backspace`,
105 /// `delete`/`del`, `tab`, `up`, `down`, `left`, `right`, `home`, `end`,
106 /// `pageup`/`pgup`, `pagedown`/`pgdn`, `f1`..`f12`). Case-insensitive
107 /// except the bare single-character form, which is taken literally (so
108 /// `"R"` and `"r"` are distinct plain characters, while `"Ctrl+R"` and
109 /// `"ctrl+r"` are the same chord). Returns `None` for anything it
110 /// doesn't recognize — a caller (`crate::tui::Keymap::with_overrides`)
111 /// treats an unparseable override as "keep the default", never a panic
112 /// or a silently-wrong binding.
113 pub fn parse(spec: &str) -> Option<Self> {
114 let mut ctrl = false;
115 let mut alt = false;
116 let mut shift = false;
117 let mut last = spec.trim();
118 loop {
119 let lower = last.to_ascii_lowercase();
120 if let Some(rest) = lower.strip_prefix("ctrl+") {
121 ctrl = true;
122 last = &last[last.len() - rest.len()..];
123 } else if let Some(rest) = lower.strip_prefix("alt+") {
124 alt = true;
125 last = &last[last.len() - rest.len()..];
126 } else if let Some(rest) = lower.strip_prefix("shift+") {
127 shift = true;
128 last = &last[last.len() - rest.len()..];
129 } else {
130 break;
131 }
132 }
133 let key = if last.chars().count() == 1 {
134 Key::Char(last.chars().next()?)
135 } else {
136 match last.to_ascii_lowercase().as_str() {
137 "enter" | "return" => Key::Enter,
138 "esc" | "escape" => Key::Escape,
139 "backspace" => Key::Backspace,
140 "delete" | "del" => Key::Delete,
141 "tab" => Key::Tab,
142 "backtab" => Key::BackTab,
143 "up" => Key::Up,
144 "down" => Key::Down,
145 "left" => Key::Left,
146 "right" => Key::Right,
147 "home" => Key::Home,
148 "end" => Key::End,
149 "pageup" | "pgup" => Key::PageUp,
150 "pagedown" | "pgdn" => Key::PageDown,
151 other if other.starts_with('f') && other.len() <= 3 => {
152 let n: u8 = other[1..].parse().ok()?;
153 if (1..=12).contains(&n) {
154 Key::F(n)
155 } else {
156 return None;
157 }
158 }
159 _ => return None,
160 }
161 };
162 Some(KeyEvent {
163 key,
164 ctrl,
165 alt,
166 shift,
167 })
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn parse_plain_char() {
177 assert_eq!(KeyEvent::parse("r"), Some(KeyEvent::ch('r')));
178 assert_eq!(KeyEvent::parse("R"), Some(KeyEvent::ch('R')));
179 }
180
181 #[test]
182 fn parse_ctrl_chord_case_insensitive() {
183 assert_eq!(
184 KeyEvent::parse("Ctrl+R"),
185 Some(KeyEvent::ctrl(Key::Char('R')))
186 );
187 assert_eq!(
188 KeyEvent::parse("ctrl+r"),
189 Some(KeyEvent::ctrl(Key::Char('r')))
190 );
191 }
192
193 #[test]
194 fn parse_named_keys() {
195 assert_eq!(KeyEvent::parse("enter"), Some(KeyEvent::plain(Key::Enter)));
196 assert_eq!(KeyEvent::parse("Esc"), Some(KeyEvent::plain(Key::Escape)));
197 assert_eq!(
198 KeyEvent::parse("ctrl+pgup"),
199 Some(KeyEvent::ctrl(Key::PageUp))
200 );
201 }
202
203 #[test]
204 fn parse_function_keys() {
205 assert_eq!(KeyEvent::parse("f1"), Some(KeyEvent::plain(Key::F(1))));
206 assert_eq!(KeyEvent::parse("F12"), Some(KeyEvent::plain(Key::F(12))));
207 assert_eq!(KeyEvent::parse("f13"), None);
208 assert_eq!(KeyEvent::parse("f0"), None);
209 }
210
211 #[test]
212 fn parse_multiple_modifiers() {
213 assert_eq!(
214 KeyEvent::parse("ctrl+alt+x"),
215 Some(KeyEvent {
216 key: Key::Char('x'),
217 ctrl: true,
218 alt: true,
219 shift: false
220 })
221 );
222 }
223
224 #[test]
225 fn parse_unknown_returns_none() {
226 assert_eq!(KeyEvent::parse(""), None);
227 assert_eq!(KeyEvent::parse("bogus-key"), None);
228 assert_eq!(KeyEvent::parse("ctrl+"), None);
229 }
230}