Skip to main content

escriba_keymap/
lib.rs

1//! `escriba-keymap` — mode-aware keybinding dispatch.
2
3extern crate self as escriba_keymap;
4
5use escriba_search::{CaretMove, Direction as SearchDirection};
6use std::collections::HashMap;
7
8use escriba_core::{
9    Action, CountedAction, InsertAt, Mode, Motion, Operator, TextObject, ViewAlign,
10};
11use escriba_mode::ModalState;
12
13pub mod pipeline;
14pub use pipeline::{FindSpec, KeyPipeline, operand_capture_order};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum Key {
19    Char(char),
20    Esc,
21    Enter,
22    Tab,
23    Backspace,
24    Delete,
25    Left,
26    Right,
27    Up,
28    Down,
29    PageUp,
30    PageDown,
31    Home,
32    End,
33    Ctrl(char),
34    Alt(char),
35    /// A function key. Discarded at the door until now
36    /// (`escriba-input`: `KeyCode::F(_) => return None`), so `<F5>` could be
37    /// declared and never arrive.
38    F(u8),
39    /// Anything the shorthands above cannot say: more than one modifier,
40    /// `Shift` as a modifier rather than a capital letter, `Super`/`Cmd`.
41    ///
42    /// `Ctrl(char)` and `Alt(char)` fold the modifier INTO the key, so they
43    /// can carry exactly one. The translator has always computed all four
44    /// modifier flags and then thrown `shift` and `meta` away for want of
45    /// somewhere to put them; this is that somewhere.
46    ///
47    /// Carries `awase::Hotkey` directly rather than a parallel spelling —
48    /// escriba's vocabulary widens by consuming the fleet's, not by growing
49    /// a fifth one.
50    Chord(awase::Hotkey),
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Binding {
55    pub action: Action,
56    pub description: String,
57}
58
59impl Binding {
60    #[must_use]
61    pub fn new(action: Action, description: impl Into<String>) -> Self {
62        Self {
63            action,
64            description: description.into(),
65        }
66    }
67}
68
69/// A binding that will not do what its author intended.
70///
71/// Recorded at BIND time rather than discovered later, because both kinds are
72/// silent by construction: a reserved chord never receives its event, and an
73/// overwritten binding simply stops existing. Neither produces an error at
74/// the moment it happens, and both present as "that key is broken".
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Collision {
77    /// Something outside escriba owns this chord — the OS, the window
78    /// manager, the terminal. The binding can never fire.
79    ///
80    /// Not arbitrable: no amount of reordering inside escriba changes it.
81    Reserved {
82        mode: Mode,
83        key: String,
84        description: String,
85        /// Who took it and what for.
86        why: String,
87    },
88    /// A later binding displaced an earlier one for the same chord.
89    ///
90    /// Sometimes intended — the shipped rc deliberately overrides defaults —
91    /// which is why this is REPORTED rather than refused. But it is reported,
92    /// because "my plugin's key stopped working" has no other explanation
93    /// available to an operator.
94    Displaced {
95        mode: Mode,
96        key: String,
97        replaced: String,
98        with: String,
99    },
100}
101
102impl Collision {
103    /// One line an operator can act on.
104    #[must_use]
105    pub fn report(&self) -> String {
106        match self {
107            Self::Reserved {
108                mode,
109                key,
110                description,
111                why,
112            } => format!("{mode:?} {key} ({description}) — {why}"),
113            Self::Displaced {
114                mode,
115                key,
116                replaced,
117                with,
118            } => format!("{mode:?} {key} — \"{replaced}\" was replaced by \"{with}\""),
119        }
120    }
121
122    /// Can this binding ever fire?
123    #[must_use]
124    pub const fn is_fatal(&self) -> bool {
125        matches!(self, Self::Reserved { .. })
126    }
127}
128
129/// What `dispatch` does with a key INSTEAD of consulting the binding table.
130///
131/// Named so a reader can tell the three apart: they look alike from the
132/// dispatcher's side (all three return before `lookup`) and are completely
133/// different to an operator trying to bind the key.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Preempted {
136    /// Normal mode: the key is composing a count (`5` of `5dd`).
137    Count,
138    /// Insert mode: a printable character types itself.
139    SelfInsert,
140    /// Insert mode: `<CR>` opens a line.
141    Newline,
142    /// Command mode: a printable character types into the prompt.
143    PromptInsert,
144}
145
146impl Preempted {
147    /// What `dispatch` answers. Kept beside the classification so the two
148    /// cannot drift — the whole point of hoisting this out of `dispatch`.
149    fn resolved(self, key: &Key) -> CountedAction {
150        match self {
151            Self::Count => CountedAction::once(Action::Pending),
152            Self::SelfInsert | Self::PromptInsert => match key {
153                Key::Char(c) => CountedAction::once(Action::InsertChar(*c)),
154                // Unreachable via `preemption`, which only returns these two
155                // for `Key::Char`. Answered rather than `unreachable!()`: a
156                // dispatcher that panics on an unexpected key is a worse
157                // failure than one that declines it.
158                _ => CountedAction::once(Action::Pending),
159            },
160            Self::Newline => CountedAction::once(Action::InsertChar('\n')),
161        }
162    }
163}
164
165/// WHETHER `dispatch` preempts a key, and whether it always does.
166///
167/// The distinction is load-bearing for reachability: `1`–`9` in Normal are
168/// preempted unconditionally, so a binding for one can never fire. `0` is
169/// preempted only while a count is being typed — which is exactly why `0` is
170/// bindable, and IS bound, to `Motion::LineStart`.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Preemption {
173    /// The key never reaches the binding table. A declaration is dead.
174    Always(Preempted),
175    /// The key reaches the table unless a count is pending. Bindable.
176    WhenCounting(Preempted),
177}
178
179/// Does `dispatch` answer `(mode, key)` before the binding table is consulted?
180///
181/// **The single statement of the rule.** [`Keymap::dispatch`] reads it to
182/// answer keys; anything auditing whether a DECLARATION could ever fire reads
183/// it to say so. Before it existed the rule lived in three inline `if mode ==`
184/// blocks inside `dispatch`, visible to nothing else, so a binding for a bare
185/// printable character in Insert mode parsed, applied, reported as applied —
186/// and was dead on arrival with nowhere to learn that from.
187///
188/// Total over the preempting cases; everything else falls through to `None`,
189/// which is the safe direction (a key wrongly called reachable shows up as a
190/// binding that does not work, a key wrongly called dead hides a working one).
191#[must_use]
192pub fn preemption(mode: Mode, key: &Key) -> Option<Preemption> {
193    match (mode, key) {
194        (Mode::Normal, Key::Char('0')) => Some(Preemption::WhenCounting(Preempted::Count)),
195        (Mode::Normal, Key::Char(c)) if c.is_ascii_digit() => {
196            Some(Preemption::Always(Preempted::Count))
197        }
198        (Mode::Insert, Key::Char(_)) => Some(Preemption::Always(Preempted::SelfInsert)),
199        (Mode::Insert, Key::Enter) => Some(Preemption::Always(Preempted::Newline)),
200        (Mode::Command, Key::Char(_)) => Some(Preemption::Always(Preempted::PromptInsert)),
201        _ => None,
202    }
203}
204
205#[derive(Debug, Clone)]
206pub struct Keymap {
207    bindings: HashMap<(Mode, Key), Binding>,
208    /// Multi-key sequence bindings (`<leader>ff`, `gg`, `<C-w>h`).
209    /// Keyed by the full key sequence; resolved by the runtime's
210    /// pending-stroke loop ([`lookup_sequence`](Keymap::lookup_sequence)
211    /// + [`is_sequence_prefix`](Keymap::is_sequence_prefix)).
212    sequences: HashMap<(Mode, Vec<Key>), Binding>,
213    /// The prefix `<leader>` resolves to at sequence-apply time.
214    leader: Key,
215    /// Chords the world owns. Consulted on every bind, so a binding that
216    /// cannot fire is known at CONSTRUCTION rather than discovered by an
217    /// operator pressing a dead key.
218    reserved: awase::Reserved,
219    /// Every collision seen while building this keymap, in bind order.
220    collisions: Vec<Collision>,
221}
222
223impl Default for Keymap {
224    fn default() -> Self {
225        Self {
226            bindings: HashMap::new(),
227            sequences: HashMap::new(),
228            reserved: awase::Reserved::fleet_darwin(),
229            collisions: Vec::new(),
230            // blnvim's leader is comma; escriba ships blnvim-parity
231            // defaults, so the prefix users press matches muscle memory.
232            leader: Key::Char(','),
233        }
234    }
235}
236
237impl Keymap {
238    #[must_use]
239    pub fn new() -> Self {
240        Self::default()
241    }
242
243    #[must_use]
244    pub fn default_vim() -> Self {
245        let mut m = Self::new();
246        let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
247        nm(
248            &mut m,
249            Key::Char('h'),
250            Action::Move(Motion::Left),
251            "move left",
252        );
253        nm(
254            &mut m,
255            Key::Char('l'),
256            Action::Move(Motion::Right),
257            "move right",
258        );
259        nm(
260            &mut m,
261            Key::Char('j'),
262            Action::Move(Motion::Down),
263            "move down",
264        );
265        nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
266        nm(
267            &mut m,
268            Key::Char('w'),
269            Action::Move(Motion::WordStartNext),
270            "word forward",
271        );
272        nm(
273            &mut m,
274            Key::Char('b'),
275            Action::Move(Motion::WordStartPrev),
276            "word back",
277        );
278        nm(
279            &mut m,
280            Key::Char('e'),
281            Action::Move(Motion::WordEndNext),
282            "word end",
283        );
284        nm(
285            &mut m,
286            Key::Char('0'),
287            Action::Move(Motion::LineStart),
288            "line start",
289        );
290        nm(
291            &mut m,
292            Key::Char('$'),
293            Action::Move(Motion::LineEnd),
294            "line end",
295        );
296        nm(
297            &mut m,
298            Key::Char('G'),
299            Action::Move(Motion::DocEnd),
300            "doc end",
301        );
302        // ── the rest of the vim movement suite ────────────────────────
303        //
304        // Everything below was a MOTION escriba already had a vocabulary for
305        // and no key to reach. Grouped as a table because the interesting
306        // content is the key→motion pairing, and eighteen `nm(...)` calls
307        // spelled out is eighteen places to mistype one.
308        //
309        // `f`/`F`/`t`/`T` are NOT here: their operand is the next keystroke,
310        // so the runtime claims them before the keymap is consulted (see
311        // `KeyPipeline::claim_find`). `;`/`,` ARE here — they carry no
312        // operand, only a direction.
313        for (key, motion, label) in [
314            (Key::Char('W'), Motion::BigWordStartNext, "WORD forward"),
315            (Key::Char('E'), Motion::BigWordEndNext, "WORD end"),
316            (Key::Char('B'), Motion::BigWordStartPrev, "WORD back"),
317            (Key::Char('^'), Motion::LineFirstNonBlank, "first non-blank"),
318            (
319                Key::Char('_'),
320                // NOT an alias of `^`: same landing character, different
321                // motion KIND, so `d^` deletes back to the indent and `d_`
322                // deletes the line. See `Motion::LinewiseDown`.
323                Motion::LinewiseDown,
324                "first non-blank, linewise",
325            ),
326            (Key::Char('|'), Motion::Column(1), "to column"),
327            (
328                Key::Char('+'),
329                Motion::LineDownFirstNonBlank,
330                "next line, first non-blank",
331            ),
332            (
333                Key::Char('-'),
334                Motion::LineUpFirstNonBlank,
335                "previous line, first non-blank",
336            ),
337            (Key::Char('%'), Motion::MatchPair, "matching bracket"),
338            (Key::Char('}'), Motion::ParagraphNext, "next paragraph"),
339            (Key::Char('{'), Motion::ParagraphPrev, "previous paragraph"),
340            (Key::Char(')'), Motion::SentenceNext, "next sentence"),
341            (Key::Char('('), Motion::SentencePrev, "previous sentence"),
342            (Key::Char('H'), Motion::ScreenTop, "screen top"),
343            (Key::Char('M'), Motion::ScreenMiddle, "screen middle"),
344            (Key::Char('L'), Motion::ScreenBottom, "screen bottom"),
345            (
346                Key::Char(';'),
347                Motion::RepeatFind { reverse: false },
348                "repeat find",
349            ),
350            // `,` is NOT here, and the reason is a real conflict rather than
351            // an oversight: escriba's shipped leader IS `,` (blnvim parity),
352            // and this keymap's rule is that a single binding WINS over a
353            // sequence prefix — so binding `,` would silently kill all 93
354            // `<leader>…` bindings the catalog ships. The leader keeps it.
355            // Reverse-repeat is reachable as `:action "find-reverse"` for an
356            // rc that chooses a different leader, and `F`/`T` remain the
357            // direct way to search backwards.
358            (Key::Ctrl('f'), Motion::PageDown, "page down"),
359            (Key::Ctrl('b'), Motion::PageUp, "page up"),
360            (Key::Ctrl('d'), Motion::HalfPageDown, "half page down"),
361            // `<C-u>` IS free in Normal — the erase verb of the same name is
362            // bound in Insert and Command only, and a binding is per-mode.
363            // It was listed as "conflicted" once; it never was.
364            (Key::Ctrl('u'), Motion::HalfPageUp, "half page up"),
365            (Key::Enter, Motion::LineDownFirstNonBlank, "next line"),
366        ] {
367            nm(&mut m, key, Action::Move(motion), label);
368        }
369        // `zt` / `zz` / `zb` — re-frame the window, leaving the cursor put.
370        // Sequences, and `z` is bound to nothing on its own, so there is no
371        // single binding for these to lose to.
372        for (k, align, label) in [
373            (Key::Char('t'), ViewAlign::Top, "cursor line to top"),
374            (Key::Char('z'), ViewAlign::Center, "centre cursor line"),
375            (Key::Char('b'), ViewAlign::Bottom, "cursor line to bottom"),
376        ] {
377            m.bind_sequence(
378                Mode::Normal,
379                vec![Key::Char('z'), k],
380                Action::ScrollView(align),
381                label,
382            );
383        }
384        // The `g`-prefixed motions.
385        //
386        // **`gg` was never bound** (found 2026-08-13). `G` was, and the only
387        // `gg` in the repo was a test that BOUND IT ITSELF before pressing it
388        // — so the test proved the sequence machinery worked and said nothing
389        // about the default keymap, and vim's most-pressed motion did nothing
390        // in the shipped editor. A test that constructs the thing it is
391        // checking cannot fail the way the product is broken.
392        for (k, motion, label) in [
393            (Key::Char('g'), Motion::DocStart, "doc start"),
394            (Key::Char('e'), Motion::WordEndPrev, "previous word end"),
395            (Key::Char('E'), Motion::BigWordEndPrev, "previous WORD end"),
396            (Key::Char('_'), Motion::LineLastNonBlank, "last non-blank"),
397        ] {
398            m.bind_sequence(
399                Mode::Normal,
400                vec![Key::Char('g'), k],
401                Action::Move(motion),
402                label,
403            );
404        }
405        // Operators — `d`/`c`/`y` arm the operator-pending FSM; the next
406        // motion composes (e.g. `dw`, `c$`, `y0`).
407        nm(
408            &mut m,
409            Key::Char('d'),
410            Action::Operator(Operator::Delete),
411            "delete (operator)",
412        );
413        nm(
414            &mut m,
415            Key::Char('c'),
416            Action::Operator(Operator::Change),
417            "change (operator)",
418        );
419        nm(
420            &mut m,
421            Key::Char('y'),
422            Action::Operator(Operator::Yank),
423            "yank (operator)",
424        );
425        // vim's single-key shortcuts for an operator-over-motion. Five keys,
426        // ZERO new executor code: they ARE those compositions, and vim simply
427        // spells them shorter. Binding them to the composed action rather than
428        // giving each its own variant is what makes `3x`, `d`-style register
429        // capture, dot-repeat and the linewise cursor rule all arrive for free
430        // and stay in step with the long spelling forever.
431        //
432        // The one prerequisite was clamping `Motion::Right` to its line —
433        // unclamped, `x` (`dl`) on an empty line crossed the terminator and
434        // joined the next line on.
435        nm(
436            &mut m,
437            Key::Char('x'),
438            Action::ApplyOperator {
439                op: Operator::Delete,
440                motion: Motion::Right,
441            },
442            "delete char under cursor",
443        );
444        nm(
445            &mut m,
446            Key::Char('X'),
447            Action::ApplyOperator {
448                op: Operator::Delete,
449                motion: Motion::Left,
450            },
451            "delete char before cursor",
452        );
453        nm(
454            &mut m,
455            Key::Char('D'),
456            Action::ApplyOperator {
457                op: Operator::Delete,
458                motion: Motion::LineEnd,
459            },
460            "delete to line end",
461        );
462        nm(
463            &mut m,
464            Key::Char('C'),
465            Action::ApplyOperator {
466                op: Operator::Change,
467                motion: Motion::LineEnd,
468            },
469            "change to line end",
470        );
471        // `Y` is the ONE key where vim and neovim actively disagree: classic
472        // vim makes it a synonym for `yy` (linewise), neovim ≥0.6 makes it
473        // `y$`. escriba's shipped default mirrors blnvim, which is neovim, so
474        // this is `y$` — stated out loud because a silent choice here is a
475        // trap for whichever half of the world guesses the other way.
476        nm(
477            &mut m,
478            Key::Char('Y'),
479            Action::ApplyOperator {
480                op: Operator::Yank,
481                motion: Motion::LineEnd,
482            },
483            "yank to line end",
484        );
485        nm(
486            &mut m,
487            Key::Char('s'),
488            Action::ApplyOperator {
489                op: Operator::Change,
490                motion: Motion::Right,
491            },
492            "substitute char",
493        );
494        nm(
495            &mut m,
496            Key::Char('S'),
497            Action::ApplyOperatorObject {
498                op: Operator::Change,
499                object: escriba_core::TextObject::Line,
500            },
501            "substitute line",
502        );
503        // `J` joins with a space and the next line's indent dropped; `gJ`
504        // splices verbatim. `r` is deliberately NOT here — its operand is a
505        // KEY, claimed before the keymap, so a binding on `r` would be a table
506        // entry no keypress can reach. See `KeyPipeline::claim_replace`.
507        nm(
508            &mut m,
509            Key::Char('J'),
510            Action::JoinLines { space: true },
511            "join lines",
512        );
513        // The other half of every `d`/`c`/`y` above. An editor that captures
514        // text and cannot put it back is a delete key with extra steps, which
515        // is what escriba was until these two bindings landed.
516        nm(
517            &mut m,
518            Key::Char('p'),
519            Action::Put { before: false },
520            "put after",
521        );
522        nm(
523            &mut m,
524            Key::Char('P'),
525            Action::Put { before: true },
526            "put before",
527        );
528        // Structural Lisp motions — Alt-prefixed like emacs paredit.
529        nm(
530            &mut m,
531            Key::Alt('f'),
532            Action::Move(Motion::ForwardSexp),
533            "forward sexp",
534        );
535        nm(
536            &mut m,
537            Key::Alt('b'),
538            Action::Move(Motion::BackwardSexp),
539            "backward sexp",
540        );
541        nm(
542            &mut m,
543            Key::Alt('u'),
544            Action::Move(Motion::UpList),
545            "up list",
546        );
547        nm(
548            &mut m,
549            Key::Alt('d'),
550            Action::Move(Motion::DownList),
551            "down list",
552        );
553        // ── Insert entry — the whole vim family, one row each ──────────
554        //
555        // Until 2026-08-12 this was ONE row: `i`. `a`, `A`, `I`, `o` and `O`
556        // were unbound, so `A` on a line resolved to `Action::Pending` and did
557        // nothing at all — no move, no mode change, no message.
558        //
559        // Binding bare `a` and `i` is safe DESPITE the text objects (`daw`,
560        // `di(`) that also begin with them, and the reason is worth stating
561        // because it is the one thing that makes this table correct: the
562        // key pipeline's `claim_object` runs BEFORE the sequence stepper and
563        // before this table, and claims `i`/`a` only while an operator is
564        // armed (`escriba-runtime`, `OpState::Awaiting`). With nothing pending
565        // they fall through to here. `escriba-keymap`'s own "single bindings
566        // win over sequence prefixes" rule would otherwise have shadowed every
567        // text object the moment `a` got a binding.
568        //
569        // Every entry is `EnterInsert`, never `ChangeMode(Insert)`: the caret
570        // placement IS the difference between the six, and a mode change
571        // cannot carry it.
572        for (key, at, label) in [
573            (Key::Char('i'), InsertAt::Caret, "insert"),
574            (Key::Char('I'), InsertAt::FirstNonBlank, "first non-blank"),
575            (Key::Char('a'), InsertAt::AfterCaret, "append"),
576            (Key::Char('A'), InsertAt::LineEnd, "append at end of line"),
577            (Key::Char('o'), InsertAt::OpenBelow, "open line below"),
578            (Key::Char('O'), InsertAt::OpenAbove, "open line above"),
579        ] {
580            nm(&mut m, key, Action::EnterInsert(at), label);
581        }
582        nm(
583            &mut m,
584            Key::Char('v'),
585            Action::ChangeMode(Mode::Visual),
586            "visual",
587        );
588        nm(
589            &mut m,
590            Key::Char('V'),
591            Action::ChangeMode(Mode::VisualLine),
592            "visual line",
593        );
594        nm(
595            &mut m,
596            Key::Char(':'),
597            Action::ChangeMode(Mode::Command),
598            "command",
599        );
600        nm(&mut m, Key::Char('u'), Action::Undo, "undo");
601        nm(
602            &mut m,
603            Key::Char('.'),
604            Action::RepeatLastChange,
605            "repeat last change",
606        );
607        nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
608        // Insert → Normal on Esc.
609        m.bind(
610            Mode::Insert,
611            Key::Esc,
612            Action::ChangeMode(Mode::Normal),
613            "to normal",
614        );
615        // ── Insert-mode editing ───────────────────────────────────────
616        // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
617        // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
618        // table is consulted — so every key below fell through to
619        // `Action::Pending` and did nothing. Insert mode could be typed into
620        // and never corrected: no erase, no caret movement. The keys were not
621        // "unimplemented", they were unbound; `Action::Backspace`'s executor
622        // had been waiting for a caller.
623        m.bind(
624            Mode::Insert,
625            Key::Backspace,
626            Action::Backspace,
627            "erase one char",
628        );
629        m.bind(
630            Mode::Insert,
631            Key::Delete,
632            Action::DeleteForward,
633            "delete char at caret",
634        );
635        // The bigger erases. `<BS>`/`<Del>` landed first and these were left
636        // behind for a day, which made Insert mode able to erase one character
637        // at a time and nothing larger — a mis-typed word had to be dismantled
638        // letter by letter. They share the erase family's routing, so the
639        // binding is the whole change: the runtime already knows whether a
640        // prompt is open.
641        m.bind(
642            Mode::Insert,
643            Key::Ctrl('w'),
644            Action::DeleteWordBefore,
645            "erase word before caret",
646        );
647        m.bind(
648            Mode::Insert,
649            Key::Ctrl('u'),
650            Action::DeleteToLineStart,
651            "erase to line start",
652        );
653        // `<C-h>` IS backspace: terminals send 0x08 for it, and vim treats the
654        // two as one key in Insert. Whether a given terminal reports the
655        // physical Backspace as `Backspace` or as `Ctrl('h')` is the
656        // terminal's business, not the operator's — binding both is what makes
657        // the answer stop mattering.
658        m.bind(
659            Mode::Insert,
660            Key::Ctrl('h'),
661            Action::Backspace,
662            "erase one char",
663        );
664        // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
665        // "edit it" means to anyone who did not grow up on hjkl. They reuse
666        // the ordinary motions, so the cursor-clamp + viewport-follow
667        // invariants come along unchanged.
668        for (key, motion, label) in [
669            (Key::Left, Motion::Left, "caret left"),
670            (Key::Right, Motion::Right, "caret right"),
671            (Key::Up, Motion::Up, "caret up"),
672            (Key::Down, Motion::Down, "caret down"),
673            (Key::Home, Motion::LineStart, "caret to line start"),
674            (Key::End, Motion::LineEnd, "caret to line end"),
675        ] {
676            m.bind(Mode::Insert, key, Action::Move(motion), label);
677        }
678        m.bind(
679            Mode::Command,
680            Key::Esc,
681            Action::ChangeMode(Mode::Normal),
682            "abort",
683        );
684        m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
685        m.bind(
686            Mode::Command,
687            Key::Up,
688            Action::PromptHistory { back: true },
689            "older search",
690        );
691        m.bind(
692            Mode::Command,
693            Key::Down,
694            Action::PromptHistory { back: false },
695            "newer search",
696        );
697        m.bind(
698            Mode::Command,
699            Key::Backspace,
700            Action::Backspace,
701            "erase one char",
702        );
703        m.bind(
704            Mode::Command,
705            Key::Delete,
706            Action::DeleteForward,
707            "delete char at caret",
708        );
709        // Caret editing inside the prompt. Without these the prompt is
710        // append-only, so a typo in the middle of a pattern can only be fixed
711        // by deleting everything back to it.
712        m.bind(
713            Mode::Command,
714            Key::Left,
715            Action::PromptCaret {
716                to: CaretMove::Left,
717            },
718            "caret left",
719        );
720        m.bind(
721            Mode::Command,
722            Key::Right,
723            Action::PromptCaret {
724                to: CaretMove::Right,
725            },
726            "caret right",
727        );
728        m.bind(
729            Mode::Command,
730            Key::Home,
731            Action::PromptCaret {
732                to: CaretMove::Start,
733            },
734            "caret to start",
735        );
736        m.bind(
737            Mode::Command,
738            Key::End,
739            Action::PromptCaret { to: CaretMove::End },
740            "caret to end",
741        );
742        m.bind(
743            Mode::Command,
744            Key::Ctrl('w'),
745            Action::DeleteWordBefore,
746            "delete word before caret",
747        );
748        // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
749        // `/pat<CR>nn`, except Escape still takes you home.
750        m.bind(
751            Mode::Command,
752            Key::Ctrl('g'),
753            Action::SearchPreviewStep { forward: true },
754            "preview next match",
755        );
756        m.bind(
757            Mode::Command,
758            Key::Ctrl('t'),
759            Action::SearchPreviewStep { forward: false },
760            "preview previous match",
761        );
762        m.bind(
763            Mode::Command,
764            Key::Ctrl('u'),
765            Action::DeleteToLineStart,
766            "clear to start",
767        );
768
769        // ── search ────────────────────────────────────────────────────
770        // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
771        // which the runtime routes to the search when a search prompt is open.
772        // That routing is typed (Option<Prompt>), not a mode flag to forget.
773        nm(
774            &mut m,
775            Key::Char('/'),
776            Action::SearchOpen(SearchDirection::Forward),
777            "search forward",
778        );
779        nm(
780            &mut m,
781            Key::Char('?'),
782            Action::SearchOpen(SearchDirection::Backward),
783            "search backward",
784        );
785        // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
786        // `Action::Move` is what makes `dn` / `yN` compose — the
787        // operator-pending machine only recognises `Action::Move` as an
788        // operand. `Action::SearchRepeat` remains a valid action (a user rc or
789        // the tatara-lisp binding table may name it) and the runtime routes it
790        // through the same executor, so there is exactly one code path.
791        nm(
792            &mut m,
793            Key::Char('n'),
794            Action::Move(Motion::SearchNext),
795            "next match",
796        );
797        nm(
798            &mut m,
799            Key::Char('N'),
800            Action::Move(Motion::SearchPrev),
801            "previous match",
802        );
803
804        // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
805        // match and `.` repeats that on the next one.
806        m.bind_sequence(
807            Mode::Normal,
808            vec![Key::Char('g'), Key::Char('n')],
809            Action::TextObject(TextObject::NextMatch),
810            "next match (object)",
811        );
812        m.bind_sequence(
813            Mode::Normal,
814            vec![Key::Char('g'), Key::Char('N')],
815            Action::TextObject(TextObject::PrevMatch),
816            "previous match (object)",
817        );
818
819        // `gJ` — join without the fixup. `J` is LOSSY (it drops the next
820        // line's indent and rewrites the newline as a space), so the escape
821        // hatch has to be a separate verb rather than a flag on the same key.
822        m.bind_sequence(
823            Mode::Normal,
824            vec![Key::Char('g'), Key::Char('J')],
825            Action::JoinLines { space: false },
826            "join lines verbatim",
827        );
828
829        // ── `ff` — REMOVED 2026-08-13, and this is the deciding it was
830        // waiting for ────────────────────────────────────────────────────
831        //
832        // `ff` was blnvim's bare format binding, taken here as a SEQUENCE with
833        // a note saying it cost nothing "because `f` (find-char) is not
834        // implemented", and that when `f` landed it would need deciding.
835        // `f` has landed. It wins: `f` is the character search in every vi
836        // lineage, and it is claimed by the runtime BEFORE the sequence
837        // stepper (its operand is a keystroke, not a binding), so leaving the
838        // sequence here would not have conflicted — it would have been dead
839        // table entry nobody could reach, which is worse than a conflict.
840        //
841        // Nothing is lost: `lsp.format` is the SAME command name the catalog
842        // already binds from `<leader>lf`, `:Format` and a `BufWritePre` hook.
843        // The verb keeps three routes; only this fourth spelling is gone.
844
845        // ── jumplist ──────────────────────────────────────────────────
846        // The return ticket for every far jump above. Without it a committed
847        // search is a one-way door.
848        nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
849        nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
850        nm(
851            &mut m,
852            Key::Char('*'),
853            Action::SearchWord { reverse: false },
854            "search word forward",
855        );
856        nm(
857            &mut m,
858            Key::Char('#'),
859            Action::SearchWord { reverse: true },
860            "search word backward",
861        );
862        m.bind(
863            Mode::Visual,
864            Key::Esc,
865            Action::ChangeMode(Mode::Normal),
866            "to normal",
867        );
868        m.bind(
869            Mode::VisualLine,
870            Key::Esc,
871            Action::ChangeMode(Mode::Normal),
872            "to normal",
873        );
874        m
875    }
876
877    pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
878        let binding = Binding::new(action, desc);
879        self.note_collisions(mode, std::slice::from_ref(&key), &binding);
880        self.bindings.insert((mode, key), binding);
881    }
882
883    /// Record anything about this bind that will surprise its author.
884    ///
885    /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
886    /// costs one hash lookup plus one conversion — a keymap that only tells
887    /// you about collisions when asked is a keymap nobody asks.
888    fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
889        let Some(first) = keys.first() else { return };
890        let spelled = format!("{keys:?}");
891
892        // (1) Does the world own it? For a sequence this is its OPENER —
893        // a sequence whose first key never arrives can never begin.
894        if let Some(hk) = to_hotkey(first) {
895            if let Some(why) = self.reserved.refuse(&hk) {
896                self.collisions.push(Collision::Reserved {
897                    mode,
898                    key: spelled.clone(),
899                    description: binding.description.clone(),
900                    why,
901                });
902            }
903        }
904
905        // (2) Is something already here? `HashMap::insert` returns the old
906        // value and every caller dropped it, so a displaced binding left no
907        // trace at all.
908        let existing = if keys.len() == 1 {
909            self.bindings
910                .get(&(mode, first.clone()))
911                .map(|b| &b.description)
912        } else {
913            self.sequences
914                .get(&(mode, keys.to_vec()))
915                .map(|b| &b.description)
916        };
917        if let Some(replaced) = existing {
918            self.collisions.push(Collision::Displaced {
919                mode,
920                key: spelled,
921                replaced: replaced.clone(),
922                with: binding.description.clone(),
923            });
924        }
925    }
926
927    /// Every collision recorded while this keymap was built.
928    ///
929    /// Read by `--list-rc` and reported at boot. An empty slice is the
930    /// claim "every binding escriba ships can actually fire".
931    #[must_use]
932    pub fn collisions(&self) -> &[Collision] {
933        &self.collisions
934    }
935
936    /// Collisions that mean a key can NEVER fire, as opposed to one that was
937    /// deliberately overridden.
938    pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
939        self.collisions.iter().filter(|c| c.is_fatal())
940    }
941
942    #[must_use]
943    pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
944        self.bindings.get(&(mode, key.clone()))
945    }
946
947    /// The leader key — what `<leader>` resolves to when a sequence
948    /// binding is applied. Defaults to `,` (blnvim parity).
949    #[must_use]
950    pub fn leader(&self) -> &Key {
951        &self.leader
952    }
953
954    /// Override the leader key. Applied before sequence bindings so
955    /// `<leader>`-prefixed specs resolve against the chosen prefix.
956    pub fn set_leader(&mut self, key: Key) {
957        self.leader = key;
958    }
959
960    /// Bind a multi-key SEQUENCE — `<leader>ff` →
961    /// `[Char(','), Char('f'), Char('f')]`, `gg` →
962    /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
963    /// [`bind`](Keymap::bind) so callers never special-case it; an
964    /// empty sequence is a no-op.
965    pub fn bind_sequence(
966        &mut self,
967        mode: Mode,
968        keys: Vec<Key>,
969        action: Action,
970        desc: impl Into<String>,
971    ) {
972        match keys.as_slice() {
973            [] => {}
974            [single] => self.bind(mode, single.clone(), action, desc),
975            _ => {
976                let binding = Binding::new(action, desc);
977                self.note_collisions(mode, &keys, &binding);
978                self.sequences.insert((mode, keys), binding);
979            }
980        }
981    }
982
983    /// Exact-match lookup for a full key sequence.
984    #[must_use]
985    pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
986        self.sequences.get(&(mode, keys.to_vec()))
987    }
988
989    /// Does any bound sequence in `mode` STRICTLY extend `prefix`
990    /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
991    /// Drives the runtime's pending-stroke state: a partial sequence
992    /// that is still a live prefix is held pending rather than
993    /// dispatched. Linear scan — fine at fleet sequence counts; a
994    /// trie is a later optimization if profiling ever asks for it.
995    #[must_use]
996    pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
997        self.sequences
998            .keys()
999            .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
1000    }
1001
1002    /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
1003    ///
1004    /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
1005    /// question with a `bool` and throws away the matches it just found. Two
1006    /// consumers need those matches:
1007    ///
1008    /// - a **which-key popup**, which must show what continues `<leader>`;
1009    /// - a **reserved-chord audit**, which cannot check bindings it cannot
1010    ///   enumerate — and `sequences` is private, so from outside this crate
1011    ///   the multi-key half of the keymap was invisible.
1012    ///
1013    /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
1014    /// a trie is the same later optimization for both.
1015    ///
1016    /// Pass an empty `prefix` for every sequence in the mode.
1017    #[must_use]
1018    pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
1019        let mut v: Vec<(&[Key], &Binding)> = self
1020            .sequences
1021            .iter()
1022            .filter(|((m, seq), _)| {
1023                *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
1024            })
1025            .map(|((_, seq), b)| (seq.as_slice(), b))
1026            .collect();
1027        // Sorted, because a which-key popup in HashMap order is a popup that
1028        // reorders itself between presses.
1029        v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
1030        v
1031    }
1032
1033    /// Count of bound multi-key sequences — for `--keymap` / doctor.
1034    #[must_use]
1035    pub fn sequence_len(&self) -> usize {
1036        self.sequences.len()
1037    }
1038
1039    #[must_use]
1040    pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
1041        let mode = state.mode();
1042        // The preemption rule is stated ONCE, in `preemption` below, and read
1043        // twice: here, to answer the key, and by `escriba-banzuke`, to decide
1044        // whether a DECLARATION for this pair could ever fire. It used to be
1045        // three inline `if mode ==` blocks that only this function could see,
1046        // so an rc binding a bare `j` in Insert parsed, applied, reported as
1047        // applied — and was dead. Nothing could say so, because the fact that
1048        // made it dead lived inside the dispatcher.
1049        match preemption(mode, key) {
1050            Some(Preemption::Always(p)) => return p.resolved(key),
1051            Some(Preemption::WhenCounting(p)) if state.pending_count().is_some() => {
1052                return p.resolved(key);
1053            }
1054            _ => {}
1055        }
1056        if let Some(b) = self.lookup(mode, key) {
1057            return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
1058        }
1059        CountedAction::once(Action::Pending)
1060    }
1061
1062    #[must_use]
1063    pub fn len(&self) -> usize {
1064        self.bindings.len()
1065    }
1066
1067    #[must_use]
1068    pub fn is_empty(&self) -> bool {
1069        self.bindings.is_empty()
1070    }
1071
1072    /// Sorted view over every binding — for `escriba --keymap` and palettes.
1073    #[must_use]
1074    pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
1075        let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
1076        v.sort_by(|a, b| {
1077            (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
1078        });
1079        v
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086
1087    #[test]
1088    fn default_vim_has_bindings() {
1089        let k = Keymap::default_vim();
1090        assert!(k.len() > 10);
1091        assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
1092        assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
1093        assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
1094    }
1095
1096    #[test]
1097    fn dispatch_normal_motion() {
1098        let k = Keymap::default_vim();
1099        let s = ModalState::new();
1100        let a = k.dispatch(&s, &Key::Char('h'));
1101        assert_eq!(a.count, 1);
1102        assert_eq!(a.action, Action::Move(Motion::Left));
1103    }
1104
1105    #[test]
1106    fn dispatch_count_prefix_pends() {
1107        let k = Keymap::default_vim();
1108        let s = ModalState::new();
1109        assert!(matches!(
1110            k.dispatch(&s, &Key::Char('5')).action,
1111            Action::Pending
1112        ));
1113    }
1114
1115    #[test]
1116    fn dispatch_insert_char() {
1117        let k = Keymap::default_vim();
1118        let mut s = ModalState::new();
1119        s.enter(Mode::Insert);
1120        let a = k.dispatch(&s, &Key::Char('a'));
1121        assert_eq!(a.action, Action::InsertChar('a'));
1122    }
1123
1124    #[test]
1125    fn lisp_structural_motions_bound() {
1126        let k = Keymap::default_vim();
1127        assert_eq!(
1128            k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
1129            Action::Move(Motion::ForwardSexp)
1130        );
1131    }
1132
1133    #[test]
1134    fn default_leader_is_comma() {
1135        assert_eq!(Keymap::new().leader(), &Key::Char(','));
1136    }
1137
1138    #[test]
1139    fn bind_sequence_stores_multikey_and_resolves() {
1140        let mut k = Keymap::new();
1141        let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
1142        k.bind_sequence(
1143            Mode::Normal,
1144            seq.clone(),
1145            Action::Command {
1146                name: "picker.files".into(),
1147                args: vec![],
1148            },
1149            "find files",
1150        );
1151        // Exact match resolves.
1152        let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
1153        assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
1154        // Proper prefixes are live; the full sequence is NOT a prefix
1155        // of itself.
1156        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
1157        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
1158        assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
1159        // Wrong mode → not a prefix.
1160        assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
1161        assert_eq!(k.sequence_len(), 1);
1162    }
1163
1164    #[test]
1165    fn bind_sequence_length_one_delegates_to_single() {
1166        let mut k = Keymap::new();
1167        k.bind_sequence(
1168            Mode::Normal,
1169            vec![Key::Char('x')],
1170            Action::Undo,
1171            "x is undo",
1172        );
1173        // Lands in the single-key table, not the sequence table.
1174        assert_eq!(k.sequence_len(), 0);
1175        assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
1176    }
1177}
1178
1179/// escriba's `Key` as the fleet's chord vocabulary.
1180///
1181/// # Why a conversion rather than a migration (yet)
1182///
1183/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
1184/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
1185/// bitflag SET over 116 key variants. The escriba shape therefore cannot
1186/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
1187/// (`escriba-input` discards `KeyCode::F(_)` at the door).
1188///
1189/// Migrating the whole keymap is the destination and it touches 52 call
1190/// sites. This conversion is what lets the **reserved-chord audit** run
1191/// today, before that lands: a binding escriba cannot even ask about is a
1192/// binding that silently dies when the window manager takes its chord.
1193///
1194/// Returns `None` for a key with no fleet spelling — today the shifted
1195/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
1196/// carry. That is the honest answer, and an audit must treat an unmappable
1197/// key as UNAUDITED rather than as available: silently counting it as clean
1198/// is how `Ctrl+Space` stayed hidden.
1199#[must_use]
1200pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
1201    use awase::{Hotkey, Key as AK, Modifiers as M};
1202    // `from_name` takes NAMES ("space"), not literal characters. Spelling a
1203    // space as " " returns None — which is how `Ctrl+Space` slipped past the
1204    // reserved audit while being bound in Insert mode AND owned by the OS.
1205    let named = |c: char| match c {
1206        ' ' => Some(AK::Space),
1207        c => AK::from_name(&c.to_ascii_lowercase().to_string()),
1208    };
1209    Some(match key {
1210        Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
1211        Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
1212        Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
1213        Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
1214        // Already a fleet chord — nothing to convert.
1215        Key::Chord(h) => *h,
1216        Key::Esc => Hotkey::new(M::NONE, AK::Escape),
1217        Key::Enter => Hotkey::new(M::NONE, AK::Return),
1218        Key::Tab => Hotkey::new(M::NONE, AK::Tab),
1219        Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
1220        Key::Delete => Hotkey::new(M::NONE, AK::Delete),
1221        Key::Left => Hotkey::new(M::NONE, AK::Left),
1222        Key::Right => Hotkey::new(M::NONE, AK::Right),
1223        Key::Up => Hotkey::new(M::NONE, AK::Up),
1224        Key::Down => Hotkey::new(M::NONE, AK::Down),
1225        Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
1226        Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
1227        Key::Home => Hotkey::new(M::NONE, AK::Home),
1228        Key::End => Hotkey::new(M::NONE, AK::End),
1229    })
1230}
1231
1232#[cfg(test)]
1233mod fleet_vocabulary {
1234    use super::*;
1235
1236    #[test]
1237    fn modifiers_survive_the_conversion() {
1238        let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
1239        assert!(h.modifiers.contains(awase::Modifiers::CTRL));
1240        assert_eq!(h.key, awase::Key::W);
1241    }
1242
1243    #[test]
1244    fn named_keys_map_to_their_fleet_spelling() {
1245        // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
1246        // fleet atlas already warns that these two spellings diverge across
1247        // consumers, which is exactly what a shared vocabulary settles.
1248        assert_eq!(
1249            to_hotkey(&Key::Esc).map(|h| h.key),
1250            Some(awase::Key::Escape)
1251        );
1252        assert_eq!(
1253            to_hotkey(&Key::Enter).map(|h| h.key),
1254            Some(awase::Key::Return)
1255        );
1256    }
1257
1258    #[test]
1259    fn every_variant_of_escribas_key_has_a_fleet_spelling() {
1260        // If one did not, the reserved audit would have a blind spot exactly
1261        // where escriba's vocabulary is unusual — which is where a collision
1262        // is most likely.
1263        let all = [
1264            Key::Char('a'),
1265            Key::Ctrl('a'),
1266            Key::Alt('a'),
1267            Key::Esc,
1268            Key::Enter,
1269            Key::Tab,
1270            Key::Backspace,
1271            Key::Delete,
1272            Key::Left,
1273            Key::Right,
1274            Key::Up,
1275            Key::Down,
1276            Key::PageUp,
1277            Key::PageDown,
1278            Key::Home,
1279            Key::End,
1280        ];
1281        for k in all {
1282            assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
1283        }
1284    }
1285
1286    #[test]
1287    fn sequences_can_now_be_enumerated() {
1288        // `sequences` was private with no accessor, so the multi-key half of
1289        // the keymap was invisible from outside this crate — unauditable and
1290        // un-displayable.
1291        let k = Keymap::default_vim();
1292        let all = k.sequences_extending(Mode::Normal, &[]);
1293        assert!(!all.is_empty(), "the default keymap binds sequences");
1294        let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
1295        assert!(
1296            g.iter()
1297                .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
1298            "a prefix query returns only its own continuations",
1299        );
1300    }
1301}
1302
1303#[cfg(test)]
1304mod collision_detection {
1305    use super::*;
1306
1307    #[test]
1308    fn a_reserved_chord_is_recorded_at_bind_time() {
1309        // Not discovered later by an audit — known the moment it is written.
1310        let mut m = Keymap::new();
1311        m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
1312        let c = m.collisions();
1313        assert_eq!(c.len(), 1, "{c:?}");
1314        assert!(c[0].is_fatal(), "a chord the world owns can never fire");
1315        assert!(
1316            c[0].report().contains("window manager"),
1317            "{}",
1318            c[0].report()
1319        );
1320    }
1321
1322    #[test]
1323    fn a_displaced_binding_is_recorded_but_not_fatal() {
1324        // Overriding is sometimes intended — the shipped rc deliberately
1325        // overrides defaults — so it is REPORTED, never refused. But "my
1326        // plugin's key stopped working" has no other explanation available.
1327        let mut m = Keymap::new();
1328        m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
1329        m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
1330        let c = m.collisions();
1331        assert_eq!(c.len(), 1);
1332        assert!(!c[0].is_fatal());
1333        let r = c[0].report();
1334        assert!(r.contains("first") && r.contains("second"), "{r}");
1335    }
1336
1337    #[test]
1338    // OPENER is shouted because which key is checked IS the point.
1339    #[allow(non_snake_case)]
1340    fn a_sequence_whose_OPENER_is_reserved_is_caught() {
1341        // `alt-j` then anything can never begin, because the first key never
1342        // arrives. Checking only single keys would miss the whole sequence.
1343        let mut m = Keymap::new();
1344        m.bind_sequence(
1345            Mode::Normal,
1346            vec![Key::Alt('j'), Key::Char('x')],
1347            Action::Undo,
1348            "dead sequence",
1349        );
1350        assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
1351    }
1352
1353    #[test]
1354    fn an_ordinary_keymap_records_nothing() {
1355        // The detector must be quiet when there is nothing to say, or it
1356        // becomes noise an operator learns to skip.
1357        let mut m = Keymap::new();
1358        m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
1359        m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
1360        assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1361    }
1362
1363    #[test]
1364    fn the_shipped_default_keymap_is_clean() {
1365        let m = Keymap::default_vim();
1366        assert!(
1367            m.collisions().is_empty(),
1368            "escriba's own defaults must not collide:\n  {}",
1369            m.collisions()
1370                .iter()
1371                .map(Collision::report)
1372                .collect::<Vec<_>>()
1373                .join("\n  "),
1374        );
1375    }
1376}