Skip to main content

wt/
keys.rs

1//! TUI key bindings (spec §10/§11): the action set, the default keymap, and
2//! parsing/rendering of key strings such as `ctrl+u` or `f5`.
3//!
4//! A [`KeyChord`] is a normalized key + modifier combination used as the map
5//! key. Normalization makes matching terminal-independent: `Shift+Tab` (reported
6//! by terminals as `BackTab`) becomes `Tab`+`SHIFT`, and the `SHIFT` modifier is
7//! dropped from character keys (the shift is already encoded in the character).
8
9use std::collections::HashMap;
10
11use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
12
13/// A TUI action that can be bound to a key (spec §10/§11). The 24 variants match
14/// the action names accepted by `ui.keybindings`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum KeyAction {
17    /// Move the selection up.
18    NavigateUp,
19    /// Move the selection down.
20    NavigateDown,
21    /// Scroll up one page.
22    PageUp,
23    /// Scroll down one page.
24    PageDown,
25    /// Jump to the first row.
26    GoToTop,
27    /// Jump to the last row.
28    GoToBottom,
29    /// Focus the next pane.
30    FocusNextPane,
31    /// Focus the previous pane.
32    FocusPrevPane,
33    /// Switch to the selected worktree (print path, exit).
34    Switch,
35    /// Enter filter mode.
36    Filter,
37    /// Clear the filter / dismiss an overlay.
38    ClearFilter,
39    /// Open the create-worktree prompt.
40    New,
41    /// Open the confirm-remove dialog.
42    Remove,
43    /// Open the PR picker.
44    PrCheckout,
45    /// Check out a branch in the selected worktree (syncs with origin).
46    Checkout,
47    /// Sync (pull then push) the selected worktree's branch.
48    Sync,
49    /// Open the selected worktree in the editor.
50    OpenEditor,
51    /// Force a full async refresh.
52    Refresh,
53    /// Cycle the sort field.
54    SortCycle,
55    /// Toggle the sort direction.
56    SortReverse,
57    /// Show the help overlay.
58    Help,
59    /// Quit without switching.
60    Quit,
61    /// Toggle the list pane (full-screen detail).
62    ToggleSidebar,
63    /// Grow the list pane width.
64    ResizeSidebarGrow,
65    /// Shrink the list pane width.
66    ResizeSidebarShrink,
67}
68
69impl KeyAction {
70    /// All actions, in the order documented in §11.
71    pub const ALL: [KeyAction; 25] = [
72        KeyAction::NavigateUp,
73        KeyAction::NavigateDown,
74        KeyAction::PageUp,
75        KeyAction::PageDown,
76        KeyAction::GoToTop,
77        KeyAction::GoToBottom,
78        KeyAction::FocusNextPane,
79        KeyAction::FocusPrevPane,
80        KeyAction::Switch,
81        KeyAction::Filter,
82        KeyAction::ClearFilter,
83        KeyAction::New,
84        KeyAction::Remove,
85        KeyAction::PrCheckout,
86        KeyAction::Checkout,
87        KeyAction::Sync,
88        KeyAction::OpenEditor,
89        KeyAction::Refresh,
90        KeyAction::SortCycle,
91        KeyAction::SortReverse,
92        KeyAction::Help,
93        KeyAction::Quit,
94        KeyAction::ToggleSidebar,
95        KeyAction::ResizeSidebarGrow,
96        KeyAction::ResizeSidebarShrink,
97    ];
98
99    /// The action name used in `ui.keybindings` configuration.
100    pub fn name(self) -> &'static str {
101        match self {
102            KeyAction::NavigateUp => "navigate-up",
103            KeyAction::NavigateDown => "navigate-down",
104            KeyAction::PageUp => "page-up",
105            KeyAction::PageDown => "page-down",
106            KeyAction::GoToTop => "go-to-top",
107            KeyAction::GoToBottom => "go-to-bottom",
108            KeyAction::FocusNextPane => "focus-next-pane",
109            KeyAction::FocusPrevPane => "focus-prev-pane",
110            KeyAction::Switch => "switch",
111            KeyAction::Filter => "filter",
112            KeyAction::ClearFilter => "clear-filter",
113            KeyAction::New => "new",
114            KeyAction::Remove => "remove",
115            KeyAction::PrCheckout => "pr-checkout",
116            KeyAction::Checkout => "checkout",
117            KeyAction::Sync => "sync",
118            KeyAction::OpenEditor => "open-editor",
119            KeyAction::Refresh => "refresh",
120            KeyAction::SortCycle => "sort-cycle",
121            KeyAction::SortReverse => "sort-reverse",
122            KeyAction::Help => "help",
123            KeyAction::Quit => "quit",
124            KeyAction::ToggleSidebar => "toggle-sidebar",
125            KeyAction::ResizeSidebarGrow => "resize-sidebar-grow",
126            KeyAction::ResizeSidebarShrink => "resize-sidebar-shrink",
127        }
128    }
129
130    /// Parses an action name, or `None` if unknown.
131    pub fn parse(name: &str) -> Option<KeyAction> {
132        KeyAction::ALL.into_iter().find(|a| a.name() == name)
133    }
134
135    /// A short human label for the status bar and help overlay (e.g. `switch`,
136    /// `new`, `checkout`). The match is exhaustive, so a new [`KeyAction`]
137    /// variant cannot be added without giving it a label — this is what keeps
138    /// the on-screen hints and help from drifting away from the key bindings
139    /// (issue #39).
140    pub fn label(self) -> &'static str {
141        match self {
142            KeyAction::NavigateUp => "navigate up",
143            KeyAction::NavigateDown => "navigate down",
144            KeyAction::PageUp => "page up",
145            KeyAction::PageDown => "page down",
146            KeyAction::GoToTop => "go to top",
147            KeyAction::GoToBottom => "go to bottom",
148            KeyAction::FocusNextPane => "next pane",
149            KeyAction::FocusPrevPane => "prev pane",
150            KeyAction::Switch => "switch",
151            KeyAction::Filter => "filter",
152            KeyAction::ClearFilter => "clear / back",
153            KeyAction::New => "new",
154            KeyAction::Remove => "remove",
155            KeyAction::PrCheckout => "pr picker",
156            KeyAction::Checkout => "checkout",
157            KeyAction::Sync => "sync",
158            KeyAction::OpenEditor => "open in editor",
159            KeyAction::Refresh => "refresh",
160            KeyAction::SortCycle => "sort cycle",
161            KeyAction::SortReverse => "sort reverse",
162            KeyAction::Help => "help",
163            KeyAction::Quit => "quit",
164            KeyAction::ToggleSidebar => "toggle sidebar",
165            KeyAction::ResizeSidebarGrow => "grow sidebar",
166            KeyAction::ResizeSidebarShrink => "shrink sidebar",
167        }
168    }
169}
170
171/// A normalized key + modifier combination.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub struct KeyChord {
174    /// The key code.
175    pub code: KeyCode,
176    /// The active modifiers (after normalization).
177    pub mods: KeyModifiers,
178}
179
180impl KeyChord {
181    /// A chord with no modifiers.
182    pub fn key(code: KeyCode) -> KeyChord {
183        KeyChord {
184            code,
185            mods: KeyModifiers::empty(),
186        }
187    }
188
189    /// A `Ctrl`+character chord.
190    pub fn ctrl(c: char) -> KeyChord {
191        KeyChord {
192            code: KeyCode::Char(c),
193            mods: KeyModifiers::CONTROL,
194        }
195    }
196
197    /// Builds a normalized chord from a key code and modifiers.
198    pub fn normalized(code: KeyCode, mods: KeyModifiers) -> KeyChord {
199        let mut code = code;
200        let mut mods = mods;
201        // `Shift+Tab` arrives as `BackTab`; normalize to `Tab`+`SHIFT`.
202        if code == KeyCode::BackTab {
203            code = KeyCode::Tab;
204            mods |= KeyModifiers::SHIFT;
205        }
206        // For character keys, shift is already encoded in the character.
207        if matches!(code, KeyCode::Char(_)) {
208            mods.remove(KeyModifiers::SHIFT);
209        }
210        // Keep only the modifiers we bind on.
211        mods &= KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
212        KeyChord { code, mods }
213    }
214
215    /// Normalizes a terminal key event into a chord for lookup.
216    pub fn from_event(ev: KeyEvent) -> KeyChord {
217        KeyChord::normalized(ev.code, ev.modifiers)
218    }
219
220    /// Parses a key string such as `ctrl+u`, `alt+enter`, `shift+tab`, `f5`, or
221    /// `q`. Returns `None` for malformed strings.
222    pub fn parse(s: &str) -> Option<KeyChord> {
223        let s = s.trim();
224        if s.is_empty() {
225            return None;
226        }
227        // The literal `+`/`-` keys double as separators; special-case them.
228        if s == "+" {
229            return Some(KeyChord::key(KeyCode::Char('+')));
230        }
231        if s == "-" {
232            return Some(KeyChord::key(KeyCode::Char('-')));
233        }
234        let parts: Vec<&str> = s.split('+').collect();
235        let (key_tok, mod_toks) = parts.split_last()?;
236        if key_tok.is_empty() {
237            return None;
238        }
239        let mut mods = KeyModifiers::empty();
240        for m in mod_toks {
241            mods |= parse_modifier(m)?;
242        }
243        let code = parse_keycode(key_tok)?;
244        Some(KeyChord::normalized(code, mods))
245    }
246
247    /// Renders this chord back to a key string (inverse of [`KeyChord::parse`]).
248    pub fn render(&self) -> String {
249        let mut s = String::new();
250        if self.mods.contains(KeyModifiers::CONTROL) {
251            s.push_str("ctrl+");
252        }
253        if self.mods.contains(KeyModifiers::ALT) {
254            s.push_str("alt+");
255        }
256        if self.mods.contains(KeyModifiers::SHIFT) {
257            s.push_str("shift+");
258        }
259        s.push_str(&keycode_name(self.code));
260        s
261    }
262
263    /// Renders this chord for on-screen hints and the help overlay:
264    /// terminal-pretty (`↑`, `Enter`, `Shift+Tab`, `Ctrl-S`, plain letters).
265    /// Distinct from [`KeyChord::render`], which produces the lowercase
266    /// `ctrl+u` *config* format that round-trips through [`KeyChord::parse`].
267    pub fn display(&self) -> String {
268        let ctrl = self.mods.contains(KeyModifiers::CONTROL);
269        let mut s = String::new();
270        if ctrl {
271            s.push_str("Ctrl-");
272        }
273        if self.mods.contains(KeyModifiers::ALT) {
274            s.push_str("Alt-");
275        }
276        if self.mods.contains(KeyModifiers::SHIFT) {
277            s.push_str("Shift+");
278        }
279        // Control chords read better uppercased (`Ctrl-U`), matching convention.
280        match self.code {
281            KeyCode::Char(c) if ctrl => s.push(c.to_ascii_uppercase()),
282            code => s.push_str(&keycode_display(code)),
283        }
284        s
285    }
286}
287
288/// Parses a modifier token (case-insensitive).
289fn parse_modifier(token: &str) -> Option<KeyModifiers> {
290    Some(match token.to_ascii_lowercase().as_str() {
291        "ctrl" | "control" => KeyModifiers::CONTROL,
292        "alt" | "option" => KeyModifiers::ALT,
293        "shift" => KeyModifiers::SHIFT,
294        _ => return None,
295    })
296}
297
298/// Parses a key token (case-insensitive for names, case-preserving for chars).
299fn parse_keycode(token: &str) -> Option<KeyCode> {
300    let lower = token.to_ascii_lowercase();
301    Some(match lower.as_str() {
302        "up" => KeyCode::Up,
303        "down" => KeyCode::Down,
304        "left" => KeyCode::Left,
305        "right" => KeyCode::Right,
306        "home" => KeyCode::Home,
307        "end" => KeyCode::End,
308        "pageup" | "pgup" => KeyCode::PageUp,
309        "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
310        "enter" | "return" => KeyCode::Enter,
311        "esc" | "escape" => KeyCode::Esc,
312        "tab" => KeyCode::Tab,
313        "backtab" => KeyCode::BackTab,
314        "space" => KeyCode::Char(' '),
315        "backspace" => KeyCode::Backspace,
316        "delete" | "del" => KeyCode::Delete,
317        "insert" | "ins" => KeyCode::Insert,
318        _ => {
319            if let Some(n) = lower.strip_prefix('f').and_then(|d| d.parse::<u8>().ok())
320                && (1..=12).contains(&n)
321            {
322                return Some(KeyCode::F(n));
323            }
324            let mut chars = token.chars();
325            let c = chars.next()?;
326            if chars.next().is_some() {
327                return None;
328            }
329            KeyCode::Char(c)
330        }
331    })
332}
333
334/// Renders a key code back to its token (inverse of [`parse_keycode`]).
335fn keycode_name(code: KeyCode) -> String {
336    match code {
337        KeyCode::Up => "up".into(),
338        KeyCode::Down => "down".into(),
339        KeyCode::Left => "left".into(),
340        KeyCode::Right => "right".into(),
341        KeyCode::Home => "home".into(),
342        KeyCode::End => "end".into(),
343        KeyCode::PageUp => "pageup".into(),
344        KeyCode::PageDown => "pagedown".into(),
345        KeyCode::Enter => "enter".into(),
346        KeyCode::Esc => "esc".into(),
347        KeyCode::Tab => "tab".into(),
348        KeyCode::BackTab => "backtab".into(),
349        KeyCode::Backspace => "backspace".into(),
350        KeyCode::Delete => "delete".into(),
351        KeyCode::Insert => "insert".into(),
352        KeyCode::Char(' ') => "space".into(),
353        KeyCode::Char(c) => c.to_string(),
354        KeyCode::F(n) => format!("f{n}"),
355        other => format!("{other:?}").to_ascii_lowercase(),
356    }
357}
358
359/// Renders a key code for on-screen display (pretty glyphs/short names), the
360/// counterpart to [`keycode_name`]'s config tokens (see [`KeyChord::display`]).
361fn keycode_display(code: KeyCode) -> String {
362    match code {
363        KeyCode::Up => "↑".into(),
364        KeyCode::Down => "↓".into(),
365        KeyCode::Left => "←".into(),
366        KeyCode::Right => "→".into(),
367        KeyCode::Home => "Home".into(),
368        KeyCode::End => "End".into(),
369        KeyCode::PageUp => "PgUp".into(),
370        KeyCode::PageDown => "PgDn".into(),
371        KeyCode::Enter => "Enter".into(),
372        KeyCode::Esc => "Esc".into(),
373        KeyCode::Tab => "Tab".into(),
374        KeyCode::BackTab => "Shift+Tab".into(),
375        KeyCode::Backspace => "Backspace".into(),
376        KeyCode::Delete => "Del".into(),
377        KeyCode::Insert => "Ins".into(),
378        KeyCode::Char(' ') => "Space".into(),
379        KeyCode::Char(c) => c.to_string(),
380        KeyCode::F(n) => format!("F{n}"),
381        other => format!("{other:?}"),
382    }
383}
384
385/// A mapping from key chords to actions (spec §10 defaults, overridable per
386/// action by `ui.keybindings`).
387#[derive(Debug, Clone)]
388pub struct Keymap {
389    bindings: HashMap<KeyChord, KeyAction>,
390}
391
392impl Keymap {
393    /// The default key bindings (spec §10 table).
394    pub fn defaults() -> Keymap {
395        let pairs: Vec<(KeyAction, KeyChord)> = vec![
396            (KeyAction::NavigateUp, KeyChord::key(KeyCode::Up)),
397            (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('k'))),
398            (KeyAction::NavigateDown, KeyChord::key(KeyCode::Down)),
399            (KeyAction::NavigateDown, KeyChord::key(KeyCode::Char('j'))),
400            (KeyAction::PageUp, KeyChord::key(KeyCode::PageUp)),
401            (KeyAction::PageUp, KeyChord::ctrl('u')),
402            (KeyAction::PageDown, KeyChord::key(KeyCode::PageDown)),
403            (KeyAction::PageDown, KeyChord::ctrl('d')),
404            (KeyAction::GoToTop, KeyChord::key(KeyCode::Char('g'))),
405            (KeyAction::GoToTop, KeyChord::key(KeyCode::Home)),
406            (KeyAction::GoToBottom, KeyChord::key(KeyCode::Char('G'))),
407            (KeyAction::GoToBottom, KeyChord::key(KeyCode::End)),
408            (KeyAction::FocusNextPane, KeyChord::key(KeyCode::Tab)),
409            (
410                KeyAction::FocusPrevPane,
411                KeyChord::normalized(KeyCode::Tab, KeyModifiers::SHIFT),
412            ),
413            (KeyAction::Switch, KeyChord::key(KeyCode::Enter)),
414            (KeyAction::Filter, KeyChord::key(KeyCode::Char('/'))),
415            (KeyAction::ClearFilter, KeyChord::key(KeyCode::Esc)),
416            (KeyAction::New, KeyChord::key(KeyCode::Char('n'))),
417            (KeyAction::Remove, KeyChord::key(KeyCode::Char('d'))),
418            (KeyAction::PrCheckout, KeyChord::key(KeyCode::Char('p'))),
419            (KeyAction::Checkout, KeyChord::key(KeyCode::Char('c'))),
420            (KeyAction::Sync, KeyChord::key(KeyCode::Char('y'))),
421            (KeyAction::OpenEditor, KeyChord::key(KeyCode::Char('o'))),
422            (KeyAction::Refresh, KeyChord::key(KeyCode::Char('r'))),
423            (KeyAction::SortCycle, KeyChord::key(KeyCode::Char('s'))),
424            (KeyAction::SortReverse, KeyChord::key(KeyCode::Char('S'))),
425            (KeyAction::Help, KeyChord::key(KeyCode::Char('?'))),
426            (KeyAction::Quit, KeyChord::key(KeyCode::Char('q'))),
427            (KeyAction::ToggleSidebar, KeyChord::key(KeyCode::Char('\\'))),
428            (
429                KeyAction::ResizeSidebarGrow,
430                KeyChord::key(KeyCode::Char('+')),
431            ),
432            (
433                KeyAction::ResizeSidebarShrink,
434                KeyChord::key(KeyCode::Char('-')),
435            ),
436        ];
437        let mut bindings = HashMap::with_capacity(pairs.len());
438        for (action, chord) in pairs {
439            bindings.insert(chord, action);
440        }
441        Keymap { bindings }
442    }
443
444    /// Returns the action bound to `chord`, if any.
445    pub fn action_for(&self, chord: KeyChord) -> Option<KeyAction> {
446        self.bindings.get(&chord).copied()
447    }
448
449    /// Rebinds `action` to a single `chord`, replacing all of that action's
450    /// existing bindings (the `ui.keybindings` override semantics, §11).
451    pub fn rebind(&mut self, action: KeyAction, chord: KeyChord) {
452        self.bindings.retain(|_, a| *a != action);
453        self.bindings.insert(chord, action);
454    }
455
456    /// Returns the chords currently bound to `action` (for help/hints display).
457    pub fn chords_for(&self, action: KeyAction) -> Vec<KeyChord> {
458        self.bindings
459            .iter()
460            .filter(|(_, a)| **a == action)
461            .map(|(c, _)| *c)
462            .collect()
463    }
464
465    /// A stable, human-readable display of every chord bound to `action`, joined
466    /// with `/` (e.g. `↑/k`), or `None` if the action is currently unbound. The
467    /// chords are sorted because [`Keymap::chords_for`] draws from a `HashMap`
468    /// whose iteration order is unspecified; without this the displayed hint
469    /// would flicker between runs.
470    pub fn display_for(&self, action: KeyAction) -> Option<String> {
471        let mut chords = self.chords_for(action);
472        if chords.is_empty() {
473            return None;
474        }
475        chords.sort_by_key(chord_sort_key);
476        Some(
477            chords
478                .iter()
479                .map(KeyChord::display)
480                .collect::<Vec<_>>()
481                .join("/"),
482        )
483    }
484}
485
486/// A stable ordering key for displaying an action's chords: named/special keys
487/// (arrows, `Enter`, …) sort before character keys, with the config `render()`
488/// string as a deterministic tiebreaker.
489fn chord_sort_key(chord: &KeyChord) -> (u8, String) {
490    let bucket = u8::from(matches!(chord.code, KeyCode::Char(_)));
491    (bucket, chord.render())
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn action_names_round_trip_and_are_unique() {
500        assert_eq!(KeyAction::ALL.len(), 25);
501        let mut names = std::collections::HashSet::new();
502        for action in KeyAction::ALL {
503            assert_eq!(KeyAction::parse(action.name()), Some(action));
504            assert!(
505                names.insert(action.name()),
506                "duplicate name {}",
507                action.name()
508            );
509        }
510        assert_eq!(KeyAction::parse("not-an-action"), None);
511    }
512
513    #[test]
514    fn parse_modifiers_and_keys() {
515        assert_eq!(KeyChord::parse("ctrl+u"), Some(KeyChord::ctrl('u')));
516        assert_eq!(
517            KeyChord::parse("alt+enter"),
518            Some(KeyChord::normalized(KeyCode::Enter, KeyModifiers::ALT))
519        );
520        assert_eq!(KeyChord::parse("f5"), Some(KeyChord::key(KeyCode::F(5))));
521        assert_eq!(
522            KeyChord::parse("g"),
523            Some(KeyChord::key(KeyCode::Char('g')))
524        );
525        assert_eq!(
526            KeyChord::parse("G"),
527            Some(KeyChord::key(KeyCode::Char('G')))
528        );
529        assert_eq!(
530            KeyChord::parse("?"),
531            Some(KeyChord::key(KeyCode::Char('?')))
532        );
533        assert_eq!(
534            KeyChord::parse("+"),
535            Some(KeyChord::key(KeyCode::Char('+')))
536        );
537        assert_eq!(
538            KeyChord::parse("-"),
539            Some(KeyChord::key(KeyCode::Char('-')))
540        );
541        assert_eq!(
542            KeyChord::parse("space"),
543            Some(KeyChord::key(KeyCode::Char(' ')))
544        );
545        assert_eq!(
546            KeyChord::parse("PgUp"),
547            Some(KeyChord::key(KeyCode::PageUp))
548        );
549    }
550
551    #[test]
552    fn parse_normalizes_shift_tab() {
553        let want = KeyChord::normalized(KeyCode::Tab, KeyModifiers::SHIFT);
554        assert_eq!(KeyChord::parse("shift+tab"), Some(want));
555        assert_eq!(KeyChord::parse("backtab"), Some(want));
556        assert_eq!(want.code, KeyCode::Tab);
557        assert!(want.mods.contains(KeyModifiers::SHIFT));
558    }
559
560    #[test]
561    fn parse_rejects_malformed() {
562        assert_eq!(KeyChord::parse(""), None);
563        assert_eq!(KeyChord::parse("ctrl+"), None);
564        assert_eq!(KeyChord::parse("nope+x"), None);
565        assert_eq!(KeyChord::parse("f99"), None);
566        assert_eq!(KeyChord::parse("abc"), None);
567    }
568
569    #[test]
570    fn from_event_normalizes() {
571        let backtab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty());
572        assert_eq!(
573            KeyChord::from_event(backtab),
574            KeyChord::normalized(KeyCode::Tab, KeyModifiers::SHIFT)
575        );
576        let shifted_g = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
577        assert_eq!(
578            KeyChord::from_event(shifted_g),
579            KeyChord::key(KeyCode::Char('G'))
580        );
581        let ctrl_u = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
582        assert_eq!(KeyChord::from_event(ctrl_u), KeyChord::ctrl('u'));
583    }
584
585    #[test]
586    fn render_round_trips() {
587        for s in [
588            "ctrl+u",
589            "alt+enter",
590            "shift+tab",
591            "f5",
592            "g",
593            "G",
594            "esc",
595            "space",
596        ] {
597            let chord = KeyChord::parse(s).unwrap();
598            assert_eq!(
599                KeyChord::parse(&chord.render()),
600                Some(chord),
601                "round-trip {s}"
602            );
603        }
604    }
605
606    #[test]
607    fn all_named_keycodes_round_trip() {
608        for code in [
609            KeyCode::Up,
610            KeyCode::Down,
611            KeyCode::Left,
612            KeyCode::Right,
613            KeyCode::Home,
614            KeyCode::End,
615            KeyCode::PageUp,
616            KeyCode::PageDown,
617            KeyCode::Enter,
618            KeyCode::Esc,
619            KeyCode::Tab,
620            KeyCode::Backspace,
621            KeyCode::Delete,
622            KeyCode::Insert,
623            KeyCode::Char(' '),
624            KeyCode::Char('x'),
625            KeyCode::F(12),
626        ] {
627            let chord = KeyChord::key(code);
628            let rendered = chord.render();
629            assert_eq!(
630                KeyChord::parse(&rendered),
631                Some(chord),
632                "round-trip {rendered}"
633            );
634        }
635    }
636
637    #[test]
638    fn defaults_cover_the_spec_table() {
639        let m = Keymap::defaults();
640        assert_eq!(
641            m.action_for(KeyChord::key(KeyCode::Up)),
642            Some(KeyAction::NavigateUp)
643        );
644        assert_eq!(
645            m.action_for(KeyChord::key(KeyCode::Char('k'))),
646            Some(KeyAction::NavigateUp)
647        );
648        assert_eq!(m.action_for(KeyChord::ctrl('u')), Some(KeyAction::PageUp));
649        assert_eq!(
650            m.action_for(KeyChord::key(KeyCode::Enter)),
651            Some(KeyAction::Switch)
652        );
653        assert_eq!(
654            m.action_for(KeyChord::normalized(KeyCode::Tab, KeyModifiers::SHIFT)),
655            Some(KeyAction::FocusPrevPane)
656        );
657        assert_eq!(
658            m.action_for(KeyChord::key(KeyCode::Char('?'))),
659            Some(KeyAction::Help)
660        );
661        assert_eq!(
662            m.action_for(KeyChord::key(KeyCode::Char('S'))),
663            Some(KeyAction::SortReverse)
664        );
665        assert_eq!(
666            m.action_for(KeyChord::key(KeyCode::Char('c'))),
667            Some(KeyAction::Checkout)
668        );
669        assert_eq!(
670            m.action_for(KeyChord::key(KeyCode::Char('y'))),
671            Some(KeyAction::Sync)
672        );
673        assert_eq!(m.action_for(KeyChord::key(KeyCode::Char('z'))), None);
674    }
675
676    #[test]
677    fn every_action_has_a_label() {
678        for action in KeyAction::ALL {
679            assert!(!action.label().is_empty(), "missing label for {action:?}");
680        }
681    }
682
683    #[test]
684    fn chord_display_is_terminal_pretty() {
685        assert_eq!(KeyChord::key(KeyCode::Up).display(), "↑");
686        assert_eq!(KeyChord::key(KeyCode::Down).display(), "↓");
687        assert_eq!(KeyChord::key(KeyCode::Enter).display(), "Enter");
688        assert_eq!(KeyChord::key(KeyCode::Esc).display(), "Esc");
689        assert_eq!(KeyChord::key(KeyCode::Char('k')).display(), "k");
690        assert_eq!(KeyChord::key(KeyCode::Char('?')).display(), "?");
691        assert_eq!(KeyChord::ctrl('s').display(), "Ctrl-S");
692        assert_eq!(
693            KeyChord::normalized(KeyCode::Tab, KeyModifiers::SHIFT).display(),
694            "Shift+Tab"
695        );
696    }
697
698    #[test]
699    fn display_for_is_sorted_and_deterministic() {
700        let m = Keymap::defaults();
701        // Multi-chord action: named key (↑) sorts before the char key (k).
702        assert_eq!(m.display_for(KeyAction::NavigateUp).as_deref(), Some("↑/k"));
703        assert_eq!(m.display_for(KeyAction::Switch).as_deref(), Some("Enter"));
704        assert_eq!(m.display_for(KeyAction::SortCycle).as_deref(), Some("s"));
705        assert_eq!(m.display_for(KeyAction::Checkout).as_deref(), Some("c"));
706        // Stable across repeated calls despite the HashMap backing.
707        assert_eq!(
708            m.display_for(KeyAction::NavigateUp),
709            m.display_for(KeyAction::NavigateUp)
710        );
711    }
712
713    #[test]
714    fn display_for_follows_rebind_and_is_none_when_unbound() {
715        let mut m = Keymap::defaults();
716        m.rebind(KeyAction::Checkout, KeyChord::key(KeyCode::Char('x')));
717        assert_eq!(m.display_for(KeyAction::Checkout).as_deref(), Some("x"));
718        // Rebind another action onto Checkout's last chord, leaving it unbound.
719        m.rebind(KeyAction::Quit, KeyChord::key(KeyCode::Char('x')));
720        assert_eq!(m.display_for(KeyAction::Checkout), None);
721    }
722
723    #[test]
724    fn rebind_replaces_all_chords_for_action() {
725        let mut m = Keymap::defaults();
726        m.rebind(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')));
727        assert_eq!(
728            m.action_for(KeyChord::key(KeyCode::Char('w'))),
729            Some(KeyAction::NavigateUp)
730        );
731        // The old bindings for the action are gone.
732        assert_eq!(m.action_for(KeyChord::key(KeyCode::Char('k'))), None);
733        assert_eq!(m.action_for(KeyChord::key(KeyCode::Up)), None);
734        assert_eq!(m.chords_for(KeyAction::NavigateUp).len(), 1);
735    }
736}