Skip to main content

pinto/
kanban_keys.rs

1//! Configurable Kanban key names and platform-neutral key matching.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6
7/// Actions that can be assigned one or more keys in the user `[tui.key_bindings]` table.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum KeyAction {
10    /// Leave Kanban mode.
11    Quit,
12    /// Leave Kanban mode and hand control to the shell.
13    Shell,
14    /// Select the column to the left.
15    SelectLeft,
16    /// Select the column to the right.
17    SelectRight,
18    /// Select the row above.
19    SelectUp,
20    /// Select the row below.
21    SelectDown,
22    /// Move the selected item to the column on the left.
23    MoveLeft,
24    /// Move the selected item to the column on the right.
25    MoveRight,
26    /// Reorder the selected item upwards.
27    ReorderUp,
28    /// Reorder the selected item downwards.
29    ReorderDown,
30    /// Expand or collapse the selected parent item.
31    ToggleExpand,
32    /// Open the form for adding a new PBI.
33    Add,
34    /// Open the form for adding a dependency to the selected PBI.
35    DependencyAdd,
36    /// Open the form for removing a dependency from the selected PBI.
37    DependencyRemove,
38    /// Open the form for setting or clearing the selected PBI's parent.
39    Parent,
40    /// Open the form for splitting the selected PBI into new PBIs.
41    Split,
42    /// Edit the selected item.
43    Edit,
44    /// Reload the board.
45    Reload,
46    /// Toggle maximized-column mode.
47    Maximize,
48    /// Open substring search.
49    Search,
50    /// Open regular-expression search.
51    RegexSearch,
52    /// Open or close the selected item's details.
53    Details,
54    /// Open or close the Kanban help window.
55    Help,
56    /// Clear an active search filter.
57    ClearFilter,
58    /// Confirm leaving from the quit dialog.
59    ConfirmQuit,
60    /// Cancel leaving from the quit dialog.
61    CancelQuit,
62    /// Close the details popup.
63    PopupClose,
64    /// Scroll the details popup upwards.
65    PopupScrollUp,
66    /// Scroll the details popup downwards.
67    PopupScrollDown,
68    /// Select the row above while the details popup is open.
69    PopupSelectUp,
70    /// Select the row below while the details popup is open.
71    PopupSelectDown,
72    /// Select the column to the left while the details popup is open.
73    PopupSelectLeft,
74    /// Select the column to the right while the details popup is open.
75    PopupSelectRight,
76}
77
78impl KeyAction {
79    /// Every supported action, in the order used by the configuration documentation.
80    pub const ALL: &'static [Self] = &[
81        Self::Quit,
82        Self::Shell,
83        Self::SelectLeft,
84        Self::SelectRight,
85        Self::SelectUp,
86        Self::SelectDown,
87        Self::MoveLeft,
88        Self::MoveRight,
89        Self::ReorderUp,
90        Self::ReorderDown,
91        Self::ToggleExpand,
92        Self::Add,
93        Self::DependencyAdd,
94        Self::DependencyRemove,
95        Self::Parent,
96        Self::Split,
97        Self::Edit,
98        Self::Reload,
99        Self::Maximize,
100        Self::Search,
101        Self::RegexSearch,
102        Self::Details,
103        Self::Help,
104        Self::ClearFilter,
105        Self::ConfirmQuit,
106        Self::CancelQuit,
107        Self::PopupClose,
108        Self::PopupScrollUp,
109        Self::PopupScrollDown,
110        Self::PopupSelectUp,
111        Self::PopupSelectDown,
112        Self::PopupSelectLeft,
113        Self::PopupSelectRight,
114    ];
115
116    /// The TOML key used for this action.
117    #[must_use]
118    pub const fn name(self) -> &'static str {
119        match self {
120            Self::Quit => "quit",
121            Self::Shell => "shell",
122            Self::SelectLeft => "select_left",
123            Self::SelectRight => "select_right",
124            Self::SelectUp => "select_up",
125            Self::SelectDown => "select_down",
126            Self::MoveLeft => "move_left",
127            Self::MoveRight => "move_right",
128            Self::ReorderUp => "reorder_up",
129            Self::ReorderDown => "reorder_down",
130            Self::ToggleExpand => "toggle_expand",
131            Self::Add => "add",
132            Self::DependencyAdd => "dependency_add",
133            Self::DependencyRemove => "dependency_remove",
134            Self::Parent => "parent",
135            Self::Split => "split",
136            Self::Edit => "edit",
137            Self::Reload => "reload",
138            Self::Maximize => "maximize",
139            Self::Search => "search",
140            Self::RegexSearch => "regex_search",
141            Self::Details => "details",
142            Self::Help => "help",
143            Self::ClearFilter => "clear_filter",
144            Self::ConfirmQuit => "confirm_quit",
145            Self::CancelQuit => "cancel_quit",
146            Self::PopupClose => "popup_close",
147            Self::PopupScrollUp => "popup_scroll_up",
148            Self::PopupScrollDown => "popup_scroll_down",
149            Self::PopupSelectUp => "popup_select_up",
150            Self::PopupSelectDown => "popup_select_down",
151            Self::PopupSelectLeft => "popup_select_left",
152            Self::PopupSelectRight => "popup_select_right",
153        }
154    }
155
156    fn from_name(name: &str) -> Option<Self> {
157        Self::ALL
158            .iter()
159            .copied()
160            .find(|action| action.name() == name)
161    }
162}
163
164/// Platform-neutral key codes used by the configuration parser and TUI adapter.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum KeyCode {
167    /// A printable character.
168    Char(char),
169    /// Enter/Return.
170    Enter,
171    /// Escape.
172    Esc,
173    /// Tab.
174    Tab,
175    /// Backspace.
176    Backspace,
177    /// Delete.
178    Delete,
179    /// Insert.
180    Insert,
181    /// Left arrow.
182    Left,
183    /// Right arrow.
184    Right,
185    /// Up arrow.
186    Up,
187    /// Down arrow.
188    Down,
189    /// Home.
190    Home,
191    /// End.
192    End,
193    /// Page up.
194    PageUp,
195    /// Page down.
196    PageDown,
197    /// Function key.
198    F(u8),
199}
200
201/// Modifier flags independent of the terminal backend.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
203pub struct Modifiers(u8);
204
205impl Modifiers {
206    /// No modifiers.
207    pub const NONE: Self = Self(0);
208    /// Shift modifier for named/non-printable keys.
209    pub const SHIFT: Self = Self(1 << 0);
210    /// Control modifier.
211    pub const CONTROL: Self = Self(1 << 1);
212    /// Alt/Option modifier.
213    pub const ALT: Self = Self(1 << 2);
214    /// Super/Command/Windows modifier.
215    pub const SUPER: Self = Self(1 << 3);
216    /// Hyper modifier.
217    pub const HYPER: Self = Self(1 << 4);
218    /// Meta modifier.
219    pub const META: Self = Self(1 << 5);
220
221    /// Return whether all bits in `other` are present.
222    #[must_use]
223    pub const fn contains(self, other: Self) -> bool {
224        self.0 & other.0 == other.0
225    }
226
227    const fn insert(self, other: Self) -> Self {
228        Self(self.0 | other.0)
229    }
230}
231
232impl std::ops::BitOr for Modifiers {
233    type Output = Self;
234
235    fn bitor(self, rhs: Self) -> Self::Output {
236        self.insert(rhs)
237    }
238}
239
240impl std::ops::BitOrAssign for Modifiers {
241    fn bitor_assign(&mut self, rhs: Self) {
242        *self = self.insert(rhs);
243    }
244}
245
246/// A parsed key stroke.
247#[derive(Debug, Clone)]
248pub struct KeyStroke {
249    code: KeyCode,
250    modifiers: Modifiers,
251    /// The spelling used for the on-screen guide.
252    display: String,
253}
254
255impl PartialEq for KeyStroke {
256    fn eq(&self, other: &Self) -> bool {
257        self.code == other.code && self.modifiers == other.modifiers
258    }
259}
260
261impl Eq for KeyStroke {}
262
263impl KeyStroke {
264    /// Parse a key expression such as `q`, `Esc`, or `Ctrl+a`.
265    pub fn parse(input: &str) -> Result<Self, KeySpecError> {
266        let input = input.trim();
267        if input.is_empty() {
268            return Err(KeySpecError::new(input, "the key name must not be empty"));
269        }
270
271        let parts: Vec<&str> = input.split('+').collect();
272        if parts.iter().any(|part| part.trim().is_empty()) {
273            return Err(KeySpecError::new(
274                input,
275                "use `Plus` for the plus key and separate modifiers with `+`",
276            ));
277        }
278
279        let key_name = parts.last().copied().unwrap_or(input).trim();
280        let code = parse_key_code(key_name).map_err(|reason| KeySpecError::new(input, reason))?;
281        let mut modifiers = Modifiers::NONE;
282        for modifier_name in &parts[..parts.len().saturating_sub(1)] {
283            let modifier_name = modifier_name.trim();
284            let modifier = parse_modifier(modifier_name).ok_or_else(|| {
285                KeySpecError::new(
286                    input,
287                    format!(
288                        "unknown modifier {modifier_name:?}; use Ctrl, Alt, Shift, Cmd, Meta, or Hyper"
289                    ),
290                )
291            })?;
292            modifiers |= modifier;
293        }
294
295        if modifiers.contains(Modifiers::SHIFT) && matches!(code, KeyCode::Char(_)) {
296            return Err(KeySpecError::new(
297                input,
298                "write a Shift-modified printable key as its resulting character (use `A` instead of `Shift+a`, or `<` instead of `Shift+,`)",
299            ));
300        }
301
302        let display = display_key_stroke(code, modifiers);
303
304        Ok(Self {
305            code,
306            modifiers,
307            display,
308        })
309    }
310
311    /// Return the display spelling used in the footer key guide.
312    #[must_use]
313    pub fn display(&self) -> &str {
314        &self.display
315    }
316
317    /// Compare an input event with this configured stroke, including terminal
318    /// aliases for ASCII control-byte encodings.
319    #[must_use]
320    pub fn matches(&self, code: KeyCode, modifiers: Modifiers) -> bool {
321        (self.code == code && self.modifiers == modifiers)
322            || control_letter_matches(self.code, self.modifiers, code, modifiers)
323            || control_character_terminal_alias_matches(self.code, self.modifiers, code, modifiers)
324            || question_mark_terminal_alias_matches(self.code, self.modifiers, code, modifiers)
325    }
326}
327
328fn control_letter_matches(
329    configured_code: KeyCode,
330    configured_modifiers: Modifiers,
331    actual_code: KeyCode,
332    actual_modifiers: Modifiers,
333) -> bool {
334    if configured_modifiers != actual_modifiers
335        || !configured_modifiers.contains(Modifiers::CONTROL)
336    {
337        return false;
338    }
339
340    match (configured_code, actual_code) {
341        (KeyCode::Char(configured), KeyCode::Char(actual)) => {
342            configured.is_ascii_alphabetic() && configured.eq_ignore_ascii_case(&actual)
343        }
344        _ => false,
345    }
346}
347
348fn control_character_terminal_alias_matches(
349    configured_code: KeyCode,
350    configured_modifiers: Modifiers,
351    actual_code: KeyCode,
352    actual_modifiers: Modifiers,
353) -> bool {
354    let KeyCode::Char(configured) = configured_code else {
355        return false;
356    };
357    if !configured_modifiers.contains(Modifiers::CONTROL) {
358        return false;
359    }
360
361    let actual_code_matches = match configured {
362        '@' => actual_code == KeyCode::Char(' '),
363        '[' => actual_code == KeyCode::Esc,
364        '\\' => actual_code == KeyCode::Char('4'),
365        ']' => actual_code == KeyCode::Char('5'),
366        '^' => actual_code == KeyCode::Char('6'),
367        '_' => actual_code == KeyCode::Char('7'),
368        _ => false,
369    };
370    if !actual_code_matches {
371        return false;
372    }
373
374    configured_modifiers == actual_modifiers
375        || (actual_code == KeyCode::Esc
376            && configured_modifiers == (actual_modifiers | Modifiers::CONTROL))
377}
378
379fn question_mark_terminal_alias_matches(
380    configured_code: KeyCode,
381    configured_modifiers: Modifiers,
382    actual_code: KeyCode,
383    actual_modifiers: Modifiers,
384) -> bool {
385    if configured_code != KeyCode::Char('?') || configured_modifiers == Modifiers::NONE {
386        return false;
387    }
388
389    // Legacy Unix input sends Ctrl+? as DEL. crossterm exposes that byte as
390    // Backspace and cannot retain the Control bit. Zellij can instead encode
391    // Ctrl+Shift+/ as 0x1f, which crossterm exposes as Ctrl+7. CSI-u can expose
392    // the question-mark character with the Control bit. Other modifier bits
393    // must remain unchanged.
394    if actual_code == KeyCode::Char('7') {
395        return configured_modifiers == actual_modifiers;
396    }
397
398    actual_code == KeyCode::Backspace
399        && (configured_modifiers == actual_modifiers
400            || (configured_modifiers.contains(Modifiers::CONTROL)
401                && configured_modifiers == (actual_modifiers | Modifiers::CONTROL)))
402}
403
404/// A malformed key expression from the configuration file.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct KeySpecError {
407    input: String,
408    reason: String,
409}
410
411impl KeySpecError {
412    fn new(input: &str, reason: impl Into<String>) -> Self {
413        Self {
414            input: input.to_string(),
415            reason: reason.into(),
416        }
417    }
418}
419
420impl fmt::Display for KeySpecError {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(
423            f,
424            "invalid key {:?}: {}; supported key names include Esc, Enter, Space, arrows, and single characters",
425            self.input, self.reason
426        )
427    }
428}
429
430impl std::error::Error for KeySpecError {}
431
432/// Key assignments stored under the user's `[tui.key_bindings]` table.
433///
434/// Each action accepts one or more key expressions. Missing action entries are filled with the
435/// defaults so older user configuration files and partial overrides remain usable.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(transparent)]
438pub struct KeyBindings(BTreeMap<String, Vec<String>>);
439
440impl Default for KeyBindings {
441    fn default() -> Self {
442        let mut bindings = BTreeMap::new();
443        for action in KeyAction::ALL {
444            bindings.insert(
445                action.name().to_string(),
446                default_keys(*action)
447                    .iter()
448                    .map(|key| (*key).to_string())
449                    .collect(),
450            );
451        }
452        Self(bindings)
453    }
454}
455
456impl KeyBindings {
457    /// Return the configured key expressions for an action.
458    pub fn keys(&self, action: KeyAction) -> &[String] {
459        self.0.get(action.name()).map(Vec::as_slice).unwrap_or(&[])
460    }
461
462    /// Override one action. Validation is performed when the user configuration is loaded.
463    pub fn set(&mut self, action: KeyAction, keys: Vec<String>) {
464        self.0.insert(action.name().to_string(), keys);
465    }
466
467    /// Fill omitted actions with their existing defaults.
468    #[must_use]
469    pub fn with_defaults(mut self) -> Self {
470        for action in KeyAction::ALL {
471            self.0.entry(action.name().to_string()).or_insert_with(|| {
472                default_keys(*action)
473                    .iter()
474                    .map(|key| (*key).to_string())
475                    .collect()
476            });
477        }
478        self
479    }
480
481    /// Validate action names, non-empty assignments, and every key expression.
482    pub fn validate(&self) -> Result<(), KeyBindingError> {
483        for (name, keys) in &self.0 {
484            let Some(action) = KeyAction::from_name(name) else {
485                return Err(KeyBindingError::UnknownAction {
486                    action: name.clone(),
487                });
488            };
489            if keys.is_empty() {
490                return Err(KeyBindingError::Empty {
491                    action: action.name().to_string(),
492                });
493            }
494            for (index, key) in keys.iter().enumerate() {
495                KeyStroke::parse(key).map_err(|source| KeyBindingError::InvalidKey {
496                    action: action.name().to_string(),
497                    index,
498                    source,
499                })?;
500            }
501        }
502        Ok(())
503    }
504}
505
506/// A malformed `[tui.key_bindings]` entry.
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub enum KeyBindingError {
509    /// The operation name is not supported.
510    UnknownAction { action: String },
511    /// An operation has no keys assigned.
512    Empty { action: String },
513    /// One key expression cannot be parsed.
514    InvalidKey {
515        action: String,
516        index: usize,
517        source: KeySpecError,
518    },
519}
520
521impl fmt::Display for KeyBindingError {
522    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523        match self {
524            Self::UnknownAction { action } => write!(
525                f,
526                "unknown operation {action:?}; use one of: {}",
527                KeyAction::ALL
528                    .iter()
529                    .map(|action| action.name())
530                    .collect::<Vec<_>>()
531                    .join(", ")
532            ),
533            Self::Empty { action } => write!(
534                f,
535                "operation {action:?} must have at least one key (remove the override to use its default)"
536            ),
537            Self::InvalidKey {
538                action,
539                index,
540                source,
541            } => write!(f, "operation {action:?} key #{index}: {source}"),
542        }
543    }
544}
545
546impl std::error::Error for KeyBindingError {}
547
548fn default_keys(action: KeyAction) -> &'static [&'static str] {
549    match action {
550        KeyAction::Quit => &["q", "Esc"],
551        KeyAction::Shell => &["Q"],
552        KeyAction::SelectLeft => &["h", "Left"],
553        KeyAction::SelectRight => &["l", "Right"],
554        KeyAction::SelectUp => &["k", "Up"],
555        KeyAction::SelectDown => &["j", "Down"],
556        KeyAction::MoveLeft => &["H", "Shift+Left"],
557        KeyAction::MoveRight => &["L", "Shift+Right"],
558        KeyAction::ReorderUp => &["K", "Shift+Up"],
559        KeyAction::ReorderDown => &["J", "Shift+Down"],
560        KeyAction::ToggleExpand => &["Space", "Enter"],
561        KeyAction::Add => &["a"],
562        KeyAction::DependencyAdd => &["d"],
563        KeyAction::DependencyRemove => &["D"],
564        KeyAction::Parent => &["p"],
565        KeyAction::Split => &["s"],
566        KeyAction::Edit => &["e"],
567        KeyAction::Reload => &["r"],
568        KeyAction::Maximize => &["m"],
569        KeyAction::Search => &["/"],
570        KeyAction::RegexSearch => &["Ctrl+?"],
571        KeyAction::Details => &["v"],
572        KeyAction::Help => &["?"],
573        KeyAction::ClearFilter => &["Esc"],
574        KeyAction::ConfirmQuit => &["y", "Y", "q", "Q", "Enter", "Esc"],
575        KeyAction::CancelQuit => &["n", "N"],
576        KeyAction::PopupClose => &["v", "Esc", "q"],
577        KeyAction::PopupScrollUp => &["k", "Up"],
578        KeyAction::PopupScrollDown => &["j", "Down"],
579        KeyAction::PopupSelectUp => &["K", "Shift+Up"],
580        KeyAction::PopupSelectDown => &["J", "Shift+Down"],
581        KeyAction::PopupSelectLeft => &["H", "Shift+Left"],
582        KeyAction::PopupSelectRight => &["L", "Shift+Right"],
583    }
584}
585
586fn parse_modifier(input: &str) -> Option<Modifiers> {
587    let modifier = match input.to_ascii_lowercase().as_str() {
588        "shift" => Modifiers::SHIFT,
589        "ctrl" | "control" => Modifiers::CONTROL,
590        "alt" | "option" => Modifiers::ALT,
591        "cmd" | "command" | "super" | "windows" | "win" => Modifiers::SUPER,
592        "meta" => Modifiers::META,
593        "hyper" => Modifiers::HYPER,
594        _ => return None,
595    };
596    Some(modifier)
597}
598
599fn parse_key_code(input: &str) -> Result<KeyCode, String> {
600    let normalized = input.to_ascii_lowercase();
601    let code = match normalized.as_str() {
602        "esc" | "escape" => KeyCode::Esc,
603        "enter" | "return" => KeyCode::Enter,
604        "space" => KeyCode::Char(' '),
605        "tab" => KeyCode::Tab,
606        "backspace" | "back" => KeyCode::Backspace,
607        "delete" | "del" => KeyCode::Delete,
608        "insert" | "ins" => KeyCode::Insert,
609        "left" => KeyCode::Left,
610        "right" => KeyCode::Right,
611        "up" => KeyCode::Up,
612        "down" => KeyCode::Down,
613        "home" => KeyCode::Home,
614        "end" => KeyCode::End,
615        "pageup" | "page_up" | "pgup" => KeyCode::PageUp,
616        "pagedown" | "page_down" | "pgdn" => KeyCode::PageDown,
617        "plus" => KeyCode::Char('+'),
618        _ if normalized.starts_with('f') && normalized[1..].parse::<u8>().is_ok() => {
619            let number = normalized[1..]
620                .parse::<u8>()
621                .map_err(|_| "function key number is invalid".to_string())?;
622            if (1..=24).contains(&number) {
623                KeyCode::F(number)
624            } else {
625                return Err("function keys must be between F1 and F24".to_string());
626            }
627        }
628        _ => {
629            let mut chars = input.chars();
630            let Some(character) = chars.next() else {
631                return Err("the key name must not be empty".to_string());
632            };
633            if chars.next().is_some() {
634                return Err(format!(
635                    "unknown key {input:?}; use a named key, a single character, or F1-F24"
636                ));
637            }
638            KeyCode::Char(character)
639        }
640    };
641    Ok(code)
642}
643
644fn display_key_stroke(code: KeyCode, modifiers: Modifiers) -> String {
645    let mut names = Vec::with_capacity(6);
646    if modifiers.contains(Modifiers::CONTROL) {
647        names.push("Ctrl");
648    }
649    if modifiers.contains(Modifiers::ALT) {
650        names.push("Alt");
651    }
652    if modifiers.contains(Modifiers::SUPER) {
653        names.push("Cmd");
654    }
655    if modifiers.contains(Modifiers::META) {
656        names.push("Meta");
657    }
658    if modifiers.contains(Modifiers::HYPER) {
659        names.push("Hyper");
660    }
661    if modifiers.contains(Modifiers::SHIFT) {
662        names.push("Shift");
663    }
664    let key = display_key_code(code);
665    if names.is_empty() {
666        key
667    } else {
668        format!("{}+{key}", names.join("+"))
669    }
670}
671
672fn display_key_code(code: KeyCode) -> String {
673    match code {
674        KeyCode::Char(' ') => "Space".to_string(),
675        KeyCode::Char('+') => "Plus".to_string(),
676        KeyCode::Char(character) => character.to_string(),
677        KeyCode::Enter => "Enter".to_string(),
678        KeyCode::Esc => "Esc".to_string(),
679        KeyCode::Tab => "Tab".to_string(),
680        KeyCode::Backspace => "Backspace".to_string(),
681        KeyCode::Delete => "Delete".to_string(),
682        KeyCode::Insert => "Insert".to_string(),
683        KeyCode::Left => "Left".to_string(),
684        KeyCode::Right => "Right".to_string(),
685        KeyCode::Up => "Up".to_string(),
686        KeyCode::Down => "Down".to_string(),
687        KeyCode::Home => "Home".to_string(),
688        KeyCode::End => "End".to_string(),
689        KeyCode::PageUp => "PageUp".to_string(),
690        KeyCode::PageDown => "PageDown".to_string(),
691        KeyCode::F(number) => format!("F{number}"),
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn control_and_platform_modifiers_are_parsed_and_displayed() {
701        let control = KeyStroke::parse("Ctrl+a").expect("control key is valid");
702        assert!(control.matches(KeyCode::Char('a'), Modifiers::CONTROL));
703        assert_eq!(control.display(), "Ctrl+a");
704
705        let command = KeyStroke::parse("Cmd+q").expect("command key is valid");
706        assert!(command.matches(KeyCode::Char('q'), Modifiers::SUPER));
707        assert_eq!(command.display(), "Cmd+q");
708    }
709
710    #[test]
711    fn terminal_aliases_match_modified_printable_bindings() {
712        let control_letter = KeyStroke::parse("Ctrl+A").expect("control letter is valid");
713        assert!(control_letter.matches(KeyCode::Char('a'), Modifiers::CONTROL));
714
715        let unix_control_aliases = [
716            ("Ctrl+@", KeyCode::Char(' '), Modifiers::CONTROL),
717            ("Ctrl+[", KeyCode::Esc, Modifiers::NONE),
718            ("Ctrl+\\", KeyCode::Char('4'), Modifiers::CONTROL),
719            ("Ctrl+]", KeyCode::Char('5'), Modifiers::CONTROL),
720            ("Ctrl+^", KeyCode::Char('6'), Modifiers::CONTROL),
721            ("Ctrl+_", KeyCode::Char('7'), Modifiers::CONTROL),
722        ];
723        for (input, code, modifiers) in unix_control_aliases {
724            let stroke = KeyStroke::parse(input).expect("control punctuation is valid");
725            assert!(stroke.matches(code, modifiers), "input: {input}");
726        }
727
728        let regex_search = KeyStroke::parse("Ctrl+?").expect("regex search key is valid");
729        assert!(regex_search.matches(KeyCode::Backspace, Modifiers::NONE));
730        assert!(regex_search.matches(KeyCode::Char('?'), Modifiers::CONTROL));
731        assert!(regex_search.matches(KeyCode::Char('7'), Modifiers::CONTROL));
732
733        let alt_regex_search = KeyStroke::parse("Alt+?").expect("Alt binding is valid");
734        assert!(alt_regex_search.matches(KeyCode::Backspace, Modifiers::ALT));
735
736        let combined_regex_search =
737            KeyStroke::parse("Ctrl+Alt+?").expect("combined binding is valid");
738        assert!(combined_regex_search.matches(KeyCode::Backspace, Modifiers::ALT));
739    }
740
741    #[test]
742    fn default_bindings_keep_multiple_existing_keys() {
743        let bindings = KeyBindings::default();
744
745        assert_eq!(
746            bindings.keys(KeyAction::Quit),
747            ["q".to_string(), "Esc".to_string()]
748        );
749        assert_eq!(
750            bindings.keys(KeyAction::SelectLeft),
751            ["h".to_string(), "Left".to_string()]
752        );
753        assert!(
754            KeyAction::ALL
755                .iter()
756                .all(|action| !bindings.keys(*action).is_empty())
757        );
758    }
759
760    #[test]
761    fn invalid_key_spec_explains_the_supported_syntax() {
762        let error = KeyStroke::parse("Controlled+a")
763            .expect_err("unknown modifier must be rejected")
764            .to_string();
765
766        assert!(error.contains("Ctrl"));
767        assert!(error.contains("Alt"));
768        assert!(error.contains("Cmd"));
769    }
770
771    #[test]
772    fn parses_named_keys_aliases_and_function_keys() {
773        let cases = [
774            ("Esc", KeyCode::Esc),
775            ("Escape", KeyCode::Esc),
776            ("Enter", KeyCode::Enter),
777            ("Return", KeyCode::Enter),
778            ("Space", KeyCode::Char(' ')),
779            ("Tab", KeyCode::Tab),
780            ("Backspace", KeyCode::Backspace),
781            ("Back", KeyCode::Backspace),
782            ("Delete", KeyCode::Delete),
783            ("Del", KeyCode::Delete),
784            ("Insert", KeyCode::Insert),
785            ("Ins", KeyCode::Insert),
786            ("Left", KeyCode::Left),
787            ("Right", KeyCode::Right),
788            ("Up", KeyCode::Up),
789            ("Down", KeyCode::Down),
790            ("Home", KeyCode::Home),
791            ("End", KeyCode::End),
792            ("PageUp", KeyCode::PageUp),
793            ("page_up", KeyCode::PageUp),
794            ("PgUp", KeyCode::PageUp),
795            ("PageDown", KeyCode::PageDown),
796            ("page_down", KeyCode::PageDown),
797            ("PgDn", KeyCode::PageDown),
798            ("Plus", KeyCode::Char('+')),
799            ("F1", KeyCode::F(1)),
800            ("F24", KeyCode::F(24)),
801        ];
802
803        for (input, expected) in cases {
804            let stroke = KeyStroke::parse(input).expect("named key is valid");
805            assert!(stroke.matches(expected, Modifiers::NONE), "input: {input}");
806        }
807        assert_eq!(
808            KeyStroke::parse("Space").expect("space key").display(),
809            "Space"
810        );
811        assert_eq!(
812            KeyStroke::parse("Plus").expect("plus key").display(),
813            "Plus"
814        );
815        assert_eq!(KeyStroke::parse(".").expect("character key").display(), ".");
816        assert_eq!(
817            KeyStroke::parse("F1").expect("function key").display(),
818            "F1"
819        );
820    }
821
822    #[test]
823    fn parses_modifier_aliases_and_displays_combined_modifiers() {
824        let aliases = [
825            ("Shift+Left", Modifiers::SHIFT),
826            ("Ctrl+Left", Modifiers::CONTROL),
827            ("Control+Left", Modifiers::CONTROL),
828            ("Alt+Left", Modifiers::ALT),
829            ("Option+Left", Modifiers::ALT),
830            ("Cmd+Left", Modifiers::SUPER),
831            ("Command+Left", Modifiers::SUPER),
832            ("Super+Left", Modifiers::SUPER),
833            ("Windows+Left", Modifiers::SUPER),
834            ("Win+Left", Modifiers::SUPER),
835            ("Meta+Left", Modifiers::META),
836            ("Hyper+Left", Modifiers::HYPER),
837        ];
838
839        for (input, expected) in aliases {
840            let stroke = KeyStroke::parse(input).expect("modifier alias is valid");
841            assert!(stroke.matches(KeyCode::Left, expected), "input: {input}");
842        }
843
844        let combined = KeyStroke::parse("Ctrl+Alt+Cmd+Meta+Hyper+Shift+PageDown")
845            .expect("combined modifiers are valid");
846        assert_eq!(combined.display(), "Ctrl+Alt+Cmd+Meta+Hyper+Shift+PageDown");
847        assert!(combined.matches(
848            KeyCode::PageDown,
849            Modifiers::CONTROL
850                | Modifiers::ALT
851                | Modifiers::SUPER
852                | Modifiers::META
853                | Modifiers::HYPER
854                | Modifiers::SHIFT
855        ));
856    }
857
858    #[test]
859    fn rejects_malformed_key_expressions() {
860        for input in [
861            "",
862            "+",
863            "Ctrl+",
864            "Unknown+a",
865            "Shift+a",
866            "F0",
867            "F25",
868            "Fabc",
869            "left right",
870        ] {
871            assert!(KeyStroke::parse(input).is_err(), "input: {input:?}");
872        }
873        assert!(
874            KeyStroke::parse("Ctrl+")
875                .expect_err("missing key")
876                .to_string()
877                .contains("Plus")
878        );
879        assert!(
880            KeyStroke::parse("F25")
881                .expect_err("function key range")
882                .to_string()
883                .contains("F1 and F24")
884        );
885    }
886
887    #[test]
888    fn key_bindings_fill_defaults_and_report_validation_errors() {
889        let empty = KeyBindings(BTreeMap::new());
890        assert!(empty.keys(KeyAction::Quit).is_empty());
891        let completed = empty.with_defaults();
892        assert!(completed.validate().is_ok());
893        assert_eq!(completed.keys(KeyAction::Quit), ["q", "Esc"]);
894
895        let mut unknown = KeyBindings(BTreeMap::new());
896        unknown
897            .0
898            .insert("unknown".to_string(), vec!["q".to_string()]);
899        let error = unknown.validate().expect_err("unknown action must fail");
900        assert!(matches!(error, KeyBindingError::UnknownAction { .. }));
901        assert!(error.to_string().contains("unknown operation"));
902
903        let mut empty_action = KeyBindings(BTreeMap::new());
904        empty_action.0.insert("quit".to_string(), Vec::new());
905        let error = empty_action
906            .validate()
907            .expect_err("empty assignment must fail");
908        assert!(matches!(error, KeyBindingError::Empty { .. }));
909        assert!(error.to_string().contains("at least one key"));
910
911        let mut invalid = KeyBindings(BTreeMap::new());
912        invalid
913            .0
914            .insert("quit".to_string(), vec!["not a key".to_string()]);
915        let error = invalid.validate().expect_err("invalid key must fail");
916        assert!(matches!(
917            error,
918            KeyBindingError::InvalidKey { index: 0, .. }
919        ));
920        assert!(error.to_string().contains("key #0"));
921    }
922}