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::{Action, CountedAction, Mode, Motion, Operator, TextObject};
9use escriba_mode::ModalState;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum Key {
14    Char(char),
15    Esc,
16    Enter,
17    Tab,
18    Backspace,
19    Delete,
20    Left,
21    Right,
22    Up,
23    Down,
24    PageUp,
25    PageDown,
26    Home,
27    End,
28    Ctrl(char),
29    Alt(char),
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Binding {
34    pub action: Action,
35    pub description: String,
36}
37
38impl Binding {
39    #[must_use]
40    pub fn new(action: Action, description: impl Into<String>) -> Self {
41        Self {
42            action,
43            description: description.into(),
44        }
45    }
46}
47
48#[derive(Debug, Clone)]
49pub struct Keymap {
50    bindings: HashMap<(Mode, Key), Binding>,
51    /// Multi-key sequence bindings (`<leader>ff`, `gg`, `<C-w>h`).
52    /// Keyed by the full key sequence; resolved by the runtime's
53    /// pending-stroke loop ([`lookup_sequence`](Keymap::lookup_sequence)
54    /// + [`is_sequence_prefix`](Keymap::is_sequence_prefix)).
55    sequences: HashMap<(Mode, Vec<Key>), Binding>,
56    /// The prefix `<leader>` resolves to at sequence-apply time.
57    leader: Key,
58}
59
60impl Default for Keymap {
61    fn default() -> Self {
62        Self {
63            bindings: HashMap::new(),
64            sequences: HashMap::new(),
65            // blnvim's leader is comma; escriba ships blnvim-parity
66            // defaults, so the prefix users press matches muscle memory.
67            leader: Key::Char(','),
68        }
69    }
70}
71
72impl Keymap {
73    #[must_use]
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    #[must_use]
79    pub fn default_vim() -> Self {
80        let mut m = Self::new();
81        let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
82        nm(
83            &mut m,
84            Key::Char('h'),
85            Action::Move(Motion::Left),
86            "move left",
87        );
88        nm(
89            &mut m,
90            Key::Char('l'),
91            Action::Move(Motion::Right),
92            "move right",
93        );
94        nm(
95            &mut m,
96            Key::Char('j'),
97            Action::Move(Motion::Down),
98            "move down",
99        );
100        nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
101        nm(
102            &mut m,
103            Key::Char('w'),
104            Action::Move(Motion::WordStartNext),
105            "word forward",
106        );
107        nm(
108            &mut m,
109            Key::Char('b'),
110            Action::Move(Motion::WordStartPrev),
111            "word back",
112        );
113        nm(
114            &mut m,
115            Key::Char('0'),
116            Action::Move(Motion::LineStart),
117            "line start",
118        );
119        nm(
120            &mut m,
121            Key::Char('$'),
122            Action::Move(Motion::LineEnd),
123            "line end",
124        );
125        nm(
126            &mut m,
127            Key::Char('G'),
128            Action::Move(Motion::DocEnd),
129            "doc end",
130        );
131        // Operators — `d`/`c`/`y` arm the operator-pending FSM; the next
132        // motion composes (e.g. `dw`, `c$`, `y0`).
133        nm(
134            &mut m,
135            Key::Char('d'),
136            Action::Operator(Operator::Delete),
137            "delete (operator)",
138        );
139        nm(
140            &mut m,
141            Key::Char('c'),
142            Action::Operator(Operator::Change),
143            "change (operator)",
144        );
145        nm(
146            &mut m,
147            Key::Char('y'),
148            Action::Operator(Operator::Yank),
149            "yank (operator)",
150        );
151        // Structural Lisp motions — Alt-prefixed like emacs paredit.
152        nm(
153            &mut m,
154            Key::Alt('f'),
155            Action::Move(Motion::ForwardSexp),
156            "forward sexp",
157        );
158        nm(
159            &mut m,
160            Key::Alt('b'),
161            Action::Move(Motion::BackwardSexp),
162            "backward sexp",
163        );
164        nm(
165            &mut m,
166            Key::Alt('u'),
167            Action::Move(Motion::UpList),
168            "up list",
169        );
170        nm(
171            &mut m,
172            Key::Alt('d'),
173            Action::Move(Motion::DownList),
174            "down list",
175        );
176        // Mode changes.
177        nm(
178            &mut m,
179            Key::Char('i'),
180            Action::ChangeMode(Mode::Insert),
181            "insert",
182        );
183        nm(
184            &mut m,
185            Key::Char('v'),
186            Action::ChangeMode(Mode::Visual),
187            "visual",
188        );
189        nm(
190            &mut m,
191            Key::Char('V'),
192            Action::ChangeMode(Mode::VisualLine),
193            "visual line",
194        );
195        nm(
196            &mut m,
197            Key::Char(':'),
198            Action::ChangeMode(Mode::Command),
199            "command",
200        );
201        nm(&mut m, Key::Char('u'), Action::Undo, "undo");
202        nm(
203            &mut m,
204            Key::Char('.'),
205            Action::RepeatLastChange,
206            "repeat last change",
207        );
208        nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
209        // Insert → Normal on Esc.
210        m.bind(
211            Mode::Insert,
212            Key::Esc,
213            Action::ChangeMode(Mode::Normal),
214            "to normal",
215        );
216        m.bind(
217            Mode::Command,
218            Key::Esc,
219            Action::ChangeMode(Mode::Normal),
220            "abort",
221        );
222        m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
223        m.bind(
224            Mode::Command,
225            Key::Up,
226            Action::PromptHistory { back: true },
227            "older search",
228        );
229        m.bind(
230            Mode::Command,
231            Key::Down,
232            Action::PromptHistory { back: false },
233            "newer search",
234        );
235        m.bind(
236            Mode::Command,
237            Key::Backspace,
238            Action::PromptBackspace,
239            "erase one char",
240        );
241        m.bind(
242            Mode::Command,
243            Key::Delete,
244            Action::PromptDelete,
245            "delete char at caret",
246        );
247        // Caret editing inside the prompt. Without these the prompt is
248        // append-only, so a typo in the middle of a pattern can only be fixed
249        // by deleting everything back to it.
250        m.bind(
251            Mode::Command,
252            Key::Left,
253            Action::PromptCaret {
254                to: CaretMove::Left,
255            },
256            "caret left",
257        );
258        m.bind(
259            Mode::Command,
260            Key::Right,
261            Action::PromptCaret {
262                to: CaretMove::Right,
263            },
264            "caret right",
265        );
266        m.bind(
267            Mode::Command,
268            Key::Home,
269            Action::PromptCaret {
270                to: CaretMove::Start,
271            },
272            "caret to start",
273        );
274        m.bind(
275            Mode::Command,
276            Key::End,
277            Action::PromptCaret { to: CaretMove::End },
278            "caret to end",
279        );
280        m.bind(
281            Mode::Command,
282            Key::Ctrl('w'),
283            Action::PromptDeleteWord,
284            "delete word before caret",
285        );
286        // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
287        // `/pat<CR>nn`, except Escape still takes you home.
288        m.bind(
289            Mode::Command,
290            Key::Ctrl('g'),
291            Action::SearchPreviewStep { forward: true },
292            "preview next match",
293        );
294        m.bind(
295            Mode::Command,
296            Key::Ctrl('t'),
297            Action::SearchPreviewStep { forward: false },
298            "preview previous match",
299        );
300        m.bind(
301            Mode::Command,
302            Key::Ctrl('u'),
303            Action::PromptClearToStart,
304            "clear to start",
305        );
306
307        // ── search ────────────────────────────────────────────────────
308        // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
309        // which the runtime routes to the search when a search prompt is open.
310        // That routing is typed (Option<Prompt>), not a mode flag to forget.
311        nm(
312            &mut m,
313            Key::Char('/'),
314            Action::SearchOpen(SearchDirection::Forward),
315            "search forward",
316        );
317        nm(
318            &mut m,
319            Key::Char('?'),
320            Action::SearchOpen(SearchDirection::Backward),
321            "search backward",
322        );
323        // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
324        // `Action::Move` is what makes `dn` / `yN` compose — the
325        // operator-pending machine only recognises `Action::Move` as an
326        // operand. `Action::SearchRepeat` remains a valid action (a user rc or
327        // the tatara-lisp binding table may name it) and the runtime routes it
328        // through the same executor, so there is exactly one code path.
329        nm(
330            &mut m,
331            Key::Char('n'),
332            Action::Move(Motion::SearchNext),
333            "next match",
334        );
335        nm(
336            &mut m,
337            Key::Char('N'),
338            Action::Move(Motion::SearchPrev),
339            "previous match",
340        );
341
342        // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
343        // match and `.` repeats that on the next one.
344        m.bind_sequence(
345            Mode::Normal,
346            vec![Key::Char('g'), Key::Char('n')],
347            Action::TextObject(TextObject::NextMatch),
348            "next match (object)",
349        );
350        m.bind_sequence(
351            Mode::Normal,
352            vec![Key::Char('g'), Key::Char('N')],
353            Action::TextObject(TextObject::PrevMatch),
354            "previous match (object)",
355        );
356
357        // ── jumplist ──────────────────────────────────────────────────
358        // The return ticket for every far jump above. Without it a committed
359        // search is a one-way door.
360        nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
361        nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
362        nm(
363            &mut m,
364            Key::Char('*'),
365            Action::SearchWord { reverse: false },
366            "search word forward",
367        );
368        nm(
369            &mut m,
370            Key::Char('#'),
371            Action::SearchWord { reverse: true },
372            "search word backward",
373        );
374        m.bind(
375            Mode::Visual,
376            Key::Esc,
377            Action::ChangeMode(Mode::Normal),
378            "to normal",
379        );
380        m.bind(
381            Mode::VisualLine,
382            Key::Esc,
383            Action::ChangeMode(Mode::Normal),
384            "to normal",
385        );
386        m
387    }
388
389    pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
390        self.bindings
391            .insert((mode, key), Binding::new(action, desc));
392    }
393
394    #[must_use]
395    pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
396        self.bindings.get(&(mode, key.clone()))
397    }
398
399    /// The leader key — what `<leader>` resolves to when a sequence
400    /// binding is applied. Defaults to `,` (blnvim parity).
401    #[must_use]
402    pub fn leader(&self) -> &Key {
403        &self.leader
404    }
405
406    /// Override the leader key. Applied before sequence bindings so
407    /// `<leader>`-prefixed specs resolve against the chosen prefix.
408    pub fn set_leader(&mut self, key: Key) {
409        self.leader = key;
410    }
411
412    /// Bind a multi-key SEQUENCE — `<leader>ff` →
413    /// `[Char(','), Char('f'), Char('f')]`, `gg` →
414    /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
415    /// [`bind`](Keymap::bind) so callers never special-case it; an
416    /// empty sequence is a no-op.
417    pub fn bind_sequence(
418        &mut self,
419        mode: Mode,
420        keys: Vec<Key>,
421        action: Action,
422        desc: impl Into<String>,
423    ) {
424        match keys.as_slice() {
425            [] => {}
426            [single] => self.bind(mode, single.clone(), action, desc),
427            _ => {
428                self.sequences
429                    .insert((mode, keys), Binding::new(action, desc));
430            }
431        }
432    }
433
434    /// Exact-match lookup for a full key sequence.
435    #[must_use]
436    pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
437        self.sequences.get(&(mode, keys.to_vec()))
438    }
439
440    /// Does any bound sequence in `mode` STRICTLY extend `prefix`
441    /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
442    /// Drives the runtime's pending-stroke state: a partial sequence
443    /// that is still a live prefix is held pending rather than
444    /// dispatched. Linear scan — fine at fleet sequence counts; a
445    /// trie is a later optimization if profiling ever asks for it.
446    #[must_use]
447    pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
448        self.sequences
449            .keys()
450            .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
451    }
452
453    /// Count of bound multi-key sequences — for `--keymap` / doctor.
454    #[must_use]
455    pub fn sequence_len(&self) -> usize {
456        self.sequences.len()
457    }
458
459    #[must_use]
460    pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
461        let mode = state.mode();
462        if mode == Mode::Normal {
463            if let Key::Char(c) = key {
464                if c.is_ascii_digit() && *c != '0' {
465                    return CountedAction::once(Action::Pending);
466                }
467                if *c == '0' && state.pending_count().is_some() {
468                    return CountedAction::once(Action::Pending);
469                }
470            }
471        }
472        if mode == Mode::Insert {
473            if let Key::Char(c) = key {
474                return CountedAction::once(Action::InsertChar(*c));
475            }
476            if matches!(key, Key::Enter) {
477                return CountedAction::once(Action::InsertChar('\n'));
478            }
479        }
480        if mode == Mode::Command {
481            if let Key::Char(c) = key {
482                return CountedAction::once(Action::InsertChar(*c));
483            }
484        }
485        if let Some(b) = self.lookup(mode, key) {
486            return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
487        }
488        CountedAction::once(Action::Pending)
489    }
490
491    #[must_use]
492    pub fn len(&self) -> usize {
493        self.bindings.len()
494    }
495
496    #[must_use]
497    pub fn is_empty(&self) -> bool {
498        self.bindings.is_empty()
499    }
500
501    /// Sorted view over every binding — for `escriba --keymap` and palettes.
502    #[must_use]
503    pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
504        let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
505        v.sort_by(|a, b| {
506            (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
507        });
508        v
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn default_vim_has_bindings() {
518        let k = Keymap::default_vim();
519        assert!(k.len() > 10);
520        assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
521        assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
522        assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
523    }
524
525    #[test]
526    fn dispatch_normal_motion() {
527        let k = Keymap::default_vim();
528        let s = ModalState::new();
529        let a = k.dispatch(&s, &Key::Char('h'));
530        assert_eq!(a.count, 1);
531        assert_eq!(a.action, Action::Move(Motion::Left));
532    }
533
534    #[test]
535    fn dispatch_count_prefix_pends() {
536        let k = Keymap::default_vim();
537        let s = ModalState::new();
538        assert!(matches!(
539            k.dispatch(&s, &Key::Char('5')).action,
540            Action::Pending
541        ));
542    }
543
544    #[test]
545    fn dispatch_insert_char() {
546        let k = Keymap::default_vim();
547        let mut s = ModalState::new();
548        s.enter(Mode::Insert);
549        let a = k.dispatch(&s, &Key::Char('a'));
550        assert_eq!(a.action, Action::InsertChar('a'));
551    }
552
553    #[test]
554    fn lisp_structural_motions_bound() {
555        let k = Keymap::default_vim();
556        assert_eq!(
557            k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
558            Action::Move(Motion::ForwardSexp)
559        );
560    }
561
562    #[test]
563    fn default_leader_is_comma() {
564        assert_eq!(Keymap::new().leader(), &Key::Char(','));
565    }
566
567    #[test]
568    fn bind_sequence_stores_multikey_and_resolves() {
569        let mut k = Keymap::new();
570        let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
571        k.bind_sequence(
572            Mode::Normal,
573            seq.clone(),
574            Action::Command {
575                name: "picker.files".into(),
576                args: vec![],
577            },
578            "find files",
579        );
580        // Exact match resolves.
581        let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
582        assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
583        // Proper prefixes are live; the full sequence is NOT a prefix
584        // of itself.
585        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
586        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
587        assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
588        // Wrong mode → not a prefix.
589        assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
590        assert_eq!(k.sequence_len(), 1);
591    }
592
593    #[test]
594    fn bind_sequence_length_one_delegates_to_single() {
595        let mut k = Keymap::new();
596        k.bind_sequence(
597            Mode::Normal,
598            vec![Key::Char('x')],
599            Action::Undo,
600            "x is undo",
601        );
602        // Lands in the single-key table, not the sequence table.
603        assert_eq!(k.sequence_len(), 0);
604        assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
605    }
606}