Skip to main content

rab/tui/
keys.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
2
3// =============================================================================
4// Key ID string helpers — pi-compatible string-based key identifiers
5// =============================================================================
6
7/// Format an F-key number into a name like "f1", "f12" etc.
8fn f_key_name(n: u8) -> String {
9    format!("f{}", n)
10}
11
12/// Parse an F-key name like "f1", "f12" into the number.
13fn parse_f_key(key_name: &str) -> Option<u8> {
14    if let Some(rest) = key_name.strip_prefix('f') {
15        rest.parse().ok().filter(|&n: &u8| (1..=24).contains(&n))
16    } else if let Some(rest) = key_name.strip_prefix('F') {
17        rest.parse().ok().filter(|&n: &u8| (1..=24).contains(&n))
18    } else {
19        None
20    }
21}
22
23/// Format a key name with modifiers into a canonical key ID string.
24/// Order: ctrl > shift > alt > super (matching pi convention).
25fn format_key_id(key_name: &str, mods: KeyModifiers) -> String {
26    let mut parts: Vec<&str> = Vec::new();
27    if mods.contains(KeyModifiers::CONTROL) {
28        parts.push("ctrl");
29    }
30    if mods.contains(KeyModifiers::SHIFT) {
31        parts.push("shift");
32    }
33    if mods.contains(KeyModifiers::ALT) {
34        parts.push("alt");
35    }
36    if mods.contains(KeyModifiers::SUPER) {
37        parts.push("super");
38    }
39    if parts.is_empty() {
40        key_name.to_string()
41    } else {
42        parts.push(key_name);
43        parts.join("+")
44    }
45}
46
47/// Convert a crossterm KeyEvent to a pi-compatible key ID string.
48/// Returns None for non-input keys (CapsLock, NumLock, etc.).
49///
50/// Examples: "enter", "escape", "ctrl+c", "shift+tab", "alt+left", "ctrl+shift+p"
51pub fn key_event_to_id(event: &KeyEvent) -> Option<String> {
52    let mods = event.modifiers;
53
54    match event.code {
55        KeyCode::Enter => Some(format_key_id("enter", mods)),
56        KeyCode::Esc => Some("escape".to_string()),
57        KeyCode::Tab => {
58            if mods.contains(KeyModifiers::SHIFT) {
59                Some("shift+tab".to_string())
60            } else {
61                Some(format_key_id("tab", mods))
62            }
63        }
64        KeyCode::Backspace => Some(format_key_id("backspace", mods)),
65        KeyCode::Delete => Some(format_key_id("delete", mods)),
66        KeyCode::Home => Some(format_key_id("home", mods)),
67        KeyCode::End => Some(format_key_id("end", mods)),
68        KeyCode::PageUp => Some(format_key_id("pageUp", mods)),
69        KeyCode::PageDown => Some(format_key_id("pageDown", mods)),
70        KeyCode::Up => Some(format_key_id("up", mods)),
71        KeyCode::Down => Some(format_key_id("down", mods)),
72        KeyCode::Left => Some(format_key_id("left", mods)),
73        KeyCode::Right => Some(format_key_id("right", mods)),
74        KeyCode::BackTab => Some("shift+tab".to_string()),
75        KeyCode::Insert => Some(format_key_id("insert", mods)),
76        KeyCode::F(n) => Some(format_key_id(&f_key_name(n), mods)),
77        KeyCode::Char(c) => {
78            if mods.is_empty() || mods == KeyModifiers::SHIFT {
79                // Plain character (possibly shifted for uppercase)
80                Some(c.to_string())
81            } else if mods.contains(KeyModifiers::CONTROL) && !mods.contains(KeyModifiers::ALT) {
82                // Ctrl+key, possibly with shift or super
83                let mut parts: Vec<String> = Vec::new();
84                parts.push("ctrl".into());
85                if mods.contains(KeyModifiers::SHIFT) {
86                    parts.push("shift".into());
87                }
88                if mods.contains(KeyModifiers::SUPER) {
89                    parts.push("super".into());
90                }
91                let lower = c.to_ascii_lowercase();
92                Some(format!("{}+{}", parts.join("+"), lower))
93            } else if mods.contains(KeyModifiers::ALT) {
94                let mut parts: Vec<String> = Vec::new();
95                if mods.contains(KeyModifiers::CONTROL) {
96                    parts.push("ctrl".into());
97                }
98                parts.push("alt".into());
99                if mods.contains(KeyModifiers::SHIFT) {
100                    parts.push("shift".into());
101                }
102                if mods.contains(KeyModifiers::SUPER) {
103                    parts.push("super".into());
104                }
105                Some(format!("{}+{}", parts.join("+"), c))
106            } else {
107                Some(c.to_string())
108            }
109        }
110
111        KeyCode::Null
112        | KeyCode::CapsLock
113        | KeyCode::ScrollLock
114        | KeyCode::NumLock
115        | KeyCode::PrintScreen
116        | KeyCode::Pause
117        | KeyCode::Menu
118        | KeyCode::KeypadBegin
119        | KeyCode::Media(_)
120        | KeyCode::Modifier(_) => None,
121    }
122}
123
124/// Parse a key ID string into its components: (key_name, ctrl, shift, alt, super).
125/// Returns None if the string is not a valid key ID.
126fn parse_key_id(key_id: &str) -> Option<(&str, bool, bool, bool, bool)> {
127    if key_id.is_empty() {
128        return None;
129    }
130    let parts: Vec<&str> = key_id.split('+').collect();
131    if parts.is_empty() {
132        return None;
133    }
134    let key = parts[parts.len() - 1];
135    let mut ctrl = false;
136    let mut shift = false;
137    let mut alt = false;
138    let mut super_mod = false;
139
140    for p in &parts[..parts.len() - 1] {
141        match *p {
142            "ctrl" => ctrl = true,
143            "shift" => shift = true,
144            "alt" => alt = true,
145            "super" => super_mod = true,
146            _ => return None, // Unknown modifier
147        }
148    }
149
150    Some((key, ctrl, shift, alt, super_mod))
151}
152
153/// Match a crossterm KeyEvent against a key ID string.
154/// Handles relaxed modifier matching — extra modifiers don't cause non-match
155/// if the component doesn't need them (e.g., "enter" matches Shift+Enter too).
156pub fn match_key_id(event: &KeyEvent, key_id: &str) -> bool {
157    let Some((key, wants_ctrl, wants_shift, wants_alt, wants_super)) = parse_key_id(key_id) else {
158        return false;
159    };
160
161    let mods = event.modifiers;
162    let has_ctrl = mods.contains(KeyModifiers::CONTROL);
163    let has_shift = mods.contains(KeyModifiers::SHIFT);
164    let has_alt = mods.contains(KeyModifiers::ALT);
165    let has_super = mods.contains(KeyModifiers::SUPER);
166
167    // Special case: BackTab is inherently Shift+Tab. If the key ID wants
168    // shift and the event is BackTab, treat it as having the shift modifier.
169    let is_backtab = event.code == KeyCode::BackTab;
170    let wants_tab = key == "tab";
171
172    // Treat BackTab as having an implicit shift modifier (only for the
173    // "wants_shift" check — actual has_shift is used for rejecting extra shift).
174    let wanted_shift = has_shift || (is_backtab && wants_tab);
175
176    // ── Required-modifier check ──
177    // If the key ID requests a modifier, the event must have it.
178    if wants_ctrl && !has_ctrl {
179        return false;
180    }
181    if wants_shift && !wanted_shift {
182        return false;
183    }
184    if wants_alt && !has_alt {
185        return false;
186    }
187    if wants_super && !has_super {
188        return false;
189    }
190
191    // ── Extra-modifier rejection ──
192    // If the key ID does NOT request a modifier, extra instances of that
193    // modifier on the event cause a non-match.  The only exception is shift
194    // when it only changes case (uppercase letter or shifted symbol).
195    if !wants_ctrl && has_ctrl {
196        return false;
197    }
198    if !wants_alt && has_alt {
199        return false;
200    }
201    if !wants_super && has_super {
202        return false;
203    }
204    // Shift is special: lowercase key "p" with shift modifier could just be
205    // the user pressing Shift+P (uppercase). Allow shift when the expected
206    // key is an uppercase letter or shifted symbol.  For BackTab we already
207    // handle it via effective_shift — it counts as having shift implicitly.
208    if !wants_shift && has_shift && !is_backtab {
209        // Allow shift only for letters where key_name is the uppercase version
210        // or for symbols that require shift
211        let shiftable = key.len() == 1 && {
212            let c = key.chars().next().unwrap();
213            c.is_ascii_uppercase()
214                || c.is_ascii_digit()
215                || matches!(
216                    c,
217                    '!' | '@'
218                        | '#'
219                        | '$'
220                        | '%'
221                        | '^'
222                        | '&'
223                        | '*'
224                        | '('
225                        | ')'
226                        | '_'
227                        | '+'
228                        | '|'
229                        | '~'
230                        | '{'
231                        | '}'
232                        | ':'
233                        | '"'
234                        | '<'
235                        | '>'
236                        | '?'
237                )
238        };
239        if !shiftable {
240            return false;
241        }
242    }
243
244    // BackTab (shift+tab) should only match key IDs that explicitly request shift
245    if event.code == KeyCode::BackTab && !wants_shift {
246        return false;
247    }
248
249    // Match the key name against the event code
250    matches_key_name(&event.code, key)
251}
252
253/// Check if a KeyCode matches a key name string.
254fn matches_key_name(code: &KeyCode, key_name: &str) -> bool {
255    match code {
256        KeyCode::Enter => key_name == "enter" || key_name == "return",
257        KeyCode::Esc => key_name == "escape" || key_name == "esc",
258        KeyCode::Tab | KeyCode::BackTab => key_name == "tab",
259        KeyCode::Backspace => key_name == "backspace",
260        KeyCode::Delete => key_name == "delete",
261        KeyCode::Home => key_name == "home",
262        KeyCode::End => key_name == "end",
263        KeyCode::PageUp => key_name == "pageUp" || key_name == "pageup",
264        KeyCode::PageDown => key_name == "pageDown" || key_name == "pagedown",
265        KeyCode::Up => key_name == "up",
266        KeyCode::Down => key_name == "down",
267        KeyCode::Left => key_name == "left",
268        KeyCode::Right => key_name == "right",
269        KeyCode::Insert => key_name == "insert",
270        KeyCode::F(n) => Some(*n) == parse_f_key(key_name),
271        KeyCode::Char(c) if key_name.len() == 1 => {
272            // Single character — compare case-insensitively
273            let key_char = key_name.chars().next().unwrap();
274            c.eq_ignore_ascii_case(&key_char)
275        }
276        _ => false,
277    }
278}
279
280/// Check if a key event is a release event (Kitty keyboard protocol flag 2).
281pub fn is_key_release(event: &KeyEvent) -> bool {
282    event.kind == KeyEventKind::Release
283}
284
285/// Check if a key event is a repeat event (Kitty keyboard protocol flag 2).
286pub fn is_key_repeat(event: &KeyEvent) -> bool {
287    event.kind == KeyEventKind::Repeat
288}
289
290/// Decode a printable character from a key event.
291/// Since crossterm already decodes CSI-u sequences, this is equivalent to
292/// `key_event_to_string` for printable characters.
293pub fn decode_kitty_printable(event: &KeyEvent) -> Option<String> {
294    match event.code {
295        KeyCode::Char(c)
296            if !event.modifiers.contains(KeyModifiers::CONTROL)
297                && !event.modifiers.contains(KeyModifiers::ALT) =>
298        {
299            Some(c.to_string())
300        }
301        _ => None,
302    }
303}
304
305// =============================================================================
306// Legacy Key enum (backward compat) — will be removed after full migration
307// =============================================================================
308
309/// Key identifiers for matching keyboard input.
310/// Use `Key::Enter`, `Key::Ctrl('c')`, `Key::CtrlShift('p')` etc.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum Key {
313    Enter,
314    Escape,
315    Tab,
316    Backspace,
317    Delete,
318    Home,
319    End,
320    PageUp,
321    PageDown,
322    Up,
323    Down,
324    Left,
325    Right,
326    Space,
327    /// Single character (printable ASCII)
328    Char(char),
329    /// Ctrl + character
330    Ctrl(char),
331    /// Alt + character
332    Alt(char),
333    /// Shift + Tab
334    ShiftTab,
335    /// Ctrl + Shift + character
336    CtrlShift(char),
337    /// Alt + arrow
338    AltLeft,
339    AltRight,
340    /// Ctrl + arrow
341    CtrlLeft,
342    CtrlRight,
343}
344
345impl Key {
346    pub fn enter() -> Self {
347        Key::Enter
348    }
349    pub fn escape() -> Self {
350        Key::Escape
351    }
352    pub fn tab() -> Self {
353        Key::Tab
354    }
355    pub fn space() -> Self {
356        Key::Space
357    }
358    pub fn backspace() -> Self {
359        Key::Backspace
360    }
361    pub fn delete() -> Self {
362        Key::Delete
363    }
364    pub fn home() -> Self {
365        Key::Home
366    }
367    pub fn end() -> Self {
368        Key::End
369    }
370    pub fn up() -> Self {
371        Key::Up
372    }
373    pub fn down() -> Self {
374        Key::Down
375    }
376    pub fn left() -> Self {
377        Key::Left
378    }
379    pub fn right() -> Self {
380        Key::Right
381    }
382    pub fn page_up() -> Self {
383        Key::PageUp
384    }
385    pub fn page_down() -> Self {
386        Key::PageDown
387    }
388    pub fn ctrl(c: char) -> Self {
389        Key::Ctrl(c.to_ascii_lowercase())
390    }
391    pub fn alt(c: char) -> Self {
392        Key::Alt(c)
393    }
394    pub fn shift_tab() -> Self {
395        Key::ShiftTab
396    }
397    pub fn ctrl_shift(c: char) -> Self {
398        Key::CtrlShift(c.to_ascii_lowercase())
399    }
400    pub fn alt_left() -> Self {
401        Key::AltLeft
402    }
403    pub fn alt_right() -> Self {
404        Key::AltRight
405    }
406    pub fn ctrl_left() -> Self {
407        Key::CtrlLeft
408    }
409    pub fn ctrl_right() -> Self {
410        Key::CtrlRight
411    }
412}
413
414/// Check if a crossterm KeyEvent matches a Key identifier.
415pub fn matches_key(event: &KeyEvent, key: &Key) -> bool {
416    match key {
417        Key::Enter => event.code == KeyCode::Enter,
418        Key::Escape => event.code == KeyCode::Esc,
419        Key::Tab => event.code == KeyCode::Tab,
420        Key::Backspace => event.code == KeyCode::Backspace,
421        Key::Delete => event.code == KeyCode::Delete,
422        Key::Home => event.code == KeyCode::Home,
423        Key::End => event.code == KeyCode::End,
424        Key::PageUp => event.code == KeyCode::PageUp,
425        Key::PageDown => event.code == KeyCode::PageDown,
426        Key::Up => event.code == KeyCode::Up,
427        Key::Down => event.code == KeyCode::Down,
428        Key::Left => event.code == KeyCode::Left,
429        Key::Right => event.code == KeyCode::Right,
430        Key::Space => event.code == KeyCode::Char(' '),
431        Key::Char(c) => {
432            event.code == KeyCode::Char(*c)
433                && !event.modifiers.contains(KeyModifiers::CONTROL)
434                && !event.modifiers.contains(KeyModifiers::ALT)
435        }
436        Key::Ctrl(c) => {
437            event.code == KeyCode::Char(c.to_ascii_lowercase())
438                && event.modifiers.contains(KeyModifiers::CONTROL)
439                && !event.modifiers.contains(KeyModifiers::ALT)
440        }
441        Key::Alt(c) => {
442            event.code == KeyCode::Char(*c)
443                && event.modifiers.contains(KeyModifiers::ALT)
444                && !event.modifiers.contains(KeyModifiers::CONTROL)
445        }
446        Key::ShiftTab => {
447            event.code == KeyCode::BackTab
448                || (event.code == KeyCode::Tab && event.modifiers.contains(KeyModifiers::SHIFT))
449        }
450        Key::CtrlShift(c) => {
451            event.code == KeyCode::Char(c.to_ascii_lowercase())
452                && event.modifiers.contains(KeyModifiers::CONTROL)
453                && event.modifiers.contains(KeyModifiers::SHIFT)
454        }
455        Key::AltLeft => event.code == KeyCode::Left && event.modifiers.contains(KeyModifiers::ALT),
456        Key::AltRight => {
457            event.code == KeyCode::Right && event.modifiers.contains(KeyModifiers::ALT)
458        }
459        Key::CtrlLeft => {
460            event.code == KeyCode::Left && event.modifiers.contains(KeyModifiers::CONTROL)
461        }
462        Key::CtrlRight => {
463            event.code == KeyCode::Right && event.modifiers.contains(KeyModifiers::CONTROL)
464        }
465    }
466}
467
468/// Convert a printable KeyEvent to a string representation.
469pub fn key_event_to_string(event: &KeyEvent) -> Option<String> {
470    match event.code {
471        KeyCode::Char(c) => {
472            if event.modifiers.is_empty() || event.modifiers == KeyModifiers::SHIFT {
473                Some(c.to_string())
474            } else {
475                None
476            }
477        }
478        KeyCode::Enter => Some("\n".to_string()),
479        KeyCode::Tab => Some("\t".to_string()),
480        _ => None,
481    }
482}
483
484/// Check if a key event is a printable character (no modifiers except shift).
485pub fn is_printable(event: &KeyEvent) -> bool {
486    matches!(event.code, KeyCode::Char(_))
487        && !event.modifiers.contains(KeyModifiers::CONTROL)
488        && !event.modifiers.contains(KeyModifiers::ALT)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn test_key_event_to_id_enter() {
497        let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
498        assert_eq!(key_event_to_id(&event), Some("enter".into()));
499    }
500
501    #[test]
502    fn test_key_event_to_id_escape() {
503        let event = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
504        assert_eq!(key_event_to_id(&event), Some("escape".into()));
505    }
506
507    #[test]
508    fn test_key_event_to_id_ctrl_c() {
509        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
510        assert_eq!(key_event_to_id(&event), Some("ctrl+c".into()));
511    }
512
513    #[test]
514    fn test_key_event_to_id_char() {
515        let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
516        assert_eq!(key_event_to_id(&event), Some("a".into()));
517    }
518
519    #[test]
520    fn test_key_event_to_id_shift_tab() {
521        let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
522        assert_eq!(key_event_to_id(&event), Some("shift+tab".into()));
523    }
524
525    #[test]
526    fn test_key_event_to_id_ctrl_shift() {
527        let event = KeyEvent::new(
528            KeyCode::Char('p'),
529            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
530        );
531        assert_eq!(key_event_to_id(&event), Some("ctrl+shift+p".into()));
532    }
533
534    #[test]
535    fn test_key_event_to_id_alt_left() {
536        let event = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
537        assert_eq!(key_event_to_id(&event), Some("alt+left".into()));
538    }
539
540    #[test]
541    fn test_key_event_to_id_ctrl_left() {
542        let event = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL);
543        assert_eq!(key_event_to_id(&event), Some("ctrl+left".into()));
544    }
545
546    #[test]
547    fn test_match_key_id_exact() {
548        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
549        assert!(match_key_id(&event, "ctrl+c"));
550        assert!(!match_key_id(&event, "ctrl+x"));
551    }
552
553    #[test]
554    fn test_match_key_id_no_extra_modifiers() {
555        // "enter" should not match ctrl+enter
556        let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
557        assert!(!match_key_id(&event, "enter"));
558    }
559
560    // Legacy tests below ===============================================
561
562    #[test]
563    fn test_matches_enter() {
564        let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
565        assert!(matches_key(&event, &Key::Enter));
566    }
567
568    #[test]
569    fn test_matches_escape() {
570        let event = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
571        assert!(matches_key(&event, &Key::Escape));
572    }
573
574    #[test]
575    fn test_matches_ctrl_c() {
576        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
577        assert!(matches_key(&event, &Key::Ctrl('c')));
578        assert!(!matches_key(&event, &Key::Char('c')));
579    }
580
581    #[test]
582    fn test_matches_char() {
583        let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
584        assert!(matches_key(&event, &Key::Char('a')));
585        assert!(!matches_key(&event, &Key::Ctrl('a')));
586    }
587
588    #[test]
589    fn test_matches_arrow_keys() {
590        assert!(matches_key(
591            &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
592            &Key::Up
593        ));
594        assert!(matches_key(
595            &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
596            &Key::Down
597        ));
598        assert!(matches_key(
599            &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
600            &Key::Left
601        ));
602        assert!(matches_key(
603            &KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
604            &Key::Right
605        ));
606    }
607
608    #[test]
609    fn test_shift_tab() {
610        assert!(matches_key(
611            &KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
612            &Key::ShiftTab
613        ));
614    }
615
616    #[test]
617    fn test_ctrl_shift() {
618        let event = KeyEvent::new(
619            KeyCode::Char('p'),
620            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
621        );
622        assert!(matches_key(&event, &Key::CtrlShift('p')));
623    }
624
625    #[test]
626    fn test_is_printable() {
627        assert!(is_printable(&KeyEvent::new(
628            KeyCode::Char('a'),
629            KeyModifiers::NONE
630        )));
631        assert!(!is_printable(&KeyEvent::new(
632            KeyCode::Char('c'),
633            KeyModifiers::CONTROL
634        )));
635        assert!(!is_printable(&KeyEvent::new(
636            KeyCode::Enter,
637            KeyModifiers::NONE
638        )));
639    }
640
641    #[test]
642    fn test_key_event_to_id_up() {
643        let event = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
644        assert_eq!(key_event_to_id(&event), Some("up".into()));
645    }
646
647    #[test]
648    fn test_key_event_to_id_backspace() {
649        let event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
650        assert_eq!(key_event_to_id(&event), Some("backspace".into()));
651    }
652}