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