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
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56pub enum KeyEventExpansionError {
57 #[error("multi-key combinations cannot be expanded into a single key event")]
59 MultiKeyCombination,
60 #[error("unsupported key code for event expansion: {0}")]
62 UnsupportedKeyCode(String),
63}
64
65impl KeyBinding {
66 pub fn matches_sequence(&self, events: &[KeyEvent]) -> bool {
68 self.steps.len() == events.len()
69 && self.steps.iter().zip(events).all(|(step, event)| {
70 #[cfg(not(target_arch = "wasm32"))]
71 {
72 *step == key_combination_from_event(*event)
73 }
74 #[cfg(target_arch = "wasm32")]
75 {
76 step.as_ref() == key_combination_from_event(event).as_ref()
77 }
78 })
79 }
80
81 pub fn is_chord(&self) -> bool {
83 self.steps.len() > 1
84 }
85
86 pub fn step_count(&self) -> usize {
88 self.steps.len()
89 }
90
91 pub fn key_events(&self) -> Result<Vec<KeyEvent>, KeyEventExpansionError> {
96 self.steps
97 .iter()
98 .map(|step| {
99 #[cfg(not(target_arch = "wasm32"))]
100 {
101 key_event_from_combination(*step)
102 }
103 #[cfg(target_arch = "wasm32")]
104 {
105 key_event_from_canonical(step.as_ref())
106 }
107 })
108 .collect()
109 }
110
111 pub fn canonical(&self) -> &str {
113 &self.canonical
114 }
115
116 pub fn canonical_lowercase(&self) -> String {
118 self.canonical.to_ascii_lowercase()
119 }
120
121 pub fn compact_display(&self) -> String {
128 self.canonical_lowercase()
129 .split_whitespace()
130 .map(compact_display_step)
131 .collect::<Vec<_>>()
132 .join(" ")
133 }
134
135 pub(crate) fn from_key_event(key: KeyEvent) -> Self {
136 #[cfg(not(target_arch = "wasm32"))]
137 {
138 Self::from_combination(key_combination_from_event(key))
139 }
140 #[cfg(target_arch = "wasm32")]
141 {
142 Self::from_combination(key_combination_from_event(&key))
143 }
144 }
145
146 #[cfg(not(target_arch = "wasm32"))]
147 pub(crate) fn from_combination(combination: KeyCombination) -> Self {
148 Self::from_steps(vec![combination])
149 }
150
151 #[cfg(target_arch = "wasm32")]
152 pub(crate) fn from_combination(combination: KeyStep) -> Self {
153 Self::from_steps(vec![combination])
154 }
155
156 fn from_steps(steps: Vec<KeyStep>) -> Self {
157 #[cfg(not(target_arch = "wasm32"))]
158 let canonical = steps
159 .iter()
160 .map(|s| canonicalize_combination(&s.to_string()))
161 .collect::<Vec<_>>()
162 .join(" ");
163 #[cfg(target_arch = "wasm32")]
164 let canonical = steps
165 .iter()
166 .map(|s| s.as_ref())
167 .collect::<Vec<_>>()
168 .join(" ");
169 Self {
170 steps,
171 canonical: Arc::from(canonical),
172 }
173 }
174
175 fn matches_step(&self, step_index: usize, key: &KeyEvent) -> bool {
176 self.steps.get(step_index).is_some_and(|step| {
177 #[cfg(not(target_arch = "wasm32"))]
178 {
179 *step == key_combination_from_event(*key)
180 }
181 #[cfg(target_arch = "wasm32")]
182 {
183 step.as_ref() == key_combination_from_event(key).as_ref()
184 }
185 })
186 }
187}
188
189impl FromStr for KeyBinding {
190 type Err = KeyBindingParseError;
191
192 fn from_str(raw: &str) -> Result<Self, Self::Err> {
193 let parts: Vec<&str> = raw.split_whitespace().collect();
194 if parts.is_empty() {
195 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
196 }
197
198 let mut steps = Vec::with_capacity(parts.len());
199 for part in &parts {
200 steps.push(parse_key_combination(part)?);
201 }
202
203 Ok(Self::from_steps(steps))
204 }
205}
206
207impl fmt::Display for KeyBinding {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 f.write_str(&self.canonical)
210 }
211}
212
213impl KeyBindings {
214 pub fn from_bindings(bindings: impl IntoIterator<Item = KeyBinding>) -> Self {
216 Self {
217 bindings: bindings.into_iter().collect(),
218 }
219 }
220
221 pub fn is_empty(&self) -> bool {
223 self.bindings.is_empty()
224 }
225
226 pub fn len(&self) -> usize {
228 self.bindings.len()
229 }
230
231 pub fn primary(&self) -> Option<&KeyBinding> {
233 self.bindings.first()
234 }
235
236 pub fn iter(&self) -> impl Iterator<Item = &KeyBinding> {
238 self.bindings.iter()
239 }
240
241 pub fn canonical_lowercase(&self) -> String {
243 let mut bindings = self.bindings.iter();
244 let Some(first) = bindings.next() else {
245 return String::new();
246 };
247
248 let mut out = first.canonical_lowercase();
249 for binding in bindings {
250 out.push_str(" / ");
251 out.push_str(&binding.canonical_lowercase());
252 }
253 out
254 }
255
256 pub fn compact_display(&self) -> String {
258 let mut unique = Vec::with_capacity(self.bindings.len());
259 for binding in &self.bindings {
260 let compact = binding.compact_display();
261 if !unique.contains(&compact) {
262 unique.push(compact);
263 }
264 }
265 unique.join(" / ")
266 }
267}
268
269impl FromStr for KeyBindings {
270 type Err = KeyBindingParseError;
271
272 fn from_str(raw: &str) -> Result<Self, Self::Err> {
273 let mut bindings = Vec::new();
274 for candidate in raw.split(',') {
275 let candidate = candidate.trim();
276 if candidate.is_empty() {
277 continue;
278 }
279 bindings.push(KeyBinding::from_str(candidate)?);
280 }
281
282 if bindings.is_empty() {
283 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
284 }
285
286 Ok(Self { bindings })
287 }
288}
289
290impl fmt::Display for KeyBindings {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 let mut bindings = self.bindings.iter();
293 let Some(first) = bindings.next() else {
294 return Ok(());
295 };
296
297 write!(f, "{first}")?;
298 for binding in bindings {
299 write!(f, " / {binding}")?;
300 }
301 Ok(())
302 }
303}
304
305pub fn format_binding(raw: &str) -> Result<String, KeyBindingParseError> {
307 Ok(KeyBinding::from_str(raw)?.to_string())
308}
309
310pub fn format_binding_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
312 Ok(KeyBinding::from_str(raw)?.canonical_lowercase())
313}
314
315pub fn format_binding_compact(raw: &str) -> Result<String, KeyBindingParseError> {
317 Ok(KeyBinding::from_str(raw)?.compact_display())
318}
319
320pub fn format_bindings(raw: &str) -> Result<String, KeyBindingParseError> {
322 Ok(KeyBindings::from_str(raw)?.to_string())
323}
324
325pub fn format_bindings_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
327 Ok(KeyBindings::from_str(raw)?.canonical_lowercase())
328}
329
330pub fn format_bindings_compact(raw: &str) -> Result<String, KeyBindingParseError> {
332 Ok(KeyBindings::from_str(raw)?.compact_display())
333}
334
335fn compact_display_step(step: &str) -> String {
336 let mut rest = step;
337 let mut modifiers = Vec::with_capacity(4);
338 for modifier in ["ctrl", "alt", "cmd", "shift"] {
339 let prefix = format!("{modifier}+");
340 if let Some(stripped) = rest.strip_prefix(&prefix) {
341 modifiers.push(modifier);
342 rest = stripped;
343 }
344 }
345
346 if !modifiers.contains(&"shift") {
347 return step.to_string();
348 }
349
350 let has_other_modifier = modifiers.iter().any(|modifier| *modifier != "shift");
351 if let Some(glyph) = shifted_us_layout_glyph(rest) {
352 return if has_other_modifier {
353 compact_join_modifiers(&modifiers, "shift", &glyph.to_string())
354 } else {
355 glyph.to_string()
356 };
357 }
358
359 if !has_other_modifier && rest.len() == 1 {
360 let key = rest.as_bytes()[0] as char;
361 if key.is_ascii_alphabetic() {
362 return key.to_ascii_uppercase().to_string();
363 }
364 }
365
366 compact_join_modifiers(&modifiers, "", rest)
367}
368
369fn compact_join_modifiers(modifiers: &[&str], omitted: &str, key: &str) -> String {
370 let mut out = String::new();
371 for modifier in modifiers {
372 if *modifier == omitted {
373 continue;
374 }
375 if !out.is_empty() {
376 out.push('+');
377 }
378 out.push_str(modifier);
379 }
380 if !key.is_empty() {
381 if !out.is_empty() {
382 out.push('+');
383 }
384 out.push_str(key);
385 }
386 out
387}
388
389fn shifted_us_layout_glyph(key: &str) -> Option<char> {
390 Some(match key {
391 "`" => '~',
392 "1" => '!',
393 "2" => '@',
394 "3" => '#',
395 "4" => '$',
396 "5" => '%',
397 "6" => '^',
398 "7" => '&',
399 "8" => '*',
400 "9" => '(',
401 "0" => ')',
402 "-" => '_',
403 "=" => '+',
404 "[" => '{',
405 "]" => '}',
406 "\\" => '|',
407 ";" => ':',
408 "'" => '"',
409 "," => '<',
410 "." => '>',
411 "/" => '?',
412 _ => return None,
413 })
414}
415
416pub(crate) fn normalize_binding(raw: &str) -> String {
417 let mut normalized = raw
421 .split_whitespace()
422 .map(normalize_chord_step)
423 .collect::<Vec<_>>()
424 .join(" ");
425 normalized = normalized.replace("super-", "cmd-");
426 normalized = normalized.replace("command-", "cmd-");
427 normalized = normalized.replace("meta-", "cmd-");
428 normalized = normalized.replace("win-", "cmd-");
429 normalized = normalized.replace("windows-", "cmd-");
430 normalized = normalized.replace("control-", "ctrl-");
431 normalized = normalized.replace("option-", "alt-");
432 normalized = normalized.replace("page-up", "pageup");
433 normalized = normalized.replace("page-down", "pagedown");
434 normalized
435}
436
437fn normalize_chord_step(step: &str) -> String {
438 let step = step.to_ascii_lowercase();
439 if step == "+" || step == "plus" {
440 return "+".to_string();
441 }
442
443 let (mods, plus_key) = if let Some(rest) = step.strip_suffix("-plus") {
444 (rest, true)
445 } else if let Some(rest) = step.strip_suffix("+plus") {
446 (rest, true)
447 } else if let Some(rest) = step.strip_suffix("-+") {
448 (rest, true)
449 } else if let Some(rest) = step.strip_suffix("++") {
450 (rest, true)
451 } else {
452 (step.as_str(), false)
453 };
454
455 let mods = mods.replace('+', "-");
456 if plus_key {
457 if mods.is_empty() {
458 "+".to_string()
459 } else {
460 format!("{mods}-+")
461 }
462 } else {
463 mods
464 }
465}
466
467pub(crate) fn is_none_binding(raw: &str) -> bool {
468 matches!(
469 normalize_binding(raw).as_str(),
470 "none" | "unbind" | "disabled"
471 )
472}
473
474#[cfg(not(target_arch = "wasm32"))]
475pub(crate) fn key_combination_from_event(key: KeyEvent) -> KeyStep {
476 let normalized = normalize_ctrl_char(key);
477 let ct_event = to_crokey_event(normalized);
478 KeyCombination::from(ct_event)
479}
480
481#[cfg(target_arch = "wasm32")]
482pub(crate) fn key_combination_from_event(key: &KeyEvent) -> KeyStep {
483 let key = normalize_ctrl_char(*key);
484 Arc::from(wasm_event_canonical(&key))
485}
486
487#[cfg(not(target_arch = "wasm32"))]
488pub(crate) fn to_crokey_event(key: KeyEvent) -> crossterm::event::KeyEvent {
489 use crossterm::event::{KeyCode as CTKeyCode, KeyEventKind, KeyEventState, KeyModifiers};
490
491 let mut mods = KeyModifiers::empty();
492 if key.mods.ctrl {
493 mods |= KeyModifiers::CONTROL;
494 }
495 if key.mods.alt {
496 mods |= KeyModifiers::ALT;
497 }
498 if key.mods.shift {
499 mods |= KeyModifiers::SHIFT;
500 }
501 if key.mods.super_key {
502 mods |= KeyModifiers::SUPER;
503 }
504 if matches!(key.code, KeyCode::BackTab) {
505 mods |= KeyModifiers::SHIFT;
506 }
507
508 let code = match key.code {
509 KeyCode::Char(c) => CTKeyCode::Char(c.to_ascii_lowercase()),
510 KeyCode::Enter => CTKeyCode::Enter,
511 KeyCode::Esc => CTKeyCode::Esc,
512 KeyCode::Tab => CTKeyCode::Tab,
513 KeyCode::BackTab => CTKeyCode::Tab,
514 KeyCode::Backspace => CTKeyCode::Backspace,
515 KeyCode::Delete => CTKeyCode::Delete,
516 KeyCode::Home => CTKeyCode::Home,
517 KeyCode::End => CTKeyCode::End,
518 KeyCode::PageUp => CTKeyCode::PageUp,
519 KeyCode::PageDown => CTKeyCode::PageDown,
520 KeyCode::Up => CTKeyCode::Up,
521 KeyCode::Down => CTKeyCode::Down,
522 KeyCode::Left => CTKeyCode::Left,
523 KeyCode::Right => CTKeyCode::Right,
524 KeyCode::Insert => CTKeyCode::Insert,
525 KeyCode::F(n) => CTKeyCode::F(n),
526 };
527
528 crossterm::event::KeyEvent {
529 code,
530 modifiers: mods,
531 kind: KeyEventKind::Press,
532 state: KeyEventState::empty(),
533 }
534}
535
536#[cfg(not(target_arch = "wasm32"))]
537fn key_event_from_combination(
538 combination: KeyCombination,
539) -> Result<KeyEvent, KeyEventExpansionError> {
540 use crokey::OneToThree;
541 use crossterm::event::KeyCode as CtKeyCode;
542
543 let combination = combination.normalized();
544 let ct_code = match combination.codes {
545 OneToThree::One(code) => code,
546 _ => return Err(KeyEventExpansionError::MultiKeyCombination),
547 };
548
549 let mut mods = KeyMods::NONE;
550 if combination
551 .modifiers
552 .contains(crossterm::event::KeyModifiers::CONTROL)
553 {
554 mods.ctrl = true;
555 }
556 if combination
557 .modifiers
558 .contains(crossterm::event::KeyModifiers::ALT)
559 {
560 mods.alt = true;
561 }
562 if combination
563 .modifiers
564 .contains(crossterm::event::KeyModifiers::SHIFT)
565 {
566 mods.shift = true;
567 }
568 if combination
569 .modifiers
570 .contains(crossterm::event::KeyModifiers::SUPER)
571 {
572 mods.super_key = true;
573 }
574
575 let code = match ct_code {
576 CtKeyCode::Char(c) => KeyCode::Char(c),
577 CtKeyCode::Enter => KeyCode::Enter,
578 CtKeyCode::Esc => KeyCode::Esc,
579 CtKeyCode::Tab if mods.shift => {
580 mods.shift = false;
581 KeyCode::BackTab
582 }
583 CtKeyCode::Tab => KeyCode::Tab,
584 CtKeyCode::Backspace => KeyCode::Backspace,
585 CtKeyCode::Delete => KeyCode::Delete,
586 CtKeyCode::Home => KeyCode::Home,
587 CtKeyCode::End => KeyCode::End,
588 CtKeyCode::PageUp => KeyCode::PageUp,
589 CtKeyCode::PageDown => KeyCode::PageDown,
590 CtKeyCode::Up => KeyCode::Up,
591 CtKeyCode::Down => KeyCode::Down,
592 CtKeyCode::Left => KeyCode::Left,
593 CtKeyCode::Right => KeyCode::Right,
594 CtKeyCode::Insert => KeyCode::Insert,
595 CtKeyCode::F(n) => KeyCode::F(n),
596 other => {
597 return Err(KeyEventExpansionError::UnsupportedKeyCode(format!(
598 "{other:?}"
599 )));
600 }
601 };
602
603 Ok(KeyEvent { code, mods })
604}
605
606#[cfg(any(target_arch = "wasm32", test))]
610fn key_event_from_canonical(canonical: &str) -> Result<KeyEvent, KeyEventExpansionError> {
611 let plus_key = canonical == "+" || canonical.ends_with("++");
614 let body = if canonical == "+" {
615 ""
616 } else if let Some(rest) = canonical.strip_suffix("++") {
617 rest
618 } else {
619 canonical
620 };
621
622 let mut mods = KeyMods::NONE;
623 let mut key_token = None;
624 for part in body.split('+').filter(|part| !part.is_empty()) {
625 match part.to_ascii_lowercase().as_str() {
626 "ctrl" => mods.ctrl = true,
627 "alt" => mods.alt = true,
628 "cmd" | "super" => mods.super_key = true,
629 "shift" => mods.shift = true,
630 token if key_token.is_none() => key_token = Some(token.to_string()),
631 _ => {
632 return Err(KeyEventExpansionError::UnsupportedKeyCode(
633 canonical.to_string(),
634 ));
635 }
636 }
637 }
638 if plus_key {
639 key_token = Some("+".to_string());
640 }
641 let Some(token) = key_token else {
642 return Err(KeyEventExpansionError::UnsupportedKeyCode(
643 canonical.to_string(),
644 ));
645 };
646 let code = match token.as_str() {
647 "esc" | "escape" => KeyCode::Esc,
648 "enter" | "return" => KeyCode::Enter,
649 "tab" if mods.shift => {
652 mods.shift = false;
653 KeyCode::BackTab
654 }
655 "tab" => KeyCode::Tab,
656 "backtab" => KeyCode::BackTab,
657 "backspace" => KeyCode::Backspace,
658 "delete" => KeyCode::Delete,
659 "insert" => KeyCode::Insert,
660 "home" => KeyCode::Home,
661 "end" => KeyCode::End,
662 "pageup" => KeyCode::PageUp,
663 "pagedown" => KeyCode::PageDown,
664 "up" => KeyCode::Up,
665 "down" => KeyCode::Down,
666 "left" => KeyCode::Left,
667 "right" => KeyCode::Right,
668 "space" => KeyCode::Char(' '),
669 "hyphen" | "minus" => KeyCode::Char('-'),
670 "plus" => KeyCode::Char('+'),
671 token if token.len() >= 2 && token.starts_with('f') && token[1..].parse::<u8>().is_ok() => {
672 KeyCode::F(token[1..].parse().unwrap_or(1))
673 }
674 token if token.chars().count() == 1 => KeyCode::Char(token.chars().next().unwrap()),
675 _ => {
676 return Err(KeyEventExpansionError::UnsupportedKeyCode(
677 canonical.to_string(),
678 ));
679 }
680 };
681 Ok(KeyEvent { code, mods })
682}
683
684#[cfg(target_arch = "wasm32")]
685fn wasm_event_canonical(key: &KeyEvent) -> String {
686 if matches!(key.code, KeyCode::BackTab) {
687 return canonicalize_combination(&normalize_binding("shift-tab"));
688 }
689 let mut raw = String::new();
690 if key.mods.ctrl {
691 raw.push_str("ctrl-");
692 }
693 if key.mods.alt {
694 raw.push_str("alt-");
695 }
696 if key.mods.super_key {
697 raw.push_str("cmd-");
698 }
699 if key.mods.shift {
700 raw.push_str("shift-");
701 }
702 match key.code {
703 KeyCode::Char(' ') => raw.push_str("space"),
704 KeyCode::Char(c) if c.is_ascii_alphabetic() => raw.push(c.to_ascii_lowercase()),
705 KeyCode::Char(c) => raw.push(c),
706 KeyCode::Enter => raw.push_str("enter"),
707 KeyCode::Esc => raw.push_str("esc"),
708 KeyCode::Tab => raw.push_str("tab"),
709 KeyCode::Backspace => raw.push_str("backspace"),
710 KeyCode::Delete => raw.push_str("delete"),
711 KeyCode::Insert => raw.push_str("insert"),
712 KeyCode::Home => raw.push_str("home"),
713 KeyCode::End => raw.push_str("end"),
714 KeyCode::PageUp => raw.push_str("pageup"),
715 KeyCode::PageDown => raw.push_str("pagedown"),
716 KeyCode::Up => raw.push_str("up"),
717 KeyCode::Down => raw.push_str("down"),
718 KeyCode::Left => raw.push_str("left"),
719 KeyCode::Right => raw.push_str("right"),
720 KeyCode::F(n) => raw.push_str(&format!("f{n}")),
721 KeyCode::BackTab => unreachable!("handled above"),
722 }
723 canonicalize_combination(&normalize_binding(&raw))
724}
725
726pub(crate) fn normalize_ctrl_char(key: KeyEvent) -> KeyEvent {
727 if key.mods.ctrl || key.mods.alt || key.mods.shift || key.mods.super_key {
728 return key;
729 }
730 let KeyCode::Char(c) = key.code else {
731 return key;
732 };
733 let Some(letter) = ctrl_char_to_letter(c) else {
734 return key;
735 };
736 KeyEvent {
737 code: KeyCode::Char(letter),
738 mods: KeyMods {
739 ctrl: true,
740 ..KeyMods::default()
741 },
742 }
743}
744
745#[cfg(not(target_arch = "wasm32"))]
746fn parse_key_combination(raw: &str) -> Result<KeyStep, KeyBindingParseError> {
747 let normalized = normalize_binding(raw);
748 if normalized.is_empty() {
749 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
750 }
751 KeyCombination::from_str(&normalized)
752 .map_err(|_err| KeyBindingParseError::Invalid(raw.trim().to_string()))
753}
754
755#[cfg(target_arch = "wasm32")]
756fn parse_key_combination(raw: &str) -> Result<KeyStep, KeyBindingParseError> {
757 let normalized = normalize_binding(raw);
758 if normalized.is_empty() {
759 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
760 }
761 if !wasm_normalized_has_key(&normalized) {
762 return Err(KeyBindingParseError::Invalid(raw.trim().to_string()));
763 }
764 Ok(Arc::from(canonicalize_combination(&normalized)))
765}
766
767#[cfg(target_arch = "wasm32")]
768fn wasm_normalized_has_key(normalized: &str) -> bool {
769 if matches!(normalized, "-" | "+" | "minus" | "hyphen" | "plus") {
771 return true;
772 }
773 const MODS: &[&str] = &["ctrl", "alt", "cmd", "shift"];
774 let mut saw_non_mod = false;
775 for token in normalized
776 .split('-')
777 .filter(|p| !p.is_empty())
778 .map(|p| p.to_ascii_lowercase())
779 {
780 if MODS.contains(&token.as_str()) {
781 continue;
782 }
783 saw_non_mod = true;
784 break;
785 }
786 saw_non_mod
787}
788
789fn ctrl_char_to_letter(c: char) -> Option<char> {
790 let code = c as u32;
791 if (1..=26).contains(&code) {
792 Some(((code as u8).saturating_sub(1) + b'a') as char)
793 } else {
794 None
795 }
796}
797
798fn canonicalize_combination(raw: &str) -> String {
799 let mut has_ctrl = false;
800 let mut has_alt = false;
801 let mut has_cmd = false;
802 let mut has_shift = false;
803 let mut key_tokens: Vec<String> = Vec::new();
804
805 for token in raw
806 .split('-')
807 .filter(|part| !part.is_empty())
808 .map(|part| part.to_ascii_lowercase())
809 {
810 match token.as_str() {
811 "ctrl" => has_ctrl = true,
812 "alt" => has_alt = true,
813 "cmd" => has_cmd = true,
814 "shift" => has_shift = true,
815 _ => key_tokens.push(token),
816 }
817 }
818
819 let mut parts = Vec::with_capacity(5);
820 if has_ctrl {
821 parts.push("Ctrl".to_string());
822 }
823 if has_alt {
824 parts.push("Alt".to_string());
825 }
826 if has_cmd {
827 parts.push("Cmd".to_string());
828 }
829 if has_shift {
830 parts.push("Shift".to_string());
831 }
832
833 let key = display_key_name(&key_tokens.join("-"));
834 if !key.is_empty() {
835 parts.push(key);
836 }
837
838 parts.join("+")
839}
840
841fn display_key_name(raw: &str) -> String {
842 if raw.len() >= 2
843 && raw.starts_with('f')
844 && raw[1..].chars().all(|ch| ch.is_ascii_digit())
845 && raw[1..].parse::<u8>().is_ok()
846 {
847 return raw.to_ascii_uppercase();
848 }
849
850 match raw {
851 "" => String::new(),
852 "esc" | "escape" => "Esc".to_string(),
853 "enter" | "return" => "Enter".to_string(),
854 "tab" => "Tab".to_string(),
855 "backtab" | "back-tab" => "BackTab".to_string(),
856 "backspace" => "Backspace".to_string(),
857 "delete" => "Delete".to_string(),
858 "insert" => "Insert".to_string(),
859 "home" => "Home".to_string(),
860 "end" => "End".to_string(),
861 "pageup" | "page-up" => "PageUp".to_string(),
862 "pagedown" | "page-down" => "PageDown".to_string(),
863 "up" => "Up".to_string(),
864 "down" => "Down".to_string(),
865 "left" => "Left".to_string(),
866 "right" => "Right".to_string(),
867 "space" => "Space".to_string(),
868 "hyphen" | "minus" => "-".to_string(),
869 "plus" => "+".to_string(),
870 _ if raw.chars().count() == 1 => {
871 let ch = raw.chars().next().unwrap_or_default();
872 if ch.is_ascii_alphabetic() {
873 ch.to_ascii_uppercase().to_string()
874 } else {
875 ch.to_string()
876 }
877 }
878 _ => raw
879 .split('-')
880 .map(title_case_token)
881 .collect::<Vec<_>>()
882 .join("-"),
883 }
884}
885
886fn title_case_token(token: &str) -> String {
887 let mut chars = token.chars();
888 let Some(first) = chars.next() else {
889 return String::new();
890 };
891 let mut out = String::new();
892 if first.is_ascii_alphabetic() {
893 out.push(first.to_ascii_uppercase());
894 } else {
895 out.push(first);
896 }
897 out.push_str(chars.as_str());
898 out
899}
900
901#[derive(Debug, Clone, PartialEq, Eq)]
903pub enum ChordResult<T> {
904 None,
906 Matched(T),
908 Pending,
910}
911
912pub struct ChordMatcher<T> {
943 entries: Vec<(KeyBinding, T)>,
944 pending: Vec<(usize, usize)>,
946}
947
948impl<T> ChordMatcher<T> {
949 pub fn new(entries: Vec<(KeyBinding, T)>) -> Self {
951 Self {
952 entries,
953 pending: Vec::new(),
954 }
955 }
956
957 pub fn feed(&mut self, key: &KeyEvent) -> ChordResult<&T> {
963 if self.pending.is_empty() {
964 return self.try_fresh(key);
965 }
966
967 let mut new_pending = Vec::new();
969 for &(entry_idx, steps_matched) in &self.pending {
970 let (binding, _) = &self.entries[entry_idx];
971 if binding.matches_step(steps_matched, key) {
972 if steps_matched + 1 == binding.step_count() {
973 self.pending.clear();
974 return ChordResult::Matched(&self.entries[entry_idx].1);
975 }
976 new_pending.push((entry_idx, steps_matched + 1));
977 }
978 }
979
980 if !new_pending.is_empty() {
981 self.pending = new_pending;
982 return ChordResult::Pending;
983 }
984
985 self.pending.clear();
987 self.try_fresh(key)
988 }
989
990 pub fn is_pending(&self) -> bool {
992 !self.pending.is_empty()
993 }
994
995 pub fn reset(&mut self) {
997 self.pending.clear();
998 }
999
1000 fn try_fresh(&mut self, key: &KeyEvent) -> ChordResult<&T> {
1001 let mut first_single_match = None;
1002
1003 for (idx, (binding, _)) in self.entries.iter().enumerate() {
1004 if binding.matches_step(0, key) {
1005 if binding.step_count() == 1 {
1006 if first_single_match.is_none() {
1007 first_single_match = Some(idx);
1008 }
1009 } else {
1010 self.pending.push((idx, 1));
1011 }
1012 }
1013 }
1014
1015 if !self.pending.is_empty() {
1017 return ChordResult::Pending;
1018 }
1019
1020 if let Some(idx) = first_single_match {
1021 return ChordResult::Matched(&self.entries[idx].1);
1022 }
1023
1024 ChordResult::None
1025 }
1026}
1027
1028#[cfg(all(test, not(target_arch = "wasm32")))]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn normalizes_super_aliases() {
1034 let cmd = KeyBinding::from_str("cmd-p").expect("cmd binding parses");
1035 let super_key = KeyBinding::from_str("super-p").expect("super alias parses");
1036 let command = KeyBinding::from_str("command-p").expect("command alias parses");
1037 let meta = KeyBinding::from_str("meta-p").expect("meta alias parses");
1038 let win = KeyBinding::from_str("win-p").expect("win alias parses");
1039
1040 assert_eq!(super_key, cmd);
1041 assert_eq!(command, cmd);
1042 assert_eq!(meta, cmd);
1043 assert_eq!(win, cmd);
1044 }
1045
1046 #[test]
1047 fn normalizes_control_and_option_aliases() {
1048 let ctrl = KeyBinding::from_str("ctrl-p").expect("ctrl parses");
1049 let control = KeyBinding::from_str("control-p").expect("control alias parses");
1050 let alt = KeyBinding::from_str("alt-p").expect("alt parses");
1051 let option = KeyBinding::from_str("option-p").expect("option alias parses");
1052
1053 assert_eq!(control, ctrl);
1054 assert_eq!(option, alt);
1055 }
1056
1057 #[test]
1058 fn formats_bindings_canonically() {
1059 assert_eq!(format_binding("ctrl+shift+up").unwrap(), "Ctrl+Shift+Up");
1060 assert_eq!(format_binding("esc").unwrap(), "Esc");
1061 assert_eq!(format_binding("page-up").unwrap(), "PageUp");
1062 assert_eq!(format_binding("f12").unwrap(), "F12");
1063 assert_eq!(
1064 format_bindings("ctrl+d, ctrl+q").unwrap(),
1065 "Ctrl+D / Ctrl+Q"
1066 );
1067 }
1068
1069 #[test]
1070 fn parses_and_displays_plus_and_minus_keys() {
1071 let plus = KeyBinding::from_str("+").expect("bare + parses");
1072 let plus_name = KeyBinding::from_str("plus").expect("plus name parses");
1073 let alt_plus = KeyBinding::from_str("alt-plus").expect("alt-plus parses");
1074 let alt_plus_glyph = KeyBinding::from_str("alt-+").expect("alt-+ parses");
1075 let minus = KeyBinding::from_str("-").expect("bare - parses");
1076 let minus_name = KeyBinding::from_str("minus").expect("minus parses");
1077 let hyphen = KeyBinding::from_str("hyphen").expect("hyphen parses");
1078 let shift_eq = KeyBinding::from_str("shift-=").expect("shift-= parses");
1079
1080 assert_eq!(plus, plus_name);
1081 assert_eq!(alt_plus, alt_plus_glyph);
1082 assert_eq!(minus, minus_name);
1083 assert_eq!(minus, hyphen);
1084
1085 assert_eq!(plus.canonical(), "+");
1086 assert_eq!(alt_plus.canonical(), "Alt++");
1087 assert_eq!(minus.canonical(), "-");
1088 assert_eq!(shift_eq.canonical(), "Shift+=");
1089
1090 assert!(plus.matches_sequence(&[KeyEvent {
1091 code: KeyCode::Char('+'),
1092 mods: KeyMods::default(),
1093 }]));
1094 assert!(alt_plus.matches_sequence(&[KeyEvent {
1095 code: KeyCode::Char('+'),
1096 mods: KeyMods {
1097 alt: true,
1098 ..KeyMods::default()
1099 },
1100 }]));
1101 assert!(minus.matches_sequence(&[KeyEvent {
1102 code: KeyCode::Char('-'),
1103 mods: KeyMods::default(),
1104 }]));
1105 assert!(shift_eq.matches_sequence(&[KeyEvent {
1106 code: KeyCode::Char('='),
1107 mods: KeyMods {
1108 shift: true,
1109 ..KeyMods::default()
1110 },
1111 }]));
1112 assert!(!plus.matches_sequence(&[KeyEvent {
1113 code: KeyCode::Char('='),
1114 mods: KeyMods {
1115 shift: true,
1116 ..KeyMods::default()
1117 },
1118 }]));
1119 }
1120
1121 #[test]
1122 fn plus_keeps_modifier_separator_behavior() {
1123 assert_eq!(format_binding("ctrl+c").unwrap(), "Ctrl+C");
1124 assert_eq!(
1125 KeyBinding::from_str("ctrl+c").unwrap(),
1126 KeyBinding::from_str("ctrl-c").unwrap()
1127 );
1128 }
1129
1130 #[test]
1131 fn key_binding_matches_function_key() {
1132 let binding = KeyBinding::from_str("f12").expect("binding parses");
1133 let key = KeyEvent {
1134 code: KeyCode::F(12),
1135 mods: KeyMods::default(),
1136 };
1137 assert!(binding.matches_sequence(&[key]));
1138 }
1139
1140 #[test]
1141 fn key_binding_matches_single_event() {
1142 let binding = KeyBinding::from_str("ctrl-c").expect("binding parses");
1143 let key = KeyEvent {
1144 code: KeyCode::Char('c'),
1145 mods: KeyMods {
1146 ctrl: true,
1147 ..KeyMods::default()
1148 },
1149 };
1150 assert!(binding.matches_sequence(&[key]));
1151 }
1152
1153 #[test]
1154 fn key_binding_expands_to_key_events() {
1155 let events = KeyBinding::from_str("ctrl-c")
1156 .expect("parses")
1157 .key_events()
1158 .expect("expands");
1159 assert_eq!(
1160 events,
1161 vec![KeyEvent {
1162 code: KeyCode::Char('c'),
1163 mods: KeyMods {
1164 ctrl: true,
1165 ..KeyMods::default()
1166 },
1167 }]
1168 );
1169
1170 let chord = KeyBinding::from_str("ctrl-x b")
1171 .expect("parses")
1172 .key_events()
1173 .expect("expands");
1174 assert_eq!(chord.len(), 2);
1175 assert_eq!(chord[0].code, KeyCode::Char('x'));
1176 assert!(chord[0].mods.ctrl);
1177 assert_eq!(chord[1].code, KeyCode::Char('b'));
1178 }
1179
1180 #[test]
1181 fn key_binding_expands_shift_tab_to_backtab() {
1182 let events = KeyBinding::from_str("shift-tab")
1183 .expect("parses")
1184 .key_events()
1185 .expect("expands");
1186 assert_eq!(events.len(), 1);
1187 assert_eq!(events[0].code, KeyCode::BackTab);
1188 assert!(
1189 !events[0].mods.shift,
1190 "BackTab already encodes the shift, so it must not be reported twice"
1191 );
1192 }
1193
1194 #[test]
1197 fn canonical_expansion_matches_native_expansion() {
1198 for raw in [
1199 "ctrl-c",
1200 "shift-tab",
1201 "alt-enter",
1202 "f5",
1203 "esc",
1204 "space",
1205 "up",
1206 "pagedown",
1207 "+",
1208 "plus",
1209 "alt-plus",
1210 "-",
1211 "minus",
1212 "shift-=",
1213 ] {
1214 let binding = KeyBinding::from_str(raw).expect("parses");
1215 let native = binding.key_events().expect("native expands");
1216 let canonical =
1217 super::key_event_from_canonical(binding.canonical()).expect("canonical expands");
1218 assert_eq!(
1219 native,
1220 vec![canonical],
1221 "native and canonical expansion disagree for {raw:?}"
1222 );
1223 }
1224 }
1225
1226 #[test]
1227 fn normalizes_raw_ctrl_chars_for_matching() {
1228 let binding = KeyBinding::from_str("ctrl-v").expect("binding parses");
1229 let key = KeyEvent {
1230 code: KeyCode::Char('\x16'),
1231 mods: KeyMods::default(),
1232 };
1233 assert!(binding.matches_sequence(&[key]));
1234 }
1235
1236 #[test]
1237 fn backtab_is_distinct_from_tab() {
1238 let tab = KeyBinding::from_str("tab").expect("tab parses");
1239 let backtab = KeyBinding::from_str("shift-tab").expect("shift-tab parses");
1240
1241 assert_ne!(tab, backtab);
1242 assert!(tab.matches_sequence(&[KeyEvent {
1243 code: KeyCode::Tab,
1244 mods: KeyMods::default(),
1245 }]));
1246 assert!(backtab.matches_sequence(&[KeyEvent {
1247 code: KeyCode::BackTab,
1248 mods: KeyMods::default(),
1249 }]));
1250 assert!(!backtab.matches_sequence(&[KeyEvent {
1251 code: KeyCode::Tab,
1252 mods: KeyMods::default(),
1253 }]));
1254 }
1255
1256 #[test]
1257 fn rejects_invalid_bindings() {
1258 assert!(KeyBinding::from_str("").is_err());
1259 assert!(KeyBinding::from_str("ctrl-").is_err());
1260 assert!(KeyBindings::from_str(" , ").is_err());
1261 }
1262
1263 #[test]
1264 fn formats_lowercase_variants() {
1265 assert_eq!(
1266 format_binding_lowercase("ctrl+shift+up").unwrap(),
1267 "ctrl+shift+up"
1268 );
1269 assert_eq!(format_binding_lowercase("Esc").unwrap(), "esc");
1270 assert_eq!(
1271 format_bindings_lowercase("ctrl+d, super+q").unwrap(),
1272 "ctrl+d / cmd+q"
1273 );
1274 }
1275
1276 #[test]
1277 fn compact_display_uses_shifted_letters_and_punctuation() {
1278 assert_eq!(KeyBinding::from_str("m").unwrap().compact_display(), "m");
1279 assert_eq!(
1280 KeyBinding::from_str("shift-m").unwrap().compact_display(),
1281 "M"
1282 );
1283 assert_eq!(format_binding_compact("m").unwrap(), "m");
1284 assert_eq!(format_binding_compact("shift-m").unwrap(), "M");
1285 assert_eq!(format_binding_compact("shift-/").unwrap(), "?");
1286 assert_eq!(format_binding_compact("shift-1").unwrap(), "!");
1287 assert_eq!(format_binding_compact("shift-0").unwrap(), ")");
1288 assert_eq!(format_binding_compact("shift-`").unwrap(), "~");
1289 assert_eq!(format_binding_compact("shift-=").unwrap(), "+");
1290 assert_eq!(format_binding_compact("shift-\\").unwrap(), "|");
1291 }
1292
1293 #[test]
1294 fn compact_display_keeps_shift_for_modified_letters_but_collapses_punctuation() {
1295 assert_eq!(
1296 format_binding_compact("ctrl-shift-x").unwrap(),
1297 "ctrl+shift+x"
1298 );
1299 assert_eq!(format_binding_compact("ctrl-shift-3").unwrap(), "ctrl+#");
1300 assert_eq!(format_binding_compact("alt-shift-/").unwrap(), "alt+?");
1301 }
1302
1303 #[test]
1304 fn compact_display_deduplicates_aliases_and_preserves_chords() {
1305 let aliases = KeyBindings::from_str("?, shift-/ , shift-m, m").unwrap();
1306 assert_eq!(aliases.compact_display(), "? / M / m");
1307 assert_eq!(
1308 format_bindings_compact("?, shift-/ , shift-m, m").unwrap(),
1309 "? / M / m"
1310 );
1311 assert_eq!(
1312 format_bindings_compact("ctrl-#, ctrl-shift-3").unwrap(),
1313 "ctrl+#"
1314 );
1315 assert_eq!(
1316 format_binding_compact("shift-m ctrl-shift-x").unwrap(),
1317 "M ctrl+shift+x"
1318 );
1319 }
1320
1321 #[test]
1322 fn compact_display_keeps_special_shift_and_literal_plus_minus_keys() {
1323 assert_eq!(format_binding_compact("shift-tab").unwrap(), "shift+tab");
1324 assert_eq!(format_binding_compact("shift-left").unwrap(), "shift+left");
1325 assert_eq!(format_binding_compact("+").unwrap(), "+");
1326 assert_eq!(format_binding_compact("minus").unwrap(), "-");
1327 assert_eq!(format_binding_compact("ctrl-plus -").unwrap(), "ctrl++ -");
1328 }
1329
1330 #[test]
1333 fn parses_chord_binding() {
1334 let chord = KeyBinding::from_str("ctrl+x b").expect("chord parses");
1335 assert!(chord.is_chord());
1336 assert_eq!(chord.step_count(), 2);
1337 assert_eq!(chord.canonical(), "Ctrl+X B");
1338 }
1339
1340 #[test]
1341 fn chord_matches_sequence() {
1342 let chord = KeyBinding::from_str("ctrl+x b").expect("chord parses");
1343 let events = [
1344 KeyEvent {
1345 code: KeyCode::Char('x'),
1346 mods: KeyMods {
1347 ctrl: true,
1348 ..KeyMods::default()
1349 },
1350 },
1351 KeyEvent {
1352 code: KeyCode::Char('b'),
1353 mods: KeyMods::default(),
1354 },
1355 ];
1356 assert!(chord.matches_sequence(&events));
1357 }
1358
1359 #[test]
1360 fn formats_chord_binding() {
1361 assert_eq!(format_binding("ctrl+x b").unwrap(), "Ctrl+X B");
1362 assert_eq!(format_binding_lowercase("ctrl+x b").unwrap(), "ctrl+x b");
1363 }
1364
1365 #[test]
1366 fn formats_chord_with_alternatives() {
1367 assert_eq!(
1368 format_bindings("ctrl+x b, ctrl+q").unwrap(),
1369 "Ctrl+X B / Ctrl+Q"
1370 );
1371 }
1372
1373 #[test]
1374 fn chord_display_three_steps() {
1375 let chord = KeyBinding::from_str("ctrl+x a b").expect("three-step chord parses");
1376 assert_eq!(chord.step_count(), 3);
1377 assert_eq!(chord.canonical(), "Ctrl+X A B");
1378 }
1379
1380 #[test]
1381 fn single_binding_is_not_chord() {
1382 let single = KeyBinding::from_str("ctrl+c").expect("single parses");
1383 assert!(!single.is_chord());
1384 assert_eq!(single.step_count(), 1);
1385 }
1386
1387 #[test]
1388 fn chord_equality() {
1389 let a = KeyBinding::from_str("ctrl+x b").expect("a parses");
1390 let b = KeyBinding::from_str("ctrl-x b").expect("b parses");
1391 assert_eq!(a, b);
1392 }
1393
1394 fn ctrl_key(c: char) -> KeyEvent {
1397 KeyEvent {
1398 code: KeyCode::Char(c),
1399 mods: KeyMods {
1400 ctrl: true,
1401 ..KeyMods::default()
1402 },
1403 }
1404 }
1405
1406 fn plain_key(c: char) -> KeyEvent {
1407 KeyEvent {
1408 code: KeyCode::Char(c),
1409 mods: KeyMods::default(),
1410 }
1411 }
1412
1413 #[test]
1414 fn chord_matcher_single_step() {
1415 let mut matcher =
1416 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+q").unwrap(), "quit")]);
1417
1418 assert_eq!(matcher.feed(&ctrl_key('q')), ChordResult::Matched(&"quit"));
1419 assert_eq!(matcher.feed(&plain_key('x')), ChordResult::None);
1420 }
1421
1422 #[test]
1423 fn chord_matcher_two_step_chord() {
1424 let mut matcher = ChordMatcher::new(vec![
1425 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
1426 (KeyBinding::from_str("ctrl+x l").unwrap(), "list"),
1427 ]);
1428
1429 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1430 assert_eq!(
1431 matcher.feed(&plain_key('b')),
1432 ChordResult::Matched(&"sidebar")
1433 );
1434
1435 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1437 assert_eq!(matcher.feed(&plain_key('l')), ChordResult::Matched(&"list"));
1438 }
1439
1440 #[test]
1441 fn chord_matcher_resets_on_wrong_second_key() {
1442 let mut matcher =
1443 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar")]);
1444
1445 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1446 assert_eq!(matcher.feed(&plain_key('z')), ChordResult::None);
1448 assert!(!matcher.is_pending());
1449 }
1450
1451 #[test]
1452 fn chord_matcher_prefix_defers_single_match() {
1453 let mut matcher = ChordMatcher::new(vec![
1454 (KeyBinding::from_str("ctrl+x").unwrap(), "cut"),
1455 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
1456 ]);
1457
1458 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1460 assert_eq!(
1462 matcher.feed(&plain_key('b')),
1463 ChordResult::Matched(&"sidebar")
1464 );
1465 }
1466
1467 #[test]
1468 fn chord_matcher_wrong_continuation_tries_fresh() {
1469 let mut matcher = ChordMatcher::new(vec![
1470 (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
1471 (KeyBinding::from_str("ctrl+q").unwrap(), "quit"),
1472 ]);
1473
1474 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1475 assert_eq!(matcher.feed(&ctrl_key('q')), ChordResult::Matched(&"quit"));
1477 }
1478
1479 #[test]
1480 fn chord_matcher_manual_reset() {
1481 let mut matcher =
1482 ChordMatcher::new(vec![(KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar")]);
1483
1484 assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1485 assert!(matcher.is_pending());
1486 matcher.reset();
1487 assert!(!matcher.is_pending());
1488 }
1489}