Skip to main content

buffr_modal/
keymap.rs

1//! Keymap trie — prefix-indexed dispatcher from chord sequences to
2//! [`PageAction`].
3//!
4//! Each binding is a path from root to a leaf; each node optionally
5//! carries a [`PageAction`] of its own (so `g` can map to one action
6//! while `gg` maps to another). Lookup walks the trie one chord at a
7//! time and returns:
8//!
9//! - [`Lookup::Match(action)`] — exact action; the engine fires it
10//!   and resets the pending buffer.
11//! - [`Lookup::Pending`] — current chord sequence is a valid prefix
12//!   of one or more bindings; engine starts the timeout clock.
13//! - [`Lookup::NoMatch`] — sequence doesn't lead anywhere; engine
14//!   resets and may forward the chords to the page.
15//!
16//! Ambiguity (`g` mapped *and* `gg` mapped) resolves the same way vim
17//! does: caller tracks elapsed time since the first chord and, if
18//! the configured timeout elapses without a longer match, fires the
19//! shorter action. This module doesn't own the clock —
20//! [`crate::engine::Engine`] does.
21//!
22//! # Mode scoping
23//!
24//! A [`Keymap`] holds one trie per [`PageMode`] (Normal, Visual,
25//! Command, Hint). Pending and Edit are not bindable directly:
26//! Pending is a transient internal state of the engine, and Edit-mode
27//! routes through `feed_edit_mode_key` instead of the trie.
28
29use crate::actions::{PageAction, PageMode};
30use crate::key::{Key, KeyChord, Modifiers, NamedKey, ParseError, parse_keys};
31use std::collections::HashMap;
32
33#[derive(Debug, Clone, Default)]
34pub struct Keymap {
35    leader: Option<char>,
36    normal: ModeMap,
37    visual: ModeMap,
38    command: ModeMap,
39    hint: ModeMap,
40}
41
42#[derive(Debug, Clone, Default)]
43pub(crate) struct ModeMap {
44    root: Node,
45}
46
47#[derive(Debug, Clone, Default)]
48struct Node {
49    action: Option<PageAction>,
50    children: HashMap<KeyChord, Node>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Lookup<'a> {
55    /// Exact match. Engine fires the action and resets.
56    Match(&'a PageAction),
57    /// Current sequence is a valid prefix of one or more bindings.
58    /// Engine starts/extends the timeout clock.
59    Pending,
60    /// Dead end — the sequence isn't a binding and isn't a prefix.
61    NoMatch,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65pub enum BindError {
66    #[error("parse error: {0}")]
67    Parse(#[from] ParseError),
68    #[error("binding contains <leader> but no leader configured")]
69    NoLeader,
70}
71
72impl Keymap {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Configured leader char. `None` means `<leader>` references
78    /// fail to bind.
79    pub fn leader(&self) -> Option<char> {
80        self.leader
81    }
82
83    pub fn set_leader(&mut self, leader: char) {
84        self.leader = Some(leader);
85    }
86
87    /// Bind a chord sequence to an action in the given mode. Parses
88    /// `keys` via [`parse_keys`] and resolves `<leader>` to the
89    /// configured leader.
90    pub fn bind(
91        &mut self,
92        mode: PageMode,
93        keys: &str,
94        action: PageAction,
95    ) -> Result<(), BindError> {
96        let chords = self.resolve_keys(keys)?;
97        self.mode_map_mut(mode).bind_chords(&chords, action);
98        Ok(())
99    }
100
101    /// Bind already-parsed chords. Used by the engine when feeding
102    /// programmatic bindings.
103    pub fn bind_chords(&mut self, mode: PageMode, chords: &[KeyChord], action: PageAction) {
104        self.mode_map_mut(mode).bind_chords(chords, action);
105    }
106
107    /// Look up the chord sequence under `mode`.
108    pub fn lookup(&self, mode: PageMode, chords: &[KeyChord]) -> Lookup<'_> {
109        self.mode_map(mode).lookup(chords)
110    }
111
112    /// Resolve the longest-prefix action along `chords` — the engine
113    /// uses this when the ambiguity timeout fires.
114    pub fn resolve_timeout(&self, mode: PageMode, chords: &[KeyChord]) -> Option<&PageAction> {
115        self.mode_map(mode).resolve_timeout(chords)
116    }
117
118    /// Flatten every binding under `mode` to `(chord_sequence, action)`
119    /// pairs. Order is unspecified — callers that want deterministic
120    /// output should sort the result. Used by the new-tab page renderer
121    /// to list the live keymap, including any hot-reloaded user
122    /// overrides.
123    pub fn entries(&self, mode: PageMode) -> Vec<(Vec<KeyChord>, PageAction)> {
124        let mut out = Vec::new();
125        let mut prefix = Vec::new();
126        self.mode_map(mode).root.collect(&mut prefix, &mut out);
127        out
128    }
129
130    fn resolve_keys(&self, keys: &str) -> Result<Vec<KeyChord>, BindError> {
131        let mut chords = parse_keys(keys)?;
132        for c in &mut chords {
133            if c.key == Key::Named(NamedKey::Leader) {
134                let l = self.leader.ok_or(BindError::NoLeader)?;
135                c.key = Key::Char(l);
136                // Leader uppercase implies shift, mirroring bare-char
137                // parsing rules.
138                if l.is_ascii_uppercase() {
139                    c.modifiers |= Modifiers::SHIFT;
140                }
141            }
142        }
143        Ok(chords)
144    }
145
146    fn mode_map(&self, mode: PageMode) -> &ModeMap {
147        match mode {
148            PageMode::Normal | PageMode::Pending | PageMode::Insert => &self.normal,
149            PageMode::Visual => &self.visual,
150            PageMode::Command => &self.command,
151            PageMode::Hint => &self.hint,
152        }
153    }
154
155    fn mode_map_mut(&mut self, mode: PageMode) -> &mut ModeMap {
156        match mode {
157            PageMode::Normal | PageMode::Pending | PageMode::Insert => &mut self.normal,
158            PageMode::Visual => &mut self.visual,
159            PageMode::Command => &mut self.command,
160            PageMode::Hint => &mut self.hint,
161        }
162    }
163
164    /// Phase 6 a11y audit: enumerate every static default binding as
165    /// `(mode_label, keys, action)` rows. Sorted by `(mode, keys)` so
166    /// the output is stable; used by `--audit-keymap` to verify
167    /// keyboard-only reachability of every `PageAction`.
168    ///
169    /// `leader` mirrors [`Self::default_bindings`]; the resolved
170    /// `<leader>` chord is rendered as the literal character so users
171    /// can see what they'd type.
172    pub fn audit_default_bindings(_leader: char) -> Vec<(&'static str, &'static str, PageAction)> {
173        let mut rows: Vec<(&'static str, &'static str, PageAction)> = DEFAULT_BINDINGS
174            .iter()
175            .map(|(mode, keys, action)| (mode_label(*mode), *keys, action.clone()))
176            .collect();
177        rows.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(b.1)));
178        rows
179    }
180
181    /// Phase 6 a11y guarantee: every `PageAction` reachable by a
182    /// reasonable user is bound to at least one default chord in some
183    /// mode. Returns the list of unbound action *names* (debug-format
184    /// of the unit/parameterised variant) — empty Vec means full
185    /// coverage.
186    ///
187    /// "Reasonable" here excludes a small allow-list:
188    ///
189    /// - [`PageAction::TabReorder`] — currently mouse-only by design
190    /// - [`PageAction::ClearCompletedDownloads`] — no obvious chord;
191    ///   reachable via `:downloads` cmdline
192    /// - [`PageAction::EnterMode`] — variant-of-everything; the
193    ///   specific `EnterHintMode`/`OpenOmnibar`/etc. cover the surface
194    /// - [`PageAction::ScrollUp`/`ScrollDown` ≠ 1] — only the count=1
195    ///   variants need a default; counts come from the count buffer
196    pub fn missing_default_bindings() -> Vec<&'static str> {
197        // Static list of variant kinds we expect bound. A new
198        // `PageAction` variant lands → add it here or to the allow
199        // list. The exhaustive match below is the failure surface
200        // that catches drift at compile time.
201        //
202        // We DO this by name (not by `PageAction` value) so that count
203        // variants compare with `count = 1`; the table maps the canonical
204        // chord that fires the unit case.
205        let bound: std::collections::HashSet<&'static str> = DEFAULT_BINDINGS
206            .iter()
207            .map(|(_, _, a)| action_kind(a))
208            .collect();
209        let expected = [
210            "ScrollUp",
211            "ScrollDown",
212            "ScrollLeft",
213            "ScrollRight",
214            "ScrollHalfPageDown",
215            "ScrollHalfPageUp",
216            "ScrollFullPageDown",
217            "ScrollFullPageUp",
218            "ScrollTop",
219            "ScrollBottom",
220            "TabNext",
221            "TabPrev",
222            "TabClose",
223            "TabNewRight",
224            "TabNewLeft",
225            "PinTab",
226            "ReopenClosedTab",
227            "PasteUrl",
228            "MoveTabLeft",
229            "MoveTabRight",
230            "HistoryBack",
231            "HistoryForward",
232            "Reload",
233            "ReloadHard",
234            "StopLoading",
235            "OpenOmnibar",
236            "OpenCommandLine",
237            "EnterHintMode",
238            "EnterHintModeBackground",
239            "Find",
240            "FindNext",
241            "FindPrev",
242            "YankUrl",
243            "ZoomIn",
244            "ZoomOut",
245            "ZoomReset",
246            "OpenDevTools",
247            "FocusFirstInput",
248            "ExitInsertMode",
249        ];
250        expected
251            .iter()
252            .copied()
253            .filter(|name| !bound.contains(name))
254            .collect()
255    }
256
257    /// Default vim-flavoured bindings. `leader` is the configured
258    /// leader char (vim default is `\`). See `docs/keymap.md` for
259    /// the full table.
260    pub fn default_bindings(leader: char) -> Self {
261        let mut km = Keymap::new();
262        km.set_leader(leader);
263        for &(mode, keys, ref action) in DEFAULT_BINDINGS {
264            // `unwrap` is fine here: the table is static and tested.
265            // A bad entry is a programming bug, surfaced by
266            // `default_bindings_table_parses` in the unit tests.
267            km.bind(mode, keys, action.clone())
268                .expect("static default-bindings table parses");
269        }
270        km
271    }
272}
273
274impl Node {
275    /// Walk the trie depth-first and append every (prefix, action)
276    /// pair to `out`. `prefix` is mutated as a working buffer; the
277    /// caller starts with an empty `Vec`.
278    fn collect(&self, prefix: &mut Vec<KeyChord>, out: &mut Vec<(Vec<KeyChord>, PageAction)>) {
279        if let Some(a) = &self.action {
280            out.push((prefix.clone(), a.clone()));
281        }
282        for (chord, child) in &self.children {
283            prefix.push(*chord);
284            child.collect(prefix, out);
285            prefix.pop();
286        }
287    }
288}
289
290impl ModeMap {
291    fn bind_chords(&mut self, chords: &[KeyChord], action: PageAction) {
292        let mut node = &mut self.root;
293        for c in chords {
294            node = node.children.entry(*c).or_default();
295        }
296        node.action = Some(action);
297    }
298
299    fn lookup(&self, chords: &[KeyChord]) -> Lookup<'_> {
300        let mut node = &self.root;
301        for c in chords {
302            match node.children.get(c) {
303                Some(n) => node = n,
304                None => return Lookup::NoMatch,
305            }
306        }
307        if let Some(action) = &node.action {
308            // Action present at this node, but a longer prefix may
309            // exist — ambiguity. Only call it Match if there are no
310            // children. With children, the caller still holds Pending
311            // so the timeout can resolve.
312            if node.children.is_empty() {
313                Lookup::Match(action)
314            } else {
315                Lookup::Pending
316            }
317        } else if node.children.is_empty() {
318            Lookup::NoMatch
319        } else {
320            Lookup::Pending
321        }
322    }
323
324    fn resolve_timeout(&self, chords: &[KeyChord]) -> Option<&PageAction> {
325        let mut node = &self.root;
326        let mut last_action: Option<&PageAction> = None;
327        if let Some(a) = &node.action {
328            last_action = Some(a);
329        }
330        for c in chords {
331            match node.children.get(c) {
332                Some(n) => {
333                    node = n;
334                    if let Some(a) = &node.action {
335                        last_action = Some(a);
336                    }
337                }
338                None => break,
339            }
340        }
341        last_action
342    }
343}
344
345fn mode_label(mode: PageMode) -> &'static str {
346    match mode {
347        PageMode::Normal => "normal",
348        PageMode::Visual => "visual",
349        PageMode::Command => "command",
350        PageMode::Hint => "hint",
351        PageMode::Pending => "pending",
352        PageMode::Insert => "insert",
353    }
354}
355
356/// Cheap discriminant name for a `PageAction`. Used by the a11y audit
357/// to bucket count-bearing variants under one name (e.g. ScrollDown(1)
358/// and ScrollDown(5) both report "ScrollDown").
359fn action_kind(a: &PageAction) -> &'static str {
360    match a {
361        PageAction::ScrollUp(_) => "ScrollUp",
362        PageAction::ScrollDown(_) => "ScrollDown",
363        PageAction::ScrollLeft(_) => "ScrollLeft",
364        PageAction::ScrollRight(_) => "ScrollRight",
365        PageAction::ScrollPageUp => "ScrollPageUp",
366        PageAction::ScrollPageDown => "ScrollPageDown",
367        PageAction::ScrollFullPageDown => "ScrollFullPageDown",
368        PageAction::ScrollFullPageUp => "ScrollFullPageUp",
369        PageAction::ScrollHalfPageDown => "ScrollHalfPageDown",
370        PageAction::ScrollHalfPageUp => "ScrollHalfPageUp",
371        PageAction::ScrollTop => "ScrollTop",
372        PageAction::ScrollBottom => "ScrollBottom",
373        PageAction::TabNext => "TabNext",
374        PageAction::TabPrev => "TabPrev",
375        PageAction::TabClose => "TabClose",
376        PageAction::TabNew => "TabNew",
377        PageAction::TabNewRight => "TabNewRight",
378        PageAction::TabNewLeft => "TabNewLeft",
379        PageAction::PinTab => "PinTab",
380        PageAction::ReopenClosedTab => "ReopenClosedTab",
381        PageAction::PasteUrl { .. } => "PasteUrl",
382        PageAction::TabReorder { .. } => "TabReorder",
383        PageAction::MoveTabLeft => "MoveTabLeft",
384        PageAction::MoveTabRight => "MoveTabRight",
385        PageAction::HistoryBack => "HistoryBack",
386        PageAction::HistoryForward => "HistoryForward",
387        PageAction::Reload => "Reload",
388        PageAction::ReloadHard => "ReloadHard",
389        PageAction::StopLoading => "StopLoading",
390        PageAction::OpenOmnibar => "OpenOmnibar",
391        PageAction::OpenCommandLine => "OpenCommandLine",
392        PageAction::EnterHintMode => "EnterHintMode",
393        PageAction::EnterHintModeBackground => "EnterHintModeBackground",
394        PageAction::EnterMode(_) => "EnterMode",
395        PageAction::Find { .. } => "Find",
396        PageAction::FindNext => "FindNext",
397        PageAction::FindPrev => "FindPrev",
398        PageAction::YankUrl => "YankUrl",
399        PageAction::YankSelection => "YankSelection",
400        PageAction::ZoomIn => "ZoomIn",
401        PageAction::ZoomOut => "ZoomOut",
402        PageAction::ZoomReset => "ZoomReset",
403        PageAction::OpenDevTools => "OpenDevTools",
404        PageAction::ClearCompletedDownloads => "ClearCompletedDownloads",
405        PageAction::EnterInsertMode => "EnterInsertMode",
406        PageAction::FocusFirstInput => "FocusFirstInput",
407        PageAction::ExitInsertMode => "ExitInsertMode",
408    }
409}
410
411/// Default keymap table. Static so test can validate every entry
412/// parses cleanly. See `docs/keymap.md` for the human-readable
413/// version.
414///
415/// Bindings mirror Vieb defaults; see docs/keymap.md for intentional
416/// divergences (`=` as ZoomReset, `<C-c>` as StopLoading, `o` kept).
417const DEFAULT_BINDINGS: &[(PageMode, &str, PageAction)] = &[
418    // -- scroll ---------------------------------------------------
419    (PageMode::Normal, "j", PageAction::ScrollDown(1)),
420    (PageMode::Normal, "k", PageAction::ScrollUp(1)),
421    (PageMode::Normal, "h", PageAction::ScrollLeft(1)),
422    (PageMode::Normal, "l", PageAction::ScrollRight(1)),
423    (PageMode::Normal, "<Down>", PageAction::ScrollDown(1)),
424    (PageMode::Normal, "<Up>", PageAction::ScrollUp(1)),
425    (PageMode::Normal, "<Left>", PageAction::ScrollLeft(1)),
426    (PageMode::Normal, "<Right>", PageAction::ScrollRight(1)),
427    (PageMode::Normal, "<C-e>", PageAction::ScrollDown(1)),
428    (PageMode::Normal, "<C-y>", PageAction::ScrollUp(1)),
429    (PageMode::Normal, "<C-d>", PageAction::ScrollHalfPageDown),
430    (PageMode::Normal, "<C-u>", PageAction::ScrollHalfPageUp),
431    (PageMode::Normal, "<C-f>", PageAction::ScrollFullPageDown),
432    (PageMode::Normal, "<C-b>", PageAction::ScrollFullPageUp),
433    (
434        PageMode::Normal,
435        "<PageDown>",
436        PageAction::ScrollFullPageDown,
437    ),
438    (PageMode::Normal, "<PageUp>", PageAction::ScrollFullPageUp),
439    (PageMode::Normal, "gg", PageAction::ScrollTop),
440    (PageMode::Normal, "G", PageAction::ScrollBottom),
441    (PageMode::Normal, "<Home>", PageAction::ScrollTop),
442    (PageMode::Normal, "<End>", PageAction::ScrollBottom),
443    // -- tabs -----------------------------------------------------
444    // User preference: H/L = tab prev/next (NOT vieb default of history).
445    (PageMode::Normal, "H", PageAction::TabPrev),
446    (PageMode::Normal, "L", PageAction::TabNext),
447    (PageMode::Normal, "gt", PageAction::TabNext),
448    (PageMode::Normal, "gT", PageAction::TabPrev),
449    (PageMode::Normal, "d", PageAction::TabClose),
450    // PinTab toggles the active tab's pin state. `<leader>p` keeps the
451    // chord off the `<C-w>` prefix space so the leaf <C-w> = TabClose
452    // bind doesn't have to wait for an ambiguity timeout.
453    (PageMode::Normal, "<leader>p", PageAction::PinTab),
454    // Paste-as-tab: open a new tab using the clipboard contents as
455    // its URL. Apps layer validates the clipboard classifies as Url
456    // or Host before opening; non-URL clipboard contents are no-ops.
457    (PageMode::Normal, "p", PageAction::PasteUrl { after: true }),
458    (PageMode::Normal, "P", PageAction::PasteUrl { after: false }),
459    // Re-open the most recently closed tab (vim-flavored undo). Stack-based
460    // so repeated `u` undoes successive closes in reverse order.
461    (PageMode::Normal, "u", PageAction::ReopenClosedTab),
462    // Conventional-browser alternates for users migrating from Chromium /
463    // Firefox. `<C-w>` is now a leaf binding (no `<C-w>X` prefix chords)
464    // so it fires immediately without an ambiguity timeout.
465    (PageMode::Normal, "<C-t>", PageAction::TabNewRight),
466    (PageMode::Normal, "<C-S-t>", PageAction::ReopenClosedTab),
467    (PageMode::Normal, "<C-w>", PageAction::TabClose),
468    // Shuffle the active tab one slot. Mirrors H/L direction (prev/next)
469    // with Shift added — same hand position, different verb.
470    (PageMode::Normal, "<C-S-h>", PageAction::MoveTabLeft),
471    (PageMode::Normal, "<C-S-l>", PageAction::MoveTabRight),
472    // -- history --------------------------------------------------
473    // User preference: J/K = history back/forward (NOT vieb default of tabs).
474    (PageMode::Normal, "J", PageAction::HistoryBack),
475    (PageMode::Normal, "K", PageAction::HistoryForward),
476    (PageMode::Normal, "<C-o>", PageAction::HistoryBack),
477    (PageMode::Normal, "<C-i>", PageAction::HistoryForward),
478    // -- reload / stop --------------------------------------------
479    (PageMode::Normal, "r", PageAction::Reload),
480    (PageMode::Normal, "R", PageAction::ReloadHard),
481    (PageMode::Normal, "<C-r>", PageAction::ReloadHard),
482    // <Esc> now exits insert mode unconditionally.
483    (PageMode::Normal, "<Esc>", PageAction::ExitInsertMode),
484    // buffr extension: <C-c> as StopLoading (Vieb: copyText)
485    (PageMode::Normal, "<C-c>", PageAction::StopLoading),
486    // -- tabs (adjacent open) -------------------------------------
487    // `o` opens a new tab to the right of the active tab and auto-opens
488    // the omnibar. `O` opens to the left.
489    (PageMode::Normal, "o", PageAction::TabNewRight),
490    (PageMode::Normal, "O", PageAction::TabNewLeft),
491    // -- omnibar / command ----------------------------------------
492    (PageMode::Normal, "e", PageAction::OpenOmnibar),
493    (PageMode::Normal, "<C-l>", PageAction::OpenOmnibar),
494    (PageMode::Normal, ":", PageAction::OpenCommandLine),
495    // buffr extension: `;` as command line alias
496    (PageMode::Normal, ";", PageAction::OpenCommandLine),
497    // -- hints ----------------------------------------------------
498    (PageMode::Normal, "f", PageAction::EnterHintMode),
499    (PageMode::Normal, "F", PageAction::EnterHintModeBackground),
500    // -- find -----------------------------------------------------
501    (PageMode::Normal, "/", PageAction::Find { forward: true }),
502    (PageMode::Normal, "?", PageAction::Find { forward: false }),
503    (PageMode::Normal, "n", PageAction::FindNext),
504    (PageMode::Normal, "N", PageAction::FindPrev),
505    // -- yank -----------------------------------------------------
506    (PageMode::Normal, "y", PageAction::YankUrl),
507    (PageMode::Normal, "<C-c>", PageAction::YankUrl),
508    // -- zoom -----------------------------------------------------
509    // `+` / `=` zoom in (matches Chromium's Ctrl++ and Ctrl+= aliases),
510    // `-` / `_` zoom out, `0` / `)` reset. `<C-0>` kept as a Vieb-style
511    // alias for users who still reach for the conventional chord.
512    (PageMode::Normal, "+", PageAction::ZoomIn),
513    (PageMode::Normal, "=", PageAction::ZoomIn),
514    (PageMode::Normal, "-", PageAction::ZoomOut),
515    (PageMode::Normal, "_", PageAction::ZoomOut),
516    (PageMode::Normal, "0", PageAction::ZoomReset),
517    (PageMode::Normal, ")", PageAction::ZoomReset),
518    (PageMode::Normal, "<C-0>", PageAction::ZoomReset),
519    // -- insert mode -----------------------------------------------
520    // `i` and `gi` both focus the first form input (same as Vieb's
521    // insertAtFirstInput / gi). EnterInsertMode stays in the enum for
522    // advanced user config but is unbound by default.
523    (PageMode::Normal, "i", PageAction::FocusFirstInput),
524    (PageMode::Normal, "gi", PageAction::FocusFirstInput),
525    // -- devtools -------------------------------------------------
526    (PageMode::Normal, "<F12>", PageAction::OpenDevTools),
527    (PageMode::Normal, "<C-S-i>", PageAction::OpenDevTools),
528    // -- visual-mode minimal ---------------------------------------
529    // Visual mode is entered automatically when the user drags with
530    // the left mouse button in the page area; the embedded CEF view
531    // handles the on-screen text-selection rendering. `y` yanks the
532    // current page selection to the system clipboard (via CEF's
533    // native frame.copy()) and returns to Normal. `<Esc>` cancels
534    // without yanking.
535    (PageMode::Visual, "y", PageAction::YankSelection),
536    (PageMode::Visual, "<C-c>", PageAction::YankSelection),
537    (
538        PageMode::Visual,
539        "<Esc>",
540        PageAction::EnterMode(PageMode::Normal),
541    ),
542    // -- hint-mode ------------------------------------------------
543    (
544        PageMode::Hint,
545        "<Esc>",
546        PageAction::EnterMode(PageMode::Normal),
547    ),
548    // -- command-mode ---------------------------------------------
549    (
550        PageMode::Command,
551        "<Esc>",
552        PageAction::EnterMode(PageMode::Normal),
553    ),
554];
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::key::parse_keys as pk;
560
561    fn chords(s: &str) -> Vec<KeyChord> {
562        pk(s).expect("parse")
563    }
564
565    #[test]
566    fn empty_lookup_returns_pending() {
567        let mut km = Keymap::new();
568        km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
569            .unwrap();
570        let r = km.lookup(PageMode::Normal, &[]);
571        assert!(matches!(r, Lookup::Pending));
572    }
573
574    #[test]
575    fn unbound_returns_no_match() {
576        let km = Keymap::new();
577        let r = km.lookup(PageMode::Normal, &chords("xyz"));
578        assert!(matches!(r, Lookup::NoMatch));
579    }
580
581    #[test]
582    fn exact_match_with_no_extension() {
583        let mut km = Keymap::new();
584        km.bind(PageMode::Normal, "<C-w>v", PageAction::TabNew)
585            .unwrap();
586        let r = km.lookup(PageMode::Normal, &chords("<C-w>v"));
587        assert!(matches!(r, Lookup::Match(PageAction::TabNew)));
588    }
589
590    #[test]
591    fn prefix_returns_pending() {
592        let mut km = Keymap::new();
593        km.bind(PageMode::Normal, "<C-w>v", PageAction::TabNew)
594            .unwrap();
595        let r = km.lookup(PageMode::Normal, &chords("<C-w>"));
596        assert!(matches!(r, Lookup::Pending));
597    }
598
599    #[test]
600    fn prefix_conflict_g_vs_gg_pending() {
601        let mut km = Keymap::new();
602        km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
603            .unwrap();
604        km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
605            .unwrap();
606        let lookup = km.lookup(PageMode::Normal, &chords("g"));
607        assert!(matches!(lookup, Lookup::Pending));
608        let resolved = km.resolve_timeout(PageMode::Normal, &chords("g"));
609        assert!(matches!(resolved, Some(PageAction::HistoryBack)));
610    }
611
612    #[test]
613    fn longer_match_wins_when_extended() {
614        let mut km = Keymap::new();
615        km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
616            .unwrap();
617        km.bind(PageMode::Normal, "gg", PageAction::ScrollTop)
618            .unwrap();
619        let r = km.lookup(PageMode::Normal, &chords("gg"));
620        assert!(matches!(r, Lookup::Match(PageAction::ScrollTop)));
621    }
622
623    #[test]
624    fn rebind_overwrites() {
625        let mut km = Keymap::new();
626        km.bind(PageMode::Normal, "<C-r>", PageAction::Reload)
627            .unwrap();
628        km.bind(PageMode::Normal, "<C-r>", PageAction::HistoryForward)
629            .unwrap();
630        let r = km.lookup(PageMode::Normal, &chords("<C-r>"));
631        assert!(matches!(r, Lookup::Match(PageAction::HistoryForward)));
632    }
633
634    #[test]
635    fn no_match_after_dead_end() {
636        let mut km = Keymap::new();
637        km.bind(PageMode::Normal, "gT", PageAction::TabPrev)
638            .unwrap();
639        let r = km.lookup(PageMode::Normal, &chords("gz"));
640        assert!(matches!(r, Lookup::NoMatch));
641    }
642
643    #[test]
644    fn case_sensitive_letters() {
645        let mut km = Keymap::new();
646        km.bind(PageMode::Normal, "g", PageAction::HistoryBack)
647            .unwrap();
648        km.bind(PageMode::Normal, "G", PageAction::ScrollBottom)
649            .unwrap();
650        assert!(matches!(
651            km.lookup(PageMode::Normal, &chords("G")),
652            Lookup::Match(PageAction::ScrollBottom)
653        ));
654    }
655
656    #[test]
657    fn mode_isolation() {
658        let mut km = Keymap::new();
659        km.bind(PageMode::Normal, "j", PageAction::ScrollDown(1))
660            .unwrap();
661        // Visual mode hasn't bound `j`, so lookup returns NoMatch.
662        let r = km.lookup(PageMode::Visual, &chords("j"));
663        assert!(matches!(r, Lookup::NoMatch));
664    }
665
666    #[test]
667    fn leader_resolves_to_configured_char() {
668        let mut km = Keymap::new();
669        km.set_leader('\\');
670        km.bind(PageMode::Normal, "<leader>n", PageAction::TabNew)
671            .unwrap();
672        // `<leader>` resolved to `\`, so the binding fires for `\n`.
673        let r = km.lookup(PageMode::Normal, &chords("\\n"));
674        assert!(matches!(r, Lookup::Match(PageAction::TabNew)));
675    }
676
677    #[test]
678    fn leader_without_config_errors() {
679        let mut km = Keymap::new();
680        let err = km.bind(PageMode::Normal, "<leader>n", PageAction::TabNew);
681        assert!(matches!(err, Err(BindError::NoLeader)));
682    }
683
684    #[test]
685    fn default_bindings_table_parses() {
686        // Smoke: every entry in DEFAULT_BINDINGS round-trips through
687        // the parser without error. Catches typos in the static
688        // table at test-time rather than panic-on-startup.
689        let _km = Keymap::default_bindings('\\');
690    }
691
692    #[test]
693    fn default_j_scrolls_down() {
694        let km = Keymap::default_bindings('\\');
695        let r = km.lookup(PageMode::Normal, &chords("j"));
696        assert!(matches!(r, Lookup::Match(PageAction::ScrollDown(1))));
697    }
698
699    #[test]
700    fn default_gg_top_g_prefix_pending() {
701        let km = Keymap::default_bindings('\\');
702        // `g` is a prefix of `gg`, `gt`, `gT` — must be Pending.
703        let r = km.lookup(PageMode::Normal, &chords("g"));
704        assert!(matches!(r, Lookup::Pending));
705        let r = km.lookup(PageMode::Normal, &chords("gg"));
706        assert!(matches!(r, Lookup::Match(PageAction::ScrollTop)));
707    }
708
709    #[test]
710    fn default_ctrl_w_closes_tab() {
711        let km = Keymap::default_bindings('\\');
712        let r = km.lookup(PageMode::Normal, &chords("<C-w>"));
713        assert!(matches!(r, Lookup::Match(PageAction::TabClose)));
714    }
715
716    #[test]
717    fn default_devtools_binding() {
718        let km = Keymap::default_bindings('\\');
719        let r = km.lookup(PageMode::Normal, &chords("<C-S-i>"));
720        assert!(matches!(r, Lookup::Match(PageAction::OpenDevTools)));
721    }
722
723    #[test]
724    fn audit_default_bindings_returns_sorted_rows() {
725        let rows = Keymap::audit_default_bindings('\\');
726        assert!(!rows.is_empty());
727        // Sorted by mode then keys — assert pairwise.
728        for w in rows.windows(2) {
729            let (a_mode, a_keys) = (w[0].0, w[0].1);
730            let (b_mode, b_keys) = (w[1].0, w[1].1);
731            let cmp = a_mode.cmp(b_mode).then(a_keys.cmp(b_keys));
732            assert!(cmp.is_le(), "{a_mode}/{a_keys} vs {b_mode}/{b_keys}");
733        }
734    }
735
736    #[test]
737    fn every_user_facing_action_has_a_default_binding() {
738        let missing = Keymap::missing_default_bindings();
739        assert!(missing.is_empty(), "unbound actions: {missing:?}");
740    }
741
742    #[test]
743    fn default_find_forward_and_back() {
744        let km = Keymap::default_bindings('\\');
745        assert!(matches!(
746            km.lookup(PageMode::Normal, &chords("/")),
747            Lookup::Match(PageAction::Find { forward: true })
748        ));
749        assert!(matches!(
750            km.lookup(PageMode::Normal, &chords("?")),
751            Lookup::Match(PageAction::Find { forward: false })
752        ));
753    }
754}