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 `[tui.key_bindings]`.
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.
314    #[must_use]
315    pub fn matches(&self, code: KeyCode, modifiers: Modifiers) -> bool {
316        self.code == code && self.modifiers == modifiers
317    }
318}
319
320/// A malformed key expression from the configuration file.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct KeySpecError {
323    input: String,
324    reason: String,
325}
326
327impl KeySpecError {
328    fn new(input: &str, reason: impl Into<String>) -> Self {
329        Self {
330            input: input.to_string(),
331            reason: reason.into(),
332        }
333    }
334}
335
336impl fmt::Display for KeySpecError {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        write!(
339            f,
340            "invalid key {:?}: {}; supported key names include Esc, Enter, Space, arrows, and single characters",
341            self.input, self.reason
342        )
343    }
344}
345
346impl std::error::Error for KeySpecError {}
347
348/// Key assignments stored under `[tui.key_bindings]`.
349///
350/// Each action accepts one or more key expressions. Missing action entries are filled with the
351/// defaults so older configuration files and partial overrides remain usable.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(transparent)]
354pub struct KeyBindings(BTreeMap<String, Vec<String>>);
355
356impl Default for KeyBindings {
357    fn default() -> Self {
358        let mut bindings = BTreeMap::new();
359        for action in KeyAction::ALL {
360            bindings.insert(
361                action.name().to_string(),
362                default_keys(*action)
363                    .iter()
364                    .map(|key| (*key).to_string())
365                    .collect(),
366            );
367        }
368        Self(bindings)
369    }
370}
371
372impl KeyBindings {
373    /// Return the configured key expressions for an action.
374    pub fn keys(&self, action: KeyAction) -> &[String] {
375        self.0.get(action.name()).map(Vec::as_slice).unwrap_or(&[])
376    }
377
378    /// Override one action. Validation is performed when the board configuration is loaded.
379    pub fn set(&mut self, action: KeyAction, keys: Vec<String>) {
380        self.0.insert(action.name().to_string(), keys);
381    }
382
383    /// Fill omitted actions with their existing defaults.
384    #[must_use]
385    pub fn with_defaults(mut self) -> Self {
386        for action in KeyAction::ALL {
387            self.0.entry(action.name().to_string()).or_insert_with(|| {
388                default_keys(*action)
389                    .iter()
390                    .map(|key| (*key).to_string())
391                    .collect()
392            });
393        }
394        self
395    }
396
397    /// Validate action names, non-empty assignments, and every key expression.
398    pub fn validate(&self) -> Result<(), KeyBindingError> {
399        for (name, keys) in &self.0 {
400            let Some(action) = KeyAction::from_name(name) else {
401                return Err(KeyBindingError::UnknownAction {
402                    action: name.clone(),
403                });
404            };
405            if keys.is_empty() {
406                return Err(KeyBindingError::Empty {
407                    action: action.name().to_string(),
408                });
409            }
410            for (index, key) in keys.iter().enumerate() {
411                KeyStroke::parse(key).map_err(|source| KeyBindingError::InvalidKey {
412                    action: action.name().to_string(),
413                    index,
414                    source,
415                })?;
416            }
417        }
418        Ok(())
419    }
420}
421
422/// A malformed `[tui.key_bindings]` entry.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum KeyBindingError {
425    /// The operation name is not supported.
426    UnknownAction { action: String },
427    /// An operation has no keys assigned.
428    Empty { action: String },
429    /// One key expression cannot be parsed.
430    InvalidKey {
431        action: String,
432        index: usize,
433        source: KeySpecError,
434    },
435}
436
437impl fmt::Display for KeyBindingError {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        match self {
440            Self::UnknownAction { action } => write!(
441                f,
442                "unknown operation {action:?}; use one of: {}",
443                KeyAction::ALL
444                    .iter()
445                    .map(|action| action.name())
446                    .collect::<Vec<_>>()
447                    .join(", ")
448            ),
449            Self::Empty { action } => write!(
450                f,
451                "operation {action:?} must have at least one key (remove the override to use its default)"
452            ),
453            Self::InvalidKey {
454                action,
455                index,
456                source,
457            } => write!(f, "operation {action:?} key #{index}: {source}"),
458        }
459    }
460}
461
462impl std::error::Error for KeyBindingError {}
463
464fn default_keys(action: KeyAction) -> &'static [&'static str] {
465    match action {
466        KeyAction::Quit => &["q", "Esc"],
467        KeyAction::Shell => &["Q"],
468        KeyAction::SelectLeft => &["h", "Left"],
469        KeyAction::SelectRight => &["l", "Right"],
470        KeyAction::SelectUp => &["k", "Up"],
471        KeyAction::SelectDown => &["j", "Down"],
472        KeyAction::MoveLeft => &["H", "Shift+Left"],
473        KeyAction::MoveRight => &["L", "Shift+Right"],
474        KeyAction::ReorderUp => &["K", "Shift+Up"],
475        KeyAction::ReorderDown => &["J", "Shift+Down"],
476        KeyAction::ToggleExpand => &["Space", "Enter"],
477        KeyAction::Add => &["a"],
478        KeyAction::DependencyAdd => &["d"],
479        KeyAction::DependencyRemove => &["D"],
480        KeyAction::Parent => &["p"],
481        KeyAction::Edit => &["e"],
482        KeyAction::Reload => &["r"],
483        KeyAction::Maximize => &["m"],
484        KeyAction::Search => &["/"],
485        KeyAction::RegexSearch => &["Ctrl+?"],
486        KeyAction::Details => &["v"],
487        KeyAction::Help => &["?"],
488        KeyAction::ClearFilter => &["Esc"],
489        KeyAction::ConfirmQuit => &["y", "Y", "q", "Q", "Enter", "Esc"],
490        KeyAction::CancelQuit => &["n", "N"],
491        KeyAction::PopupClose => &["v", "Esc", "q"],
492        KeyAction::PopupScrollUp => &["k", "Up"],
493        KeyAction::PopupScrollDown => &["j", "Down"],
494        KeyAction::PopupSelectUp => &["K", "Shift+Up"],
495        KeyAction::PopupSelectDown => &["J", "Shift+Down"],
496        KeyAction::PopupSelectLeft => &["H", "Shift+Left"],
497        KeyAction::PopupSelectRight => &["L", "Shift+Right"],
498    }
499}
500
501fn parse_modifier(input: &str) -> Option<Modifiers> {
502    let modifier = match input.to_ascii_lowercase().as_str() {
503        "shift" => Modifiers::SHIFT,
504        "ctrl" | "control" => Modifiers::CONTROL,
505        "alt" | "option" => Modifiers::ALT,
506        "cmd" | "command" | "super" | "windows" | "win" => Modifiers::SUPER,
507        "meta" => Modifiers::META,
508        "hyper" => Modifiers::HYPER,
509        _ => return None,
510    };
511    Some(modifier)
512}
513
514fn parse_key_code(input: &str) -> Result<KeyCode, String> {
515    let normalized = input.to_ascii_lowercase();
516    let code = match normalized.as_str() {
517        "esc" | "escape" => KeyCode::Esc,
518        "enter" | "return" => KeyCode::Enter,
519        "space" => KeyCode::Char(' '),
520        "tab" => KeyCode::Tab,
521        "backspace" | "back" => KeyCode::Backspace,
522        "delete" | "del" => KeyCode::Delete,
523        "insert" | "ins" => KeyCode::Insert,
524        "left" => KeyCode::Left,
525        "right" => KeyCode::Right,
526        "up" => KeyCode::Up,
527        "down" => KeyCode::Down,
528        "home" => KeyCode::Home,
529        "end" => KeyCode::End,
530        "pageup" | "page_up" | "pgup" => KeyCode::PageUp,
531        "pagedown" | "page_down" | "pgdn" => KeyCode::PageDown,
532        "plus" => KeyCode::Char('+'),
533        _ if normalized.starts_with('f') && normalized[1..].parse::<u8>().is_ok() => {
534            let number = normalized[1..]
535                .parse::<u8>()
536                .map_err(|_| "function key number is invalid".to_string())?;
537            if (1..=24).contains(&number) {
538                KeyCode::F(number)
539            } else {
540                return Err("function keys must be between F1 and F24".to_string());
541            }
542        }
543        _ => {
544            let mut chars = input.chars();
545            let Some(character) = chars.next() else {
546                return Err("the key name must not be empty".to_string());
547            };
548            if chars.next().is_some() {
549                return Err(format!(
550                    "unknown key {input:?}; use a named key, a single character, or F1-F24"
551                ));
552            }
553            KeyCode::Char(character)
554        }
555    };
556    Ok(code)
557}
558
559fn display_key_stroke(code: KeyCode, modifiers: Modifiers) -> String {
560    let mut names = Vec::with_capacity(6);
561    if modifiers.contains(Modifiers::CONTROL) {
562        names.push("Ctrl");
563    }
564    if modifiers.contains(Modifiers::ALT) {
565        names.push("Alt");
566    }
567    if modifiers.contains(Modifiers::SUPER) {
568        names.push("Cmd");
569    }
570    if modifiers.contains(Modifiers::META) {
571        names.push("Meta");
572    }
573    if modifiers.contains(Modifiers::HYPER) {
574        names.push("Hyper");
575    }
576    if modifiers.contains(Modifiers::SHIFT) {
577        names.push("Shift");
578    }
579    let key = display_key_code(code);
580    if names.is_empty() {
581        key
582    } else {
583        format!("{}+{key}", names.join("+"))
584    }
585}
586
587fn display_key_code(code: KeyCode) -> String {
588    match code {
589        KeyCode::Char(' ') => "Space".to_string(),
590        KeyCode::Char('+') => "Plus".to_string(),
591        KeyCode::Char(character) => character.to_string(),
592        KeyCode::Enter => "Enter".to_string(),
593        KeyCode::Esc => "Esc".to_string(),
594        KeyCode::Tab => "Tab".to_string(),
595        KeyCode::Backspace => "Backspace".to_string(),
596        KeyCode::Delete => "Delete".to_string(),
597        KeyCode::Insert => "Insert".to_string(),
598        KeyCode::Left => "Left".to_string(),
599        KeyCode::Right => "Right".to_string(),
600        KeyCode::Up => "Up".to_string(),
601        KeyCode::Down => "Down".to_string(),
602        KeyCode::Home => "Home".to_string(),
603        KeyCode::End => "End".to_string(),
604        KeyCode::PageUp => "PageUp".to_string(),
605        KeyCode::PageDown => "PageDown".to_string(),
606        KeyCode::F(number) => format!("F{number}"),
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn control_and_platform_modifiers_are_parsed_and_displayed() {
616        let control = KeyStroke::parse("Ctrl+a").expect("control key is valid");
617        assert!(control.matches(KeyCode::Char('a'), Modifiers::CONTROL));
618        assert_eq!(control.display(), "Ctrl+a");
619
620        let command = KeyStroke::parse("Cmd+q").expect("command key is valid");
621        assert!(command.matches(KeyCode::Char('q'), Modifiers::SUPER));
622        assert_eq!(command.display(), "Cmd+q");
623    }
624
625    #[test]
626    fn default_bindings_keep_multiple_existing_keys() {
627        let bindings = KeyBindings::default();
628
629        assert_eq!(
630            bindings.keys(KeyAction::Quit),
631            ["q".to_string(), "Esc".to_string()]
632        );
633        assert_eq!(
634            bindings.keys(KeyAction::SelectLeft),
635            ["h".to_string(), "Left".to_string()]
636        );
637        assert!(
638            KeyAction::ALL
639                .iter()
640                .all(|action| !bindings.keys(*action).is_empty())
641        );
642    }
643
644    #[test]
645    fn invalid_key_spec_explains_the_supported_syntax() {
646        let error = KeyStroke::parse("Controlled+a")
647            .expect_err("unknown modifier must be rejected")
648            .to_string();
649
650        assert!(error.contains("Ctrl"));
651        assert!(error.contains("Alt"));
652        assert!(error.contains("Cmd"));
653    }
654
655    #[test]
656    fn parses_named_keys_aliases_and_function_keys() {
657        let cases = [
658            ("Esc", KeyCode::Esc),
659            ("Escape", KeyCode::Esc),
660            ("Enter", KeyCode::Enter),
661            ("Return", KeyCode::Enter),
662            ("Space", KeyCode::Char(' ')),
663            ("Tab", KeyCode::Tab),
664            ("Backspace", KeyCode::Backspace),
665            ("Back", KeyCode::Backspace),
666            ("Delete", KeyCode::Delete),
667            ("Del", KeyCode::Delete),
668            ("Insert", KeyCode::Insert),
669            ("Ins", KeyCode::Insert),
670            ("Left", KeyCode::Left),
671            ("Right", KeyCode::Right),
672            ("Up", KeyCode::Up),
673            ("Down", KeyCode::Down),
674            ("Home", KeyCode::Home),
675            ("End", KeyCode::End),
676            ("PageUp", KeyCode::PageUp),
677            ("page_up", KeyCode::PageUp),
678            ("PgUp", KeyCode::PageUp),
679            ("PageDown", KeyCode::PageDown),
680            ("page_down", KeyCode::PageDown),
681            ("PgDn", KeyCode::PageDown),
682            ("Plus", KeyCode::Char('+')),
683            ("F1", KeyCode::F(1)),
684            ("F24", KeyCode::F(24)),
685        ];
686
687        for (input, expected) in cases {
688            let stroke = KeyStroke::parse(input).expect("named key is valid");
689            assert!(stroke.matches(expected, Modifiers::NONE), "input: {input}");
690        }
691        assert_eq!(
692            KeyStroke::parse("Space").expect("space key").display(),
693            "Space"
694        );
695        assert_eq!(
696            KeyStroke::parse("Plus").expect("plus key").display(),
697            "Plus"
698        );
699        assert_eq!(KeyStroke::parse(".").expect("character key").display(), ".");
700        assert_eq!(
701            KeyStroke::parse("F1").expect("function key").display(),
702            "F1"
703        );
704    }
705
706    #[test]
707    fn parses_modifier_aliases_and_displays_combined_modifiers() {
708        let aliases = [
709            ("Shift+Left", Modifiers::SHIFT),
710            ("Ctrl+Left", Modifiers::CONTROL),
711            ("Control+Left", Modifiers::CONTROL),
712            ("Alt+Left", Modifiers::ALT),
713            ("Option+Left", Modifiers::ALT),
714            ("Cmd+Left", Modifiers::SUPER),
715            ("Command+Left", Modifiers::SUPER),
716            ("Super+Left", Modifiers::SUPER),
717            ("Windows+Left", Modifiers::SUPER),
718            ("Win+Left", Modifiers::SUPER),
719            ("Meta+Left", Modifiers::META),
720            ("Hyper+Left", Modifiers::HYPER),
721        ];
722
723        for (input, expected) in aliases {
724            let stroke = KeyStroke::parse(input).expect("modifier alias is valid");
725            assert!(stroke.matches(KeyCode::Left, expected), "input: {input}");
726        }
727
728        let combined = KeyStroke::parse("Ctrl+Alt+Cmd+Meta+Hyper+Shift+PageDown")
729            .expect("combined modifiers are valid");
730        assert_eq!(combined.display(), "Ctrl+Alt+Cmd+Meta+Hyper+Shift+PageDown");
731        assert!(combined.matches(
732            KeyCode::PageDown,
733            Modifiers::CONTROL
734                | Modifiers::ALT
735                | Modifiers::SUPER
736                | Modifiers::META
737                | Modifiers::HYPER
738                | Modifiers::SHIFT
739        ));
740    }
741
742    #[test]
743    fn rejects_malformed_key_expressions() {
744        for input in [
745            "",
746            "+",
747            "Ctrl+",
748            "Unknown+a",
749            "Shift+a",
750            "F0",
751            "F25",
752            "Fabc",
753            "left right",
754        ] {
755            assert!(KeyStroke::parse(input).is_err(), "input: {input:?}");
756        }
757        assert!(
758            KeyStroke::parse("Ctrl+")
759                .expect_err("missing key")
760                .to_string()
761                .contains("Plus")
762        );
763        assert!(
764            KeyStroke::parse("F25")
765                .expect_err("function key range")
766                .to_string()
767                .contains("F1 and F24")
768        );
769    }
770
771    #[test]
772    fn key_bindings_fill_defaults_and_report_validation_errors() {
773        let empty = KeyBindings(BTreeMap::new());
774        assert!(empty.keys(KeyAction::Quit).is_empty());
775        let completed = empty.with_defaults();
776        assert!(completed.validate().is_ok());
777        assert_eq!(completed.keys(KeyAction::Quit), ["q", "Esc"]);
778
779        let mut unknown = KeyBindings(BTreeMap::new());
780        unknown
781            .0
782            .insert("unknown".to_string(), vec!["q".to_string()]);
783        let error = unknown.validate().expect_err("unknown action must fail");
784        assert!(matches!(error, KeyBindingError::UnknownAction { .. }));
785        assert!(error.to_string().contains("unknown operation"));
786
787        let mut empty_action = KeyBindings(BTreeMap::new());
788        empty_action.0.insert("quit".to_string(), Vec::new());
789        let error = empty_action
790            .validate()
791            .expect_err("empty assignment must fail");
792        assert!(matches!(error, KeyBindingError::Empty { .. }));
793        assert!(error.to_string().contains("at least one key"));
794
795        let mut invalid = KeyBindings(BTreeMap::new());
796        invalid
797            .0
798            .insert("quit".to_string(), vec!["not a key".to_string()]);
799        let error = invalid.validate().expect_err("invalid key must fail");
800        assert!(matches!(
801            error,
802            KeyBindingError::InvalidKey { index: 0, .. }
803        ));
804        assert!(error.to_string().contains("key #0"));
805    }
806}