Skip to main content

tmprl_core/
keymap.rs

1//! Key resolution: chords in, command ids out.
2//!
3//! Three behaviours here are worth more than they look:
4//!
5//! * **Counts.** `7j` means seven, and it composes with any motion, because the count is
6//!   accumulated by the resolver rather than by each command.
7//! * **Prefixes.** An incomplete sequence resolves to [`Resolution::Pending`] carrying the
8//!   keys that would complete it. That list is exactly what the which-key popup draws, so
9//!   the popup can never disagree with the keymap.
10//! * **Flushing.** An unmatched sequence returns the chords it swallowed. That is what lets
11//!   `jk` leave Insert mode without eating a literal `j` typed before some other letter.
12
13use crate::key::{Chord, ChordSeq, Key, KeyParseError};
14use crate::mode::Mode;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Binding {
18    pub mode: Mode,
19    pub seq: ChordSeq,
20    pub command: &'static str,
21}
22
23/// A key that could come next, for the which-key popup.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PendingEntry {
26    pub next: Chord,
27    /// `Some` when this key completes a binding, `None` when it only opens a deeper prefix.
28    pub command: Option<&'static str>,
29    /// How many bindings live under this key. `> 1` means it is a group.
30    pub bindings: usize,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Resolution {
35    /// A digit was consumed into the count. Nothing to run yet.
36    Count(u32),
37    /// A prefix matched. `candidates` is what could come next.
38    Pending { candidates: Vec<PendingEntry> },
39    /// A binding matched.
40    Run {
41        id: &'static str,
42        count: Option<u32>,
43    },
44    /// Nothing matched. `flushed` is every chord that was held, including this one, so the
45    /// caller can treat them as literal input.
46    Unbound { flushed: Vec<Chord> },
47}
48
49/// Keys held while waiting for a sequence to complete.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct Pending {
52    pub count: Option<u32>,
53    pub chords: Vec<Chord>,
54}
55
56impl Pending {
57    pub fn clear(&mut self) {
58        self.count = None;
59        self.chords.clear();
60    }
61    pub fn is_idle(&self) -> bool {
62        self.count.is_none() && self.chords.is_empty()
63    }
64    /// What the statusline shows in the bottom right, like vim's pending-command indicator.
65    pub fn display(&self) -> String {
66        let mut s = String::new();
67        if let Some(c) = self.count {
68            s.push_str(&c.to_string());
69        }
70        for ch in &self.chords {
71            s.push_str(&ch.to_string());
72        }
73        s
74    }
75}
76
77pub struct Keymap {
78    bindings: Vec<Binding>,
79    leader: Chord,
80}
81
82/// Counts are capped so that a leaned-on digit key cannot ask for a motion of four billion.
83const MAX_COUNT: u32 = 100_000;
84
85impl Keymap {
86    pub fn new(leader: Chord) -> Self {
87        Self {
88            bindings: Vec::new(),
89            leader,
90        }
91    }
92
93    pub fn bind(
94        &mut self,
95        mode: Mode,
96        seq: &str,
97        command: &'static str,
98    ) -> Result<(), KeyParseError> {
99        let seq = ChordSeq::parse(seq, self.leader)?;
100        // Last binding wins, so a user keymap can override a default.
101        self.bindings.retain(|b| !(b.mode == mode && b.seq == seq));
102        self.bindings.push(Binding { mode, seq, command });
103        Ok(())
104    }
105
106    pub fn leader(&self) -> Chord {
107        self.leader
108    }
109    pub fn bindings(&self) -> &[Binding] {
110        &self.bindings
111    }
112
113    /// Every chord sequence bound to a command, for the help overlay.
114    pub fn keys_for(&self, command: &str) -> Vec<&Binding> {
115        self.bindings
116            .iter()
117            .filter(|b| b.command == command)
118            .collect()
119    }
120
121    pub fn resolve(&self, mode: Mode, pending: &mut Pending, chord: Chord) -> Resolution {
122        // A digit starts or extends a count, but only when no sequence is in flight,
123        // otherwise `<leader>1` could never be bound.
124        if mode.takes_counts()
125            && pending.chords.is_empty()
126            && let Key::Char(c) = chord.key
127            && chord.mods.is_none()
128            && let Some(d) = c.to_digit(10)
129            && !(d == 0 && pending.count.is_none())
130        {
131            let next = pending
132                .count
133                .unwrap_or(0)
134                .saturating_mul(10)
135                .saturating_add(d);
136            pending.count = Some(next.min(MAX_COUNT));
137            return Resolution::Count(pending.count.unwrap());
138        }
139
140        pending.chords.push(chord);
141
142        if let Some(b) = self
143            .bindings
144            .iter()
145            .find(|b| b.mode == mode && b.seq.0 == pending.chords)
146        {
147            let count = pending.count;
148            pending.clear();
149            return Resolution::Run {
150                id: b.command,
151                count,
152            };
153        }
154
155        let depth = pending.chords.len();
156        let mut candidates: Vec<PendingEntry> = Vec::new();
157        for b in &self.bindings {
158            if b.mode != mode || b.seq.len() <= depth || !b.seq.starts_with(&pending.chords) {
159                continue;
160            }
161            let next = b.seq.0[depth];
162            let completes = b.seq.len() == depth + 1;
163            match candidates.iter_mut().find(|e| e.next == next) {
164                Some(e) => {
165                    e.bindings += 1;
166                    if completes {
167                        e.command = Some(b.command);
168                    }
169                }
170                None => candidates.push(PendingEntry {
171                    next,
172                    command: completes.then_some(b.command),
173                    bindings: 1,
174                }),
175            }
176        }
177
178        if !candidates.is_empty() {
179            candidates.sort_by_key(|e| e.next);
180            return Resolution::Pending { candidates };
181        }
182
183        let flushed = std::mem::take(&mut pending.chords);
184        pending.count = None;
185        Resolution::Unbound { flushed }
186    }
187}
188
189/// The default keymap.
190///
191/// Only bindings whose commands actually do something are registered. Binding a key to a
192/// feature that is not built yet would make the which-key popup advertise things that do
193/// nothing, which is worse than an empty keymap.
194///
195/// `C-h/j/k/l` are deliberately absent. See `docs/INTERFACE.md`.
196pub fn default_keymap() -> Keymap {
197    let mut m = Keymap::new(Chord::ch(' '));
198    let mut bind = |mode, seq, cmd| {
199        m.bind(mode, seq, cmd)
200            .unwrap_or_else(|e| panic!("bad default binding `{seq}`: {e}"));
201    };
202
203    for mode in [Mode::Normal, Mode::Visual, Mode::VisualLine] {
204        bind(mode, "j", "motion.down");
205        bind(mode, "k", "motion.up");
206        bind(mode, "<Down>", "motion.down");
207        bind(mode, "<Up>", "motion.up");
208        bind(mode, "gg", "motion.top");
209        bind(mode, "G", "motion.bottom");
210        bind(mode, "<C-d>", "motion.half-down");
211        bind(mode, "<C-u>", "motion.half-up");
212        bind(mode, "y", "yank.field");
213        bind(mode, "Y", "yank.record");
214        bind(mode, "<Esc>", "app.cancel");
215        bind(mode, ":", "app.command-line");
216        bind(mode, "?", "app.help");
217        bind(mode, "R", "app.refresh");
218        bind(mode, "<leader>q", "app.quit");
219        bind(mode, "<C-c>", "app.quit");
220    }
221
222    // `<CR>` opens in the visual modes too, where it means "open the selection": that is
223    // how several namespaces become one merged workflow list. `-` stays Normal-only,
224    // walking up a level while selecting rows has no sensible meaning.
225    for mode in [Mode::Normal, Mode::Visual, Mode::VisualLine] {
226        bind(mode, "<CR>", "nav.open");
227    }
228    bind(Mode::Normal, "-", "nav.up");
229    // The jumplist. `<C-o>` and `<C-i>` are free here: neither is one of the four
230    // chords tmux's pane navigation takes.
231    //
232    // `<Tab>` is bound alongside `<C-i>` because on a terminal they are the *same key*:
233    // Ctrl+I is byte 0x09, which is what Tab sends, and crossterm reports it as
234    // `KeyCode::Tab`. Binding only `<C-i>` gives a jump-forward that never fires outside
235    // the few terminals speaking the Kitty keyboard protocol. Terminal vim has the same
236    // collision and resolves it the same way.
237    bind(Mode::Normal, "<C-o>", "nav.jump-back");
238    bind(Mode::Normal, "<C-i>", "nav.jump-forward");
239    bind(Mode::Normal, "<Tab>", "nav.jump-forward");
240    // `g` is vim's goto prefix, so `gs` and `gw` switch between the two lists a namespace
241    // holds.
242    bind(Mode::Normal, "gs", "nav.schedules");
243    bind(Mode::Normal, "gw", "nav.workflows");
244
245    // Folds use vim's `z` family, so the which-key popup on `z` reads like vim's does.
246    // `zp` is not a vim binding, but it sits in the same namespace as the folds it
247    // resembles: it folds away the workflow-task plumbing.
248    for mode in [Mode::Normal, Mode::Visual, Mode::VisualLine] {
249        bind(mode, "za", "history.fold");
250        bind(mode, "zR", "history.expand-all");
251        bind(mode, "zM", "history.collapse-all");
252        bind(mode, "zp", "history.plumbing");
253        // vim-unimpaired's bracket motions: `]f` / `[f` for the next and previous failure.
254        bind(mode, "]f", "history.next-failure");
255        bind(mode, "[f", "history.prev-failure");
256        bind(mode, "F", "history.follow");
257        // `K` is vim's "look up what is under the cursor", which is exactly what the detail
258        // pane does, it shows the payloads of the focused event or group.
259        bind(mode, "K", "history.detail");
260        // vim scrolls a window by a line with <C-e>/<C-y>; here they scroll the payload
261        // pane, which is the only thing on screen tall enough to need it.
262        bind(mode, "<C-e>", "history.detail-down");
263        bind(mode, "<C-y>", "history.detail-up");
264        // vim's filter operator. Here it filters the focused payloads rather than lines.
265        bind(mode, "!", "payload.pipe");
266        // Payload yanks live under a `<leader>y` prefix rather than on `y` itself: an exact
267        // match wins over a prefix in `resolve`, so binding `<leader>y` as well would make
268        // these three unreachable. The prefix also puts them in the which-key popup.
269        bind(mode, "<leader>ya", "yank.payload");
270        bind(mode, "<leader>yi", "yank.payload-input");
271        bind(mode, "<leader>yr", "yank.payload-result");
272
273        // Windows and tabs, with vim's bindings. `<C-w>` prefixes focus movement, never
274        // bare `<C-h/j/k/l>`, which tmux's vim-tmux-navigator swallows before any
275        // application sees them.
276        bind(mode, "<leader>sv", "window.split-right");
277        bind(mode, "<leader>sh", "window.split-down");
278        bind(mode, "<leader>sx", "window.close");
279        bind(mode, "<leader>se", "window.equalize");
280        bind(mode, "<C-w>h", "window.focus-left");
281        bind(mode, "<C-w>j", "window.focus-down");
282        bind(mode, "<C-w>k", "window.focus-up");
283        bind(mode, "<C-w>l", "window.focus-right");
284        bind(mode, "<leader>rh", "window.grow-left");
285        bind(mode, "<leader>rj", "window.grow-down");
286        bind(mode, "<leader>rk", "window.grow-up");
287        bind(mode, "<leader>rl", "window.grow-right");
288        // Mutations live under `<leader>m`, for "mutate", and clear of bare `m`, which
289        // marks reserve. Every one of them opens a confirmation rather than acting.
290        bind(mode, "<leader>mc", "workflow.cancel");
291        bind(mode, "<leader>mt", "workflow.terminate");
292        bind(mode, "<leader>ms", "workflow.signal");
293        bind(mode, "<leader>md", "workflow.delete");
294        bind(mode, "<leader>mr", "workflow.reset");
295        bind(mode, "<leader>mu", "workflow.update");
296        bind(mode, "<leader>mp", "schedule.pause");
297        bind(mode, "<leader>mg", "schedule.trigger");
298        bind(mode, "<leader>mD", "schedule.delete");
299        bind(mode, "<leader>mb", "schedule.backfill");
300        bind(mode, "<leader>mn", "schedule.create");
301
302        bind(mode, "<leader>to", "tab.new");
303        bind(mode, "<leader>tx", "tab.close");
304        bind(mode, "<leader>tn", "tab.next");
305        bind(mode, "<leader>tp", "tab.previous");
306    }
307
308    // `/` `n` `N` as vim has them. Reverse-open (`?`) is not bound: `?` is the help
309    // overlay here, and help is reached far more often than a backwards search is started.
310    // `N` still walks backwards, which is the part that matters.
311    bind(Mode::Normal, "/", "search.open");
312    bind(Mode::Normal, "n", "search.next");
313    bind(Mode::Normal, "N", "search.previous");
314
315    // The `<leader>f` family, Telescope's `f` for find. Each is the same picker over a
316    // different list, which is why they share a prefix rather than being spread across the
317    // keyboard by what they happen to search.
318    bind(Mode::Normal, "<leader>ff", "find.workflow");
319    bind(Mode::Normal, "<leader>fl", "find.event");
320    bind(Mode::Normal, "<leader>fb", "find.pane");
321    bind(Mode::Normal, "<leader>fh", "find.command");
322    bind(Mode::Normal, "<leader>fg", "find.filter");
323    // `<leader>N` sits outside the `f` family on purpose: switching namespace is changing
324    // *where you are*, not finding something inside where you already are.
325    bind(Mode::Normal, "<leader>N", "find.namespace");
326    bind(Mode::Normal, "<leader>xx", "list.problems");
327    bind(Mode::Normal, "<leader>e", "payload.edit");
328
329    bind(Mode::Normal, "i", "mode.insert");
330    bind(Mode::Normal, "v", "mode.visual");
331    bind(Mode::Normal, "V", "mode.visual-line");
332
333    // `jk` is the escape hatch; `<Esc>` works too.
334    bind(Mode::Insert, "jk", "mode.normal");
335    bind(Mode::Insert, "<Esc>", "mode.normal");
336
337    m
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn map() -> Keymap {
345        default_keymap()
346    }
347
348    fn feed(m: &Keymap, mode: Mode, p: &mut Pending, keys: &[Chord]) -> Resolution {
349        let mut last = Resolution::Unbound { flushed: vec![] };
350        for &c in keys {
351            last = m.resolve(mode, p, c);
352        }
353        last
354    }
355
356    #[test]
357    fn every_default_binding_names_a_command_that_exists() {
358        // `Keymap::bind` validates the *chord* and panics on a bad one, but it has no
359        // registry to check the command id against, so a typo in a default binding is a key
360        // that silently does nothing. `keys.toml` is checked at load; this is the same
361        // guarantee for the built-in map.
362        let registry = crate::command::Registry::builtin();
363        let map = map();
364        let missing: Vec<&str> = map
365            .bindings()
366            .iter()
367            .map(|b| b.command)
368            .filter(|id| registry.get(id).is_none())
369            .collect();
370        assert!(missing.is_empty(), "bound to nothing: {missing:?}");
371    }
372
373    #[test]
374    fn no_two_default_bindings_claim_the_same_keys_in_one_mode() {
375        // A duplicate is not an error the keymap can raise, the later one simply wins, so
376        // the earlier binding vanishes without a word.
377        let map = map();
378        let mut seen: Vec<(Mode, &ChordSeq)> = Vec::new();
379        let mut clashes = Vec::new();
380        for b in map.bindings() {
381            if seen.iter().any(|(m, s)| *m == b.mode && *s == &b.seq) {
382                clashes.push(format!("{:?} {:?} -> {}", b.mode, b.seq, b.command));
383            }
384            seen.push((b.mode, &b.seq));
385        }
386        assert!(clashes.is_empty(), "duplicate bindings: {clashes:?}");
387    }
388
389    #[test]
390    fn resolves_a_single_key() {
391        let (m, mut p) = (map(), Pending::default());
392        assert_eq!(
393            m.resolve(Mode::Normal, &mut p, Chord::ch('j')),
394            Resolution::Run {
395                id: "motion.down",
396                count: None
397            }
398        );
399        assert!(p.is_idle(), "pending state must reset after a match");
400    }
401
402    #[test]
403    fn accumulates_multi_digit_counts() {
404        let (m, mut p) = (map(), Pending::default());
405        assert_eq!(
406            m.resolve(Mode::Normal, &mut p, Chord::ch('1')),
407            Resolution::Count(1)
408        );
409        assert_eq!(
410            m.resolve(Mode::Normal, &mut p, Chord::ch('2')),
411            Resolution::Count(12)
412        );
413        assert_eq!(
414            m.resolve(Mode::Normal, &mut p, Chord::ch('j')),
415            Resolution::Run {
416                id: "motion.down",
417                count: Some(12)
418            }
419        );
420    }
421
422    #[test]
423    fn leading_zero_is_not_a_count() {
424        // In vim `0` is a motion, not a count, it may only extend one already started.
425        let (m, mut p) = (map(), Pending::default());
426        assert!(matches!(
427            m.resolve(Mode::Normal, &mut p, Chord::ch('0')),
428            Resolution::Unbound { .. }
429        ));
430        p.clear();
431        m.resolve(Mode::Normal, &mut p, Chord::ch('1'));
432        assert_eq!(
433            m.resolve(Mode::Normal, &mut p, Chord::ch('0')),
434            Resolution::Count(10)
435        );
436    }
437
438    #[test]
439    fn counts_are_capped() {
440        let (m, mut p) = (map(), Pending::default());
441        for _ in 0..12 {
442            m.resolve(Mode::Normal, &mut p, Chord::ch('9'));
443        }
444        assert_eq!(p.count, Some(MAX_COUNT));
445    }
446
447    #[test]
448    fn multi_key_sequences_report_pending_then_run() {
449        let (m, mut p) = (map(), Pending::default());
450        let r = m.resolve(Mode::Normal, &mut p, Chord::ch('g'));
451        match r {
452            Resolution::Pending { candidates } => {
453                // `g` is vim's goto prefix and gains continuations over time, so the point
454                // is that `gg` is among them, not how many there are.
455                let gg = candidates
456                    .iter()
457                    .find(|c| c.next == Chord::ch('g'))
458                    .expect("gg should be reachable from g");
459                assert_eq!(gg.command, Some("motion.top"));
460            }
461            other => panic!("expected Pending, got {other:?}"),
462        }
463        assert_eq!(
464            m.resolve(Mode::Normal, &mut p, Chord::ch('g')),
465            Resolution::Run {
466                id: "motion.top",
467                count: None
468            }
469        );
470    }
471
472    #[test]
473    fn leader_lists_its_candidates() {
474        let (m, mut p) = (map(), Pending::default());
475        match m.resolve(Mode::Normal, &mut p, Chord::ch(' ')) {
476            Resolution::Pending { candidates } => {
477                assert!(candidates.iter().any(|c| c.command == Some("app.quit")));
478            }
479            other => panic!("expected Pending, got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn counts_survive_a_multi_key_sequence() {
485        let (m, mut p) = (map(), Pending::default());
486        let r = feed(
487            &m,
488            Mode::Normal,
489            &mut p,
490            &[Chord::ch('5'), Chord::ch('g'), Chord::ch('g')],
491        );
492        assert_eq!(
493            r,
494            Resolution::Run {
495                id: "motion.top",
496                count: Some(5)
497            }
498        );
499    }
500
501    #[test]
502    fn jk_leaves_insert_mode() {
503        let (m, mut p) = (map(), Pending::default());
504        assert!(matches!(
505            m.resolve(Mode::Insert, &mut p, Chord::ch('j')),
506            Resolution::Pending { .. }
507        ));
508        assert_eq!(
509            m.resolve(Mode::Insert, &mut p, Chord::ch('k')),
510            Resolution::Run {
511                id: "mode.normal",
512                count: None
513            }
514        );
515    }
516
517    #[test]
518    fn a_held_j_is_flushed_when_the_sequence_fails() {
519        // Typing "ja" in Insert must insert both characters, not swallow the `j`.
520        let (m, mut p) = (map(), Pending::default());
521        m.resolve(Mode::Insert, &mut p, Chord::ch('j'));
522        match m.resolve(Mode::Insert, &mut p, Chord::ch('a')) {
523            Resolution::Unbound { flushed } => {
524                assert_eq!(flushed, vec![Chord::ch('j'), Chord::ch('a')]);
525            }
526            other => panic!("expected Unbound with both chords, got {other:?}"),
527        }
528        assert!(p.is_idle());
529    }
530
531    #[test]
532    fn insert_mode_ignores_counts() {
533        let (m, mut p) = (map(), Pending::default());
534        match m.resolve(Mode::Insert, &mut p, Chord::ch('7')) {
535            Resolution::Unbound { flushed } => assert_eq!(flushed, vec![Chord::ch('7')]),
536            other => panic!("digits must be literal in Insert, got {other:?}"),
537        }
538    }
539
540    #[test]
541    fn ctrl_hjkl_is_never_bound() {
542        // tmux's vim-tmux-navigator consumes these before any application sees them.
543        let m = map();
544        for c in ['h', 'j', 'k', 'l'] {
545            let chord = Chord::ctrl(c);
546            assert!(
547                !m.bindings().iter().any(|b| b.seq.0 == vec![chord]),
548                "<C-{c}> must not be bound; tmux eats it"
549            );
550        }
551    }
552
553    #[test]
554    fn later_bindings_override_earlier_ones() {
555        let mut m = Keymap::new(Chord::ch(' '));
556        m.bind(Mode::Normal, "j", "motion.down").unwrap();
557        m.bind(Mode::Normal, "j", "motion.up").unwrap();
558        assert_eq!(m.bindings().len(), 1);
559        let mut p = Pending::default();
560        assert_eq!(
561            m.resolve(Mode::Normal, &mut p, Chord::ch('j')),
562            Resolution::Run {
563                id: "motion.up",
564                count: None
565            }
566        );
567    }
568
569    #[test]
570    fn pending_display_matches_what_was_typed() {
571        let (m, mut p) = (map(), Pending::default());
572        feed(&m, Mode::Normal, &mut p, &[Chord::ch('2'), Chord::ch('g')]);
573        assert_eq!(p.display(), "2g");
574    }
575
576    #[test]
577    fn every_bound_command_exists_in_the_registry() {
578        // A binding to a non-existent id would be a key that silently does nothing.
579        let reg = crate::command::Registry::builtin();
580        for b in map().bindings() {
581            assert!(
582                reg.get(b.command).is_some(),
583                "binding {} points at unknown command `{}`",
584                b.seq,
585                b.command
586            );
587        }
588    }
589}