Skip to main content

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}