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