1use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum KeyAction {
10 Quit,
12 Shell,
14 SelectLeft,
16 SelectRight,
18 SelectUp,
20 SelectDown,
22 MoveLeft,
24 MoveRight,
26 ReorderUp,
28 ReorderDown,
30 ToggleExpand,
32 Add,
34 DependencyAdd,
36 DependencyRemove,
38 Parent,
40 Edit,
42 Reload,
44 Maximize,
46 Search,
48 RegexSearch,
50 Details,
52 Help,
54 ClearFilter,
56 ConfirmQuit,
58 CancelQuit,
60 PopupClose,
62 PopupScrollUp,
64 PopupScrollDown,
66 PopupSelectUp,
68 PopupSelectDown,
70 PopupSelectLeft,
72 PopupSelectRight,
74}
75
76impl KeyAction {
77 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162pub enum KeyCode {
163 Char(char),
165 Enter,
167 Esc,
169 Tab,
171 Backspace,
173 Delete,
175 Insert,
177 Left,
179 Right,
181 Up,
183 Down,
185 Home,
187 End,
189 PageUp,
191 PageDown,
193 F(u8),
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
199pub struct Modifiers(u8);
200
201impl Modifiers {
202 pub const NONE: Self = Self(0);
204 pub const SHIFT: Self = Self(1 << 0);
206 pub const CONTROL: Self = Self(1 << 1);
208 pub const ALT: Self = Self(1 << 2);
210 pub const SUPER: Self = Self(1 << 3);
212 pub const HYPER: Self = Self(1 << 4);
214 pub const META: Self = Self(1 << 5);
216
217 #[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#[derive(Debug, Clone)]
244pub struct KeyStroke {
245 code: KeyCode,
246 modifiers: Modifiers,
247 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 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 #[must_use]
309 pub fn display(&self) -> &str {
310 &self.display
311 }
312
313 #[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 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#[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#[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 pub fn keys(&self, action: KeyAction) -> &[String] {
455 self.0.get(action.name()).map(Vec::as_slice).unwrap_or(&[])
456 }
457
458 pub fn set(&mut self, action: KeyAction, keys: Vec<String>) {
460 self.0.insert(action.name().to_string(), keys);
461 }
462
463 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum KeyBindingError {
505 UnknownAction { action: String },
507 Empty { action: String },
509 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}