1use crate::core::event::{KeyCode, KeyEvent, KeyMods};
4#[cfg(not(target_arch = "wasm32"))]
5use crokey::{KeyCombination, crossterm};
6use std::fmt;
7use std::hash::{Hash, Hasher};
8use std::str::FromStr;
9use std::sync::Arc;
10
11#[cfg(not(target_arch = "wasm32"))]
12type KeyStep = KeyCombination;
13#[cfg(target_arch = "wasm32")]
14type KeyStep = Arc<str>;
15
16#[derive(Clone, Debug)]
18pub struct KeyBinding {
19 steps: Vec<KeyStep>,
20 canonical: Arc<str>,
21}
22
23impl PartialEq for KeyBinding {
24 fn eq(&self, other: &Self) -> bool {
25 self.steps == other.steps
26 }
27}
28
29impl Eq for KeyBinding {}
30
31impl Hash for KeyBinding {
32 fn hash<H: Hasher>(&self, state: &mut H) {
33 self.steps.hash(state);
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
39pub struct KeyBindings {
40 bindings: Vec<KeyBinding>,
41}
42
43#[derive(Clone, Debug, thiserror::Error)]
45pub enum KeyBindingParseError {
46 #[error("invalid key binding: {0}")]
48 Invalid(String),
49}
50
51impl KeyBinding {
52 pub fn matches_sequence(&self, events: &[KeyEvent]) -> bool {
54 self.steps.len() == events.len()
55 && self.steps.iter().zip(events).all(|(step, event)| {
56 #[cfg(not(target_arch = "wasm32"))]
57 {
58 *step == key_combination_from_event(*event)
59 }
60 #[cfg(target_arch = "wasm32")]
61 {
62 step.as_ref() == key_combination_from_event(event).as_ref()
63 }
64 })
65 }
66
67 pub fn is_chord(&self) -> bool {
69 self.steps.len() > 1
70 }
71
72 pub fn step_count(&self) -> usize {
74 self.steps.len()
75 }
76
77 pub fn canonical(&self) -> &str {
79 &self.canonical
80 }
81
82 pub fn canonical_lowercase(&self) -> String {
84 self.canonical.to_ascii_lowercase()
85 }
86
87 pub(crate) fn from_key_event(key: KeyEvent) -> Self {
88 #[cfg(not(target_arch = "wasm32"))]
89 {
90 Self::from_combination(key_combination_from_event(key))
91 }
92 #[cfg(target_arch = "wasm32")]
93 {
94 Self::from_combination(key_combination_from_event(&key))
95 }
96 }
97
98 #[cfg(not(target_arch = "wasm32"))]
99 pub(crate) fn from_combination(combination: KeyCombination) -> Self {
100 Self::from_steps(vec![combination])
101 }
102
103 #[cfg(target_arch = "wasm32")]
104 pub(crate) fn from_combination(combination: KeyStep) -> Self {
105 Self::from_steps(vec![combination])
106 }
107
108 fn from_steps(steps: Vec<KeyStep>) -> Self {
109 #[cfg(not(target_arch = "wasm32"))]
110 let canonical = steps
111 .iter()
112 .map(|s| canonicalize_combination(&s.to_string()))
113 .collect::<Vec<_>>()
114 .join(" ");
115 #[cfg(target_arch = "wasm32")]
116 let canonical = steps
117 .iter()
118 .map(|s| s.as_ref())
119 .collect::<Vec<_>>()
120 .join(" ");
121 Self {
122 steps,
123 canonical: Arc::from(canonical),
124 }
125 }
126
127 fn matches_step(&self, step_index: usize, key: &KeyEvent) -> bool {
128 self.steps.get(step_index).is_some_and(|step| {
129 #[cfg(not(target_arch = "wasm32"))]
130 {
131 *step == key_combination_from_event(*key)
132 }
133 #[cfg(target_arch = "wasm32")]
134 {
135 step.as_ref() == key_combination_from_event(key).as_ref()
136 }
137 })
138 }
139}
140
141impl FromStr for KeyBinding {
142 type Err = KeyBindingParseError;
143
144 fn from_str(raw: &str) -> Result<Self, Self::Err> {
145 let parts: Vec<&str> = raw.split_whitespace().collect();
146 if parts.is_empty() {
147 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
148 }
149
150 let mut steps = Vec::with_capacity(parts.len());
151 for part in &parts {
152 steps.push(parse_key_combination(part)?);
153 }
154
155 Ok(Self::from_steps(steps))
156 }
157}
158
159impl fmt::Display for KeyBinding {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.write_str(&self.canonical)
162 }
163}
164
165impl KeyBindings {
166 pub fn is_empty(&self) -> bool {
168 self.bindings.is_empty()
169 }
170
171 pub fn len(&self) -> usize {
173 self.bindings.len()
174 }
175
176 pub fn primary(&self) -> Option<&KeyBinding> {
178 self.bindings.first()
179 }
180
181 pub fn iter(&self) -> impl Iterator<Item = &KeyBinding> {
183 self.bindings.iter()
184 }
185
186 pub fn canonical_lowercase(&self) -> String {
188 let mut bindings = self.bindings.iter();
189 let Some(first) = bindings.next() else {
190 return String::new();
191 };
192
193 let mut out = first.canonical_lowercase();
194 for binding in bindings {
195 out.push_str(" / ");
196 out.push_str(&binding.canonical_lowercase());
197 }
198 out
199 }
200}
201
202impl FromStr for KeyBindings {
203 type Err = KeyBindingParseError;
204
205 fn from_str(raw: &str) -> Result<Self, Self::Err> {
206 let mut bindings = Vec::new();
207 for candidate in raw.split(',') {
208 let candidate = candidate.trim();
209 if candidate.is_empty() {
210 continue;
211 }
212 bindings.push(KeyBinding::from_str(candidate)?);
213 }
214
215 if bindings.is_empty() {
216 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
217 }
218
219 Ok(Self { bindings })
220 }
221}
222
223impl fmt::Display for KeyBindings {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 let mut bindings = self.bindings.iter();
226 let Some(first) = bindings.next() else {
227 return Ok(());
228 };
229
230 write!(f, "{first}")?;
231 for binding in bindings {
232 write!(f, " / {binding}")?;
233 }
234 Ok(())
235 }
236}
237
238pub fn format_binding(raw: &str) -> Result<String, KeyBindingParseError> {
240 Ok(KeyBinding::from_str(raw)?.to_string())
241}
242
243pub fn format_binding_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
245 Ok(KeyBinding::from_str(raw)?.canonical_lowercase())
246}
247
248pub fn format_bindings(raw: &str) -> Result<String, KeyBindingParseError> {
250 Ok(KeyBindings::from_str(raw)?.to_string())
251}
252
253pub fn format_bindings_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
255 Ok(KeyBindings::from_str(raw)?.canonical_lowercase())
256}
257
258pub(crate) fn normalize_binding(raw: &str) -> String {
259 let mut normalized = raw.trim().replace('+', "-").to_ascii_lowercase();
260 normalized = normalized.replace("super-", "cmd-");
261 normalized = normalized.replace("command-", "cmd-");
262 normalized = normalized.replace("meta-", "cmd-");
263 normalized = normalized.replace("win-", "cmd-");
264 normalized = normalized.replace("windows-", "cmd-");
265 normalized = normalized.replace("control-", "ctrl-");
266 normalized = normalized.replace("option-", "alt-");
267 normalized = normalized.replace("page-up", "pageup");
268 normalized = normalized.replace("page-down", "pagedown");
269 normalized
270}
271
272pub(crate) fn is_none_binding(raw: &str) -> bool {
273 matches!(
274 normalize_binding(raw).as_str(),
275 "none" | "unbind" | "disabled"
276 )
277}
278
279#[cfg(not(target_arch = "wasm32"))]
280pub(crate) fn key_combination_from_event(key: KeyEvent) -> KeyStep {
281 let normalized = normalize_ctrl_char(key);
282 let ct_event = to_crokey_event(normalized);
283 KeyCombination::from(ct_event)
284}
285
286#[cfg(target_arch = "wasm32")]
287pub(crate) fn key_combination_from_event(key: &KeyEvent) -> KeyStep {
288 let key = normalize_ctrl_char(*key);
289 Arc::from(wasm_event_canonical(&key))
290}
291
292#[cfg(not(target_arch = "wasm32"))]
293pub(crate) fn to_crokey_event(key: KeyEvent) -> crossterm::event::KeyEvent {
294 use crossterm::event::{KeyCode as CTKeyCode, KeyEventKind, KeyEventState, KeyModifiers};
295
296 let mut mods = KeyModifiers::empty();
297 if key.mods.ctrl {
298 mods |= KeyModifiers::CONTROL;
299 }
300 if key.mods.alt {
301 mods |= KeyModifiers::ALT;
302 }
303 if key.mods.shift {
304 mods |= KeyModifiers::SHIFT;
305 }
306 if key.mods.super_key {
307 mods |= KeyModifiers::SUPER;
308 }
309 if matches!(key.code, KeyCode::BackTab) {
310 mods |= KeyModifiers::SHIFT;
311 }
312
313 let code = match key.code {
314 KeyCode::Char(c) => CTKeyCode::Char(c.to_ascii_lowercase()),
315 KeyCode::Enter => CTKeyCode::Enter,
316 KeyCode::Esc => CTKeyCode::Esc,
317 KeyCode::Tab => CTKeyCode::Tab,
318 KeyCode::BackTab => CTKeyCode::Tab,
319 KeyCode::Backspace => CTKeyCode::Backspace,
320 KeyCode::Delete => CTKeyCode::Delete,
321 KeyCode::Home => CTKeyCode::Home,
322 KeyCode::End => CTKeyCode::End,
323 KeyCode::PageUp => CTKeyCode::PageUp,
324 KeyCode::PageDown => CTKeyCode::PageDown,
325 KeyCode::Up => CTKeyCode::Up,
326 KeyCode::Down => CTKeyCode::Down,
327 KeyCode::Left => CTKeyCode::Left,
328 KeyCode::Right => CTKeyCode::Right,
329 KeyCode::Insert => CTKeyCode::Insert,
330 KeyCode::F(n) => CTKeyCode::F(n),
331 };
332
333 crossterm::event::KeyEvent {
334 code,
335 modifiers: mods,
336 kind: KeyEventKind::Press,
337 state: KeyEventState::empty(),
338 }
339}
340
341#[cfg(target_arch = "wasm32")]
342fn wasm_event_canonical(key: &KeyEvent) -> String {
343 if matches!(key.code, KeyCode::BackTab) {
344 return canonicalize_combination(&normalize_binding("shift-tab"));
345 }
346 let mut raw = String::new();
347 if key.mods.ctrl {
348 raw.push_str("ctrl-");
349 }
350 if key.mods.alt {
351 raw.push_str("alt-");
352 }
353 if key.mods.super_key {
354 raw.push_str("cmd-");
355 }
356 if key.mods.shift {
357 raw.push_str("shift-");
358 }
359 match key.code {
360 KeyCode::Char(' ') => raw.push_str("space"),
361 KeyCode::Char(c) if c.is_ascii_alphabetic() => raw.push(c.to_ascii_lowercase()),
362 KeyCode::Char(c) => raw.push(c),
363 KeyCode::Enter => raw.push_str("enter"),
364 KeyCode::Esc => raw.push_str("esc"),
365 KeyCode::Tab => raw.push_str("tab"),
366 KeyCode::Backspace => raw.push_str("backspace"),
367 KeyCode::Delete => raw.push_str("delete"),
368 KeyCode::Insert => raw.push_str("insert"),
369 KeyCode::Home => raw.push_str("home"),
370 KeyCode::End => raw.push_str("end"),
371 KeyCode::PageUp => raw.push_str("pageup"),
372 KeyCode::PageDown => raw.push_str("pagedown"),
373 KeyCode::Up => raw.push_str("up"),
374 KeyCode::Down => raw.push_str("down"),
375 KeyCode::Left => raw.push_str("left"),
376 KeyCode::Right => raw.push_str("right"),
377 KeyCode::F(n) => raw.push_str(&format!("f{n}")),
378 KeyCode::BackTab => unreachable!("handled above"),
379 }
380 canonicalize_combination(&normalize_binding(&raw))
381}
382
383pub(crate) fn normalize_ctrl_char(key: KeyEvent) -> KeyEvent {
384 if key.mods.ctrl || key.mods.alt || key.mods.shift || key.mods.super_key {
385 return key;
386 }
387 let KeyCode::Char(c) = key.code else {
388 return key;
389 };
390 let Some(letter) = ctrl_char_to_letter(c) else {
391 return key;
392 };
393 KeyEvent {
394 code: KeyCode::Char(letter),
395 mods: KeyMods {
396 ctrl: true,
397 ..KeyMods::default()
398 },
399 }
400}
401
402#[cfg(not(target_arch = "wasm32"))]
403fn parse_key_combination(raw: &str) -> Result<KeyStep, KeyBindingParseError> {
404 let normalized = normalize_binding(raw);
405 if normalized.is_empty() {
406 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
407 }
408 KeyCombination::from_str(&normalized)
409 .map_err(|_err| KeyBindingParseError::Invalid(raw.trim().to_string()))
410}
411
412#[cfg(target_arch = "wasm32")]
413fn parse_key_combination(raw: &str) -> Result<KeyStep, KeyBindingParseError> {
414 let normalized = normalize_binding(raw);
415 if normalized.is_empty() {
416 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
417 }
418 if !wasm_normalized_has_key(&normalized) {
419 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
420 }
421 Ok(Arc::from(canonicalize_combination(&normalized)))
422}
423
424#[cfg(target_arch = "wasm32")]
425fn wasm_normalized_has_key(normalized: &str) -> bool {
426 const MODS: &[&str] = &["ctrl", "alt", "cmd", "shift"];
427 let mut saw_non_mod = false;
428 for token in normalized
429 .split('-')
430 .filter(|p| !p.is_empty())
431 .map(|p| p.to_ascii_lowercase())
432 {
433 if MODS.contains(&token.as_str()) {
434 continue;
435 }
436 saw_non_mod = true;
437 break;
438 }
439 saw_non_mod
440}
441
442fn ctrl_char_to_letter(c: char) -> Option<char> {
443 let code = c as u32;
444 if (1..=26).contains(&code) {
445 Some(((code as u8).saturating_sub(1) + b'a') as char)
446 } else {
447 None
448 }
449}
450
451fn canonicalize_combination(raw: &str) -> String {
452 let mut has_ctrl = false;
453 let mut has_alt = false;
454 let mut has_cmd = false;
455 let mut has_shift = false;
456 let mut key_tokens: Vec<String> = Vec::new();
457
458 for token in raw
459 .split('-')
460 .filter(|part| !part.is_empty())
461 .map(|part| part.to_ascii_lowercase())
462 {
463 match token.as_str() {
464 "ctrl" => has_ctrl = true,
465 "alt" => has_alt = true,
466 "cmd" => has_cmd = true,
467 "shift" => has_shift = true,
468 _ => key_tokens.push(token),
469 }
470 }
471
472 let mut parts = Vec::with_capacity(5);
473 if has_ctrl {
474 parts.push("Ctrl".to_string());
475 }
476 if has_alt {
477 parts.push("Alt".to_string());
478 }
479 if has_cmd {
480 parts.push("Cmd".to_string());
481 }
482 if has_shift {
483 parts.push("Shift".to_string());
484 }
485
486 let key = display_key_name(&key_tokens.join("-"));
487 if !key.is_empty() {
488 parts.push(key);
489 }
490
491 parts.join("+")
492}
493
494fn display_key_name(raw: &str) -> String {
495 if raw.len() >= 2
496 && raw.starts_with('f')
497 && raw[1..].chars().all(|ch| ch.is_ascii_digit())
498 && raw[1..].parse::<u8>().is_ok()
499 {
500 return raw.to_ascii_uppercase();
501 }
502
503 match raw {
504 "" => String::new(),
505 "esc" | "escape" => "Esc".to_string(),
506 "enter" | "return" => "Enter".to_string(),
507 "tab" => "Tab".to_string(),
508 "backtab" | "back-tab" => "BackTab".to_string(),
509 "backspace" => "Backspace".to_string(),
510 "delete" => "Delete".to_string(),
511 "insert" => "Insert".to_string(),
512 "home" => "Home".to_string(),
513 "end" => "End".to_string(),
514 "pageup" | "page-up" => "PageUp".to_string(),
515 "pagedown" | "page-down" => "PageDown".to_string(),
516 "up" => "Up".to_string(),
517 "down" => "Down".to_string(),
518 "left" => "Left".to_string(),
519 "right" => "Right".to_string(),
520 "space" => "Space".to_string(),
521 _ if raw.chars().count() == 1 => {
522 let ch = raw.chars().next().unwrap_or_default();
523 if ch.is_ascii_alphabetic() {
524 ch.to_ascii_uppercase().to_string()
525 } else {
526 ch.to_string()
527 }
528 }
529 _ => raw
530 .split('-')
531 .map(title_case_token)
532 .collect::<Vec<_>>()
533 .join("-"),
534 }
535}
536
537fn title_case_token(token: &str) -> String {
538 let mut chars = token.chars();
539 let Some(first) = chars.next() else {
540 return String::new();
541 };
542 let mut out = String::new();
543 if first.is_ascii_alphabetic() {
544 out.push(first.to_ascii_uppercase());
545 } else {
546 out.push(first);
547 }
548 out.push_str(chars.as_str());
549 out
550}
551
552#[derive(Debug, Clone, PartialEq, Eq)]
554pub enum ChordResult<T> {
555 None,
557 Matched(T),
559 Pending,
561}
562
563pub struct ChordMatcher<T> {
594 entries: Vec<(KeyBinding, T)>,
595 pending: Vec<(usize, usize)>,
597}
598
599impl<T> ChordMatcher<T> {
600 pub fn new(entries: Vec<(KeyBinding, T)>) -> Self {
602 Self {
603 entries,
604 pending: Vec::new(),
605 }
606 }
607
608 pub fn feed(&mut self, key: &KeyEvent) -> ChordResult<&T> {
614 if self.pending.is_empty() {
615 return self.try_fresh(key);
616 }
617
618 let mut new_pending = Vec::new();
620 for &(entry_idx, steps_matched) in &self.pending {
621 let (binding, _) = &self.entries[entry_idx];
622 if binding.matches_step(steps_matched, key) {
623 if steps_matched + 1 == binding.step_count() {
624 self.pending.clear();
625 return ChordResult::Matched(&self.entries[entry_idx].1);
626 }
627 new_pending.push((entry_idx, steps_matched + 1));
628 }
629 }
630
631 if !new_pending.is_empty() {
632 self.pending = new_pending;
633 return ChordResult::Pending;
634 }
635
636 self.pending.clear();
638 self.try_fresh(key)
639 }
640
641 pub fn is_pending(&self) -> bool {
643 !self.pending.is_empty()
644 }
645
646 pub fn reset(&mut self) {
648 self.pending.clear();
649 }
650
651 fn try_fresh(&mut self, key: &KeyEvent) -> ChordResult<&T> {
652 let mut first_single_match = None;
653
654 for (idx, (binding, _)) in self.entries.iter().enumerate() {
655 if binding.matches_step(0, key) {
656 if binding.step_count() == 1 {
657 if first_single_match.is_none() {
658 first_single_match = Some(idx);
659 }
660 } else {
661 self.pending.push((idx, 1));
662 }
663 }
664 }
665
666 if !self.pending.is_empty() {
668 return ChordResult::Pending;
669 }
670
671 if let Some(idx) = first_single_match {
672 return ChordResult::Matched(&self.entries[idx].1);
673 }
674
675 ChordResult::None
676 }
677}
678
679#[cfg(all(test, not(target_arch = "wasm32")))]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn normalizes_super_aliases() {
685 let cmd = KeyBinding::from_str("cmd-p").expect("cmd binding parses");
686 let super_key = KeyBinding::from_str("super-p").expect("super alias parses");
687 let command = KeyBinding::from_str("command-p").expect("command alias parses");
688 let meta = KeyBinding::from_str("meta-p").expect("meta alias parses");
689 let win = KeyBinding::from_str("win-p").expect("win alias parses");
690
691 assert_eq!(super_key, cmd);
692 assert_eq!(command, cmd);
693 assert_eq!(meta, cmd);
694 assert_eq!(win, cmd);
695 }
696
697 #[test]
698 fn normalizes_control_and_option_aliases() {
699 let ctrl = KeyBinding::from_str("ctrl-p").expect("ctrl parses");
700 let control = KeyBinding::from_str("control-p").expect("control alias parses");
701 let alt = KeyBinding::from_str("alt-p").expect("alt parses");
702 let option = KeyBinding::from_str("option-p").expect("option alias parses");
703
704 assert_eq!(control, ctrl);
705 assert_eq!(option, alt);
706 }
707
708 #[test]
709 fn formats_bindings_canonically() {
710 assert_eq!(format_binding("ctrl+shift+up").unwrap(), "Ctrl+Shift+Up");
711 assert_eq!(format_binding("esc").unwrap(), "Esc");
712 assert_eq!(format_binding("page-up").unwrap(), "PageUp");
713 assert_eq!(format_binding("f12").unwrap(), "F12");
714 assert_eq!(
715 format_bindings("ctrl+d, ctrl+q").unwrap(),
716 "Ctrl+D / Ctrl+Q"
717 );
718 }
719
720 #[test]
721 fn key_binding_matches_function_key() {
722 let binding = KeyBinding::from_str("f12").expect("binding parses");
723 let key = KeyEvent {
724 code: KeyCode::F(12),
725 mods: KeyMods::default(),
726 };
727 assert!(binding.matches_sequence(&[key]));
728 }
729
730 #[test]
731 fn key_binding_matches_single_event() {
732 let binding = KeyBinding::from_str("ctrl-c").expect("binding parses");
733 let key = KeyEvent {
734 code: KeyCode::Char('c'),
735 mods: KeyMods {
736 ctrl: true,
737 ..KeyMods::default()
738 },
739 };
740 assert!(binding.matches_sequence(&[key]));
741 }
742
743 #[test]
744 fn normalizes_raw_ctrl_chars_for_matching() {
745 let binding = KeyBinding::from_str("ctrl-v").expect("binding parses");
746 let key = KeyEvent {
747 code: KeyCode::Char('\x16'),
748 mods: KeyMods::default(),
749 };
750 assert!(binding.matches_sequence(&[key]));
751 }
752
753 #[test]
754 fn backtab_is_distinct_from_tab() {
755 let tab = KeyBinding::from_str("tab").expect("tab parses");
756 let backtab = KeyBinding::from_str("shift-tab").expect("shift-tab parses");
757
758 assert_ne!(tab, backtab);
759 assert!(tab.matches_sequence(&[KeyEvent {
760 code: KeyCode::Tab,
761 mods: KeyMods::default(),
762 }]));
763 assert!(backtab.matches_sequence(&[KeyEvent {
764 code: KeyCode::BackTab,
765 mods: KeyMods::default(),
766 }]));
767 assert!(!backtab.matches_sequence(&[KeyEvent {
768 code: KeyCode::Tab,
769 mods: KeyMods::default(),
770 }]));
771 }
772
773 #[test]
774 fn rejects_invalid_bindings() {
775 assert!(KeyBinding::from_str("").is_err());
776 assert!(KeyBinding::from_str("ctrl-").is_err());
777 assert!(KeyBindings::from_str(" , ").is_err());
778 }
779
780 #[test]
781 fn formats_lowercase_variants() {
782 assert_eq!(
783 format_binding_lowercase("ctrl+shift+up").unwrap(),
784 "ctrl+shift+up"
785 );
786 assert_eq!(format_binding_lowercase("Esc").unwrap(), "esc");
787 assert_eq!(
788 format_bindings_lowercase("ctrl+d, super+q").unwrap(),
789 "ctrl+d / cmd+q"
790 );
791 }
792
793 #[test]
796 fn parses_chord_binding() {
797 let chord = KeyBinding::from_str("ctrl+x b").expect("chord parses");
798 assert!(chord.is_chord());
799 assert_eq!(chord.step_count(), 2);
800 assert_eq!(chord.canonical(), "Ctrl+X B");
801 }
802
803 #[test]
804 fn chord_matches_sequence() {
805 let chord = KeyBinding::from_str("ctrl+x b").expect("chord parses");
806 let events = [
807 KeyEvent {
808 code: KeyCode::Char('x'),
809 mods: KeyMods {
810 ctrl: true,
811 ..KeyMods::default()
812 },
813 },
814 KeyEvent {
815 code: KeyCode::Char('b'),
816 mods: KeyMods::default(),
817 },
818 ];
819 assert!(chord.matches_sequence(&events));
820 }
821
822 #[test]
823 fn formats_chord_binding() {
824 assert_eq!(format_binding("ctrl+x b").unwrap(), "Ctrl+X B");
825 assert_eq!(format_binding_lowercase("ctrl+x b").unwrap(), "ctrl+x b");
826 }
827
828 #[test]
829 fn formats_chord_with_alternatives() {
830 assert_eq!(
831 format_bindings("ctrl+x b, ctrl+q").unwrap(),
832 "Ctrl+X B / Ctrl+Q"
833 );
834 }
835
836 #[test]
837 fn chord_display_three_steps() {
838 let chord = KeyBinding::from_str("ctrl+x a b").expect("three-step chord parses");
839 assert_eq!(chord.step_count(), 3);
840 assert_eq!(chord.canonical(), "Ctrl+X A B");
841 }
842
843 #[test]
844 fn single_binding_is_not_chord() {
845 let single = KeyBinding::from_str("ctrl+c").expect("single parses");
846 assert!(!single.is_chord());
847 assert_eq!(single.step_count(), 1);
848 }
849
850 #[test]
851 fn chord_equality() {
852 let a = KeyBinding::from_str("ctrl+x b").expect("a parses");
853 let b = KeyBinding::from_str("ctrl-x b").expect("b parses");
854 assert_eq!(a, b);
855 }
856
857 fn ctrl_key(c: char) -> KeyEvent {
860 KeyEvent {
861 code: KeyCode::Char(c),
862 mods: KeyMods {
863 ctrl: true,
864 ..KeyMods::default()
865 },
866 }
867 }
868
869 fn plain_key(c: char) -> KeyEvent {
870 KeyEvent {
871 code: KeyCode::Char(c),
872 mods: KeyMods::default(),
873 }
874 }
875
876 #[test]
877 fn chord_matcher_single_step() {
878 let mut matcher =
879 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+q").unwrap(), "quit")]);
880
881 assert_eq!(matcher.feed(&ctrl_key('q')), ChordResult::Matched(&"quit"));
882 assert_eq!(matcher.feed(&plain_key('x')), ChordResult::None);
883 }
884
885 #[test]
886 fn chord_matcher_two_step_chord() {
887 let mut matcher = ChordMatcher::new(vec![
888 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
889 (KeyBinding::from_str("ctrl+x l").unwrap(), "list"),
890 ]);
891
892 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
893 assert_eq!(
894 matcher.feed(&plain_key('b')),
895 ChordResult::Matched(&"sidebar")
896 );
897
898 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
900 assert_eq!(matcher.feed(&plain_key('l')), ChordResult::Matched(&"list"));
901 }
902
903 #[test]
904 fn chord_matcher_resets_on_wrong_second_key() {
905 let mut matcher =
906 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar")]);
907
908 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
909 assert_eq!(matcher.feed(&plain_key('z')), ChordResult::None);
911 assert!(!matcher.is_pending());
912 }
913
914 #[test]
915 fn chord_matcher_prefix_defers_single_match() {
916 let mut matcher = ChordMatcher::new(vec![
917 (KeyBinding::from_str("ctrl+x").unwrap(), "cut"),
918 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
919 ]);
920
921 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
923 assert_eq!(
925 matcher.feed(&plain_key('b')),
926 ChordResult::Matched(&"sidebar")
927 );
928 }
929
930 #[test]
931 fn chord_matcher_wrong_continuation_tries_fresh() {
932 let mut matcher = ChordMatcher::new(vec![
933 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
934 (KeyBinding::from_str("ctrl+q").unwrap(), "quit"),
935 ]);
936
937 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
938 assert_eq!(matcher.feed(&ctrl_key('q')), ChordResult::Matched(&"quit"));
940 }
941
942 #[test]
943 fn chord_matcher_manual_reset() {
944 let mut matcher =
945 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar")]);
946
947 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
948 assert!(matcher.is_pending());
949 matcher.reset();
950 assert!(!matcher.is_pending());
951 }
952}