Skip to main content

tui_lipan/input/
mod.rs

1//! Public keybinding parsing, matching, and formatting utilities.
2
3use 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/// One parsed keyboard shortcut, possibly a multi-key chord (e.g. "ctrl+x b").
17#[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/// A set of alternative keybindings.
38#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
39pub struct KeyBindings {
40    bindings: Vec<KeyBinding>,
41}
42
43/// Parse error for keybinding strings.
44#[derive(Clone, Debug, thiserror::Error)]
45pub enum KeyBindingParseError {
46    /// Invalid keybinding expression.
47    #[error("invalid key binding: {0}")]
48    Invalid(String),
49}
50
51/// Error returned by [`KeyBinding::key_events`].
52///
53/// Distinct from [`KeyBindingParseError`]: the binding already parsed, it just
54/// cannot be expressed as one discrete [`KeyEvent`] per chord step.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56pub enum KeyEventExpansionError {
57    /// The step presses several key codes at once (e.g. a `crokey` multi-code combination).
58    #[error("multi-key combinations cannot be expanded into a single key event")]
59    MultiKeyCombination,
60    /// The step uses a key code with no [`KeyCode`] equivalent.
61    #[error("unsupported key code for event expansion: {0}")]
62    UnsupportedKeyCode(String),
63}
64
65impl KeyBinding {
66    /// Returns true when this binding matches the given sequence of key events.
67    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    /// Returns true if this is a chord (multi-step) binding.
82    pub fn is_chord(&self) -> bool {
83        self.steps.len() > 1
84    }
85
86    /// Returns the number of key steps in this binding.
87    pub fn step_count(&self) -> usize {
88        self.steps.len()
89    }
90
91    /// Expand this binding into one [`KeyEvent`] per chord step.
92    ///
93    /// Combinations that press multiple key codes at once are rejected — send-keys and similar
94    /// callers need a single discrete event per step.
95    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    /// Returns the canonical display string for this binding.
112    pub fn canonical(&self) -> &str {
113        &self.canonical
114    }
115
116    /// Returns the canonical display string in lowercase.
117    pub fn canonical_lowercase(&self) -> String {
118        self.canonical.to_ascii_lowercase()
119    }
120
121    /// Returns a compact display string with lowercase ordinary keys and modifiers.
122    ///
123    /// Shift-only ASCII letters and US-layout punctuation are shown as the glyph they produce
124    /// (`shift-m` → `M`, `shift-/` → `?`). Shift remains explicit for special keys and for
125    /// letters combined with another modifier. This changes display text only; matching and
126    /// binding identity remain unchanged.
127    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    /// Create a binding set from individual bindings.
215    pub fn from_bindings(bindings: impl IntoIterator<Item = KeyBinding>) -> Self {
216        Self {
217            bindings: bindings.into_iter().collect(),
218        }
219    }
220
221    /// Returns true if no bindings are configured.
222    pub fn is_empty(&self) -> bool {
223        self.bindings.is_empty()
224    }
225
226    /// Returns the number of bindings.
227    pub fn len(&self) -> usize {
228        self.bindings.len()
229    }
230
231    /// Returns the first binding, if present.
232    pub fn primary(&self) -> Option<&KeyBinding> {
233        self.bindings.first()
234    }
235
236    /// Iterates over all bindings.
237    pub fn iter(&self) -> impl Iterator<Item = &KeyBinding> {
238        self.bindings.iter()
239    }
240
241    /// Returns canonical display text in lowercase.
242    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    /// Returns compact display text for alternatives, stable-deduplicated after normalization.
257    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
305/// Parse and canonicalize one binding string.
306pub fn format_binding(raw: &str) -> Result<String, KeyBindingParseError> {
307    Ok(KeyBinding::from_str(raw)?.to_string())
308}
309
310/// Parse and canonicalize one binding string, then lowercase it.
311pub fn format_binding_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
312    Ok(KeyBinding::from_str(raw)?.canonical_lowercase())
313}
314
315/// Parse and compactly format one binding string.
316pub fn format_binding_compact(raw: &str) -> Result<String, KeyBindingParseError> {
317    Ok(KeyBinding::from_str(raw)?.compact_display())
318}
319
320/// Parse and canonicalize comma-separated binding alternatives.
321pub fn format_bindings(raw: &str) -> Result<String, KeyBindingParseError> {
322    Ok(KeyBindings::from_str(raw)?.to_string())
323}
324
325/// Parse and canonicalize comma-separated binding alternatives, then lowercase them.
326pub fn format_bindings_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
327    Ok(KeyBindings::from_str(raw)?.canonical_lowercase())
328}
329
330/// Parse and compactly format comma-separated binding alternatives.
331pub 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    // Chord steps are whitespace-separated. Within a step, `+` is a modifier
418    // separator (`ctrl+c` → `ctrl-c`), except when the step is the plus key itself
419    // (`+` / `plus` / `alt-plus` / `alt-+`) which must stay `Char('+')` for crokey.
420    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// Compiled on native under `test` as well, so the canonical-string path stays
607// covered by `canonical_expansion_matches_native_expansion` instead of only ever
608// being exercised on wasm.
609#[cfg(any(target_arch = "wasm32", test))]
610fn key_event_from_canonical(canonical: &str) -> Result<KeyEvent, KeyEventExpansionError> {
611    // Canonical form joins mods with '+', so Char('+') renders as "+" / "Alt++".
612    // Detect that before splitting so the key is not eaten as a separator.
613    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        // BackTab already encodes the shift; keeping the modifier too would make
650        // this disagree with the native expansion path.
651        "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    // Lone `-` / `+` split away under `-` tokenization; treat them as keys.
770    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/// Result of feeding a key event into a [`ChordMatcher`].
902#[derive(Debug, Clone, PartialEq, Eq)]
903pub enum ChordResult<T> {
904    /// No binding matched and no chord is pending.
905    None,
906    /// A binding was fully matched. Contains the associated value.
907    Matched(T),
908    /// The key is a valid prefix of one or more chord bindings. Waiting for more keys.
909    Pending,
910}
911
912/// Stateful matcher for key chord sequences.
913///
914/// Tracks partial matches across multiple key events, allowing multi-key
915/// chord bindings like "ctrl+x b" to be matched incrementally.
916///
917/// # Example
918///
919/// ```
920/// use tui_lipan::prelude::{KeyBinding, KeyCode, KeyEvent, KeyMods};
921/// use tui_lipan::{ChordMatcher, ChordResult};
922/// use std::str::FromStr;
923///
924/// let mut matcher = ChordMatcher::new(vec![
925///     (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
926///     (KeyBinding::from_str("ctrl+x l").unwrap(), "list"),
927///     (KeyBinding::from_str("ctrl+q").unwrap(), "quit"),
928/// ]);
929///
930/// let ctrl_x = KeyEvent {
931///     code: KeyCode::Char('x'),
932///     mods: KeyMods { ctrl: true, ..KeyMods::default() },
933/// };
934/// let b = KeyEvent {
935///     code: KeyCode::Char('b'),
936///     mods: KeyMods::default(),
937/// };
938///
939/// assert_eq!(matcher.feed(&ctrl_x), ChordResult::Pending);
940/// assert_eq!(matcher.feed(&b), ChordResult::Matched(&"sidebar"));
941/// ```
942pub struct ChordMatcher<T> {
943    entries: Vec<(KeyBinding, T)>,
944    /// Indices of entries with partial matches and how many steps have been matched.
945    pending: Vec<(usize, usize)>,
946}
947
948impl<T> ChordMatcher<T> {
949    /// Creates a new chord matcher from a list of (binding, value) pairs.
950    pub fn new(entries: Vec<(KeyBinding, T)>) -> Self {
951        Self {
952            entries,
953            pending: Vec::new(),
954        }
955    }
956
957    /// Feeds a key event into the matcher, advancing chord state.
958    ///
959    /// Returns [`ChordResult::Matched`] when a binding is fully matched,
960    /// [`ChordResult::Pending`] when waiting for more keys, or
961    /// [`ChordResult::None`] when nothing matches.
962    pub fn feed(&mut self, key: &KeyEvent) -> ChordResult<&T> {
963        if self.pending.is_empty() {
964            return self.try_fresh(key);
965        }
966
967        // Try to continue pending chords.
968        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        // Nothing continued - reset and try this key as a fresh start.
986        self.pending.clear();
987        self.try_fresh(key)
988    }
989
990    /// Returns true if the matcher is waiting for more keys to complete a chord.
991    pub fn is_pending(&self) -> bool {
992        !self.pending.is_empty()
993    }
994
995    /// Resets the matcher, discarding any partial chord state.
996    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 there are pending chords, defer single-step matches.
1016        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    /// The wasm expansion path parses the canonical string rather than a structured
1195    /// combination, so it is covered here to keep the two implementations in step.
1196    #[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    // --- Chord support ---
1331
1332    #[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    // --- ChordMatcher ---
1395
1396    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        // Second chord
1436        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        // Wrong second key - should reset
1447        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        // ctrl+x matches "cut" but also starts "ctrl+x b" - should be Pending
1459        assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
1460        // b completes the chord
1461        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        // ctrl+q doesn't continue the chord, but it IS a fresh match
1476        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}