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
51impl KeyBinding {
52    /// Returns true when this binding matches the given sequence of key events.
53    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    /// Returns true if this is a chord (multi-step) binding.
68    pub fn is_chord(&self) -> bool {
69        self.steps.len() > 1
70    }
71
72    /// Returns the number of key steps in this binding.
73    pub fn step_count(&self) -> usize {
74        self.steps.len()
75    }
76
77    /// Returns the canonical display string for this binding.
78    pub fn canonical(&self) -> &str {
79        &self.canonical
80    }
81
82    /// Returns the canonical display string in lowercase.
83    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    /// Returns true if no bindings are configured.
167    pub fn is_empty(&self) -> bool {
168        self.bindings.is_empty()
169    }
170
171    /// Returns the number of bindings.
172    pub fn len(&self) -> usize {
173        self.bindings.len()
174    }
175
176    /// Returns the first binding, if present.
177    pub fn primary(&self) -> Option<&KeyBinding> {
178        self.bindings.first()
179    }
180
181    /// Iterates over all bindings.
182    pub fn iter(&self) -> impl Iterator<Item = &KeyBinding> {
183        self.bindings.iter()
184    }
185
186    /// Returns canonical display text in lowercase.
187    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
238/// Parse and canonicalize one binding string.
239pub fn format_binding(raw: &str) -> Result<String, KeyBindingParseError> {
240    Ok(KeyBinding::from_str(raw)?.to_string())
241}
242
243/// Parse and canonicalize one binding string, then lowercase it.
244pub fn format_binding_lowercase(raw: &str) -> Result<String, KeyBindingParseError> {
245    Ok(KeyBinding::from_str(raw)?.canonical_lowercase())
246}
247
248/// Parse and canonicalize comma-separated binding alternatives.
249pub fn format_bindings(raw: &str) -> Result<String, KeyBindingParseError> {
250    Ok(KeyBindings::from_str(raw)?.to_string())
251}
252
253/// Parse and canonicalize comma-separated binding alternatives, then lowercase them.
254pub 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/// Result of feeding a key event into a [`ChordMatcher`].
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub enum ChordResult<T> {
555    /// No binding matched and no chord is pending.
556    None,
557    /// A binding was fully matched. Contains the associated value.
558    Matched(T),
559    /// The key is a valid prefix of one or more chord bindings. Waiting for more keys.
560    Pending,
561}
562
563/// Stateful matcher for key chord sequences.
564///
565/// Tracks partial matches across multiple key events, allowing multi-key
566/// chord bindings like "ctrl+x b" to be matched incrementally.
567///
568/// # Example
569///
570/// ```
571/// use tui_lipan::prelude::{KeyBinding, KeyCode, KeyEvent, KeyMods};
572/// use tui_lipan::{ChordMatcher, ChordResult};
573/// use std::str::FromStr;
574///
575/// let mut matcher = ChordMatcher::new(vec![
576///     (KeyBinding::from_str("ctrl+x b").unwrap(), "sidebar"),
577///     (KeyBinding::from_str("ctrl+x l").unwrap(), "list"),
578///     (KeyBinding::from_str("ctrl+q").unwrap(), "quit"),
579/// ]);
580///
581/// let ctrl_x = KeyEvent {
582///     code: KeyCode::Char('x'),
583///     mods: KeyMods { ctrl: true, ..KeyMods::default() },
584/// };
585/// let b = KeyEvent {
586///     code: KeyCode::Char('b'),
587///     mods: KeyMods::default(),
588/// };
589///
590/// assert_eq!(matcher.feed(&ctrl_x), ChordResult::Pending);
591/// assert_eq!(matcher.feed(&b), ChordResult::Matched(&"sidebar"));
592/// ```
593pub struct ChordMatcher<T> {
594    entries: Vec<(KeyBinding, T)>,
595    /// Indices of entries with partial matches and how many steps have been matched.
596    pending: Vec<(usize, usize)>,
597}
598
599impl<T> ChordMatcher<T> {
600    /// Creates a new chord matcher from a list of (binding, value) pairs.
601    pub fn new(entries: Vec<(KeyBinding, T)>) -> Self {
602        Self {
603            entries,
604            pending: Vec::new(),
605        }
606    }
607
608    /// Feeds a key event into the matcher, advancing chord state.
609    ///
610    /// Returns [`ChordResult::Matched`] when a binding is fully matched,
611    /// [`ChordResult::Pending`] when waiting for more keys, or
612    /// [`ChordResult::None`] when nothing matches.
613    pub fn feed(&mut self, key: &KeyEvent) -> ChordResult<&T> {
614        if self.pending.is_empty() {
615            return self.try_fresh(key);
616        }
617
618        // Try to continue pending chords.
619        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        // Nothing continued - reset and try this key as a fresh start.
637        self.pending.clear();
638        self.try_fresh(key)
639    }
640
641    /// Returns true if the matcher is waiting for more keys to complete a chord.
642    pub fn is_pending(&self) -> bool {
643        !self.pending.is_empty()
644    }
645
646    /// Resets the matcher, discarding any partial chord state.
647    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 there are pending chords, defer single-step matches.
667        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    // --- Chord support ---
794
795    #[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    // --- ChordMatcher ---
858
859    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        // Second chord
899        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        // Wrong second key - should reset
910        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        // ctrl+x matches "cut" but also starts "ctrl+x b" - should be Pending
922        assert_eq!(matcher.feed(&ctrl_key('x')), ChordResult::Pending);
923        // b completes the chord
924        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        // ctrl+q doesn't continue the chord, but it IS a fresh match
939        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}