Skip to main content

kimun_notes/keys/
leader.rs

1//! The **leader engine** — the non-modal key-sequence state machine behind
2//! the leader gateway (Ctrl-G; spec §8 says Ctrl-K, which stays the note
3//! browser here). The gateway starts a sequence in every context; subsequent
4//! keys walk the leader tree until a leaf fires, `Esc` cancels, or
5//! `Backspace` steps up a level. The which-key overlay (phase 06) renders
6//! the pending node; this module is pure input logic.
7
8use std::time::Instant;
9
10use crate::components::drawer::DrawerView;
11use crate::components::drawer_views::LinksTab;
12
13/// What a leader leaf does. Executed by the editor screen, which owns every
14/// surface the actions touch.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LeaderAction {
17    // +open drawer
18    OpenDrawer(DrawerView),
19    // +find — these route to the existing pickers/drawers until the
20    // telescope modal (phase 08) takes the list-style leaves over.
21    FindFiles,
22    FindGrep,
23    FindTags,
24    FindBacklinks,
25    FindRecent,
26    FindSaved,
27    FindHeadings,
28    // +note
29    NoteNew,
30    NoteDaily,
31    NoteFromTemplate,
32    NoteRename,
33    NoteMove,
34    NoteDelete,
35    // +links (for the open note)
36    LinksTab(LinksTab),
37    LinksGraph,
38    // +git/sync
39    GitStatus,
40    GitSync,
41    GitLog,
42    GitDiff,
43    // +vault
44    VaultSwitch,
45    VaultReindex,
46    VaultConfig,
47    VaultTheme,
48    VaultPreferences,
49    // +window
50    WindowZen,
51    WindowSplit,
52    WindowGrowDrawer,
53    WindowShrinkDrawer,
54    // +this note (m)
55    NoteToggleTodo,
56    NotePreview,
57    NoteCopyWikilink,
58    NoteExport,
59    NoteYankPath,
60    /// Open the command palette.
61    Palette,
62    // help
63    Help,
64    // +vim / universal
65    /// Flush the autosave immediately (vim `:w` / `:write`).
66    NoteSave,
67    /// Quit the application (vim `:q` / `:qa` / `:wq` / `:x`).
68    AppQuit,
69    /// Open the guided-setup (onboarding) flow.
70    AppOnboarding,
71    /// Open the update dialog (or report up-to-date / check now).
72    AppCheckUpdates,
73}
74
75impl LeaderAction {
76    /// Stable identifier for config files (`[leader]` overrides) and docs.
77    /// Renaming one breaks user configs — treat as public API.
78    pub fn id(&self) -> &'static str {
79        match self {
80            LeaderAction::OpenDrawer(DrawerView::Files) => "drawer.files",
81            LeaderAction::OpenDrawer(DrawerView::Find) => "drawer.find",
82            LeaderAction::OpenDrawer(DrawerView::Tags) => "drawer.tags",
83            LeaderAction::OpenDrawer(DrawerView::Links) => "drawer.links",
84            LeaderAction::OpenDrawer(DrawerView::Outline) => "drawer.outline",
85            LeaderAction::OpenDrawer(DrawerView::Config) => "drawer.config",
86            LeaderAction::FindFiles => "find.files",
87            LeaderAction::FindGrep => "find.grep",
88            LeaderAction::FindTags => "find.tags",
89            LeaderAction::FindBacklinks => "find.backlinks",
90            LeaderAction::FindRecent => "find.recent",
91            LeaderAction::FindSaved => "find.saved",
92            LeaderAction::FindHeadings => "find.headings",
93            LeaderAction::NoteNew => "note.new",
94            LeaderAction::NoteDaily => "note.daily",
95            LeaderAction::NoteFromTemplate => "note.template",
96            LeaderAction::NoteRename => "note.rename",
97            LeaderAction::NoteMove => "note.move",
98            LeaderAction::NoteDelete => "note.delete",
99            LeaderAction::LinksTab(LinksTab::Backlinks) => "links.backlinks",
100            LeaderAction::LinksTab(LinksTab::Outgoing) => "links.outgoing",
101            LeaderAction::LinksTab(LinksTab::Unlinked) => "links.unlinked",
102            LeaderAction::LinksGraph => "links.graph",
103            LeaderAction::GitStatus => "git.status",
104            LeaderAction::GitSync => "git.sync",
105            LeaderAction::GitLog => "git.log",
106            LeaderAction::GitDiff => "git.diff",
107            LeaderAction::VaultSwitch => "vault.switch",
108            LeaderAction::VaultReindex => "vault.reindex",
109            LeaderAction::VaultConfig => "vault.config",
110            LeaderAction::VaultTheme => "vault.theme",
111            LeaderAction::VaultPreferences => "vault.settings",
112            LeaderAction::WindowZen => "window.zen",
113            LeaderAction::WindowSplit => "window.split",
114            LeaderAction::WindowGrowDrawer => "window.grow",
115            LeaderAction::WindowShrinkDrawer => "window.shrink",
116            LeaderAction::NoteToggleTodo => "this.todo",
117            LeaderAction::NotePreview => "this.preview",
118            LeaderAction::NoteCopyWikilink => "this.copy-link",
119            LeaderAction::NoteExport => "this.export",
120            LeaderAction::NoteYankPath => "this.yank-path",
121            LeaderAction::Palette => "palette",
122            LeaderAction::Help => "help",
123            LeaderAction::NoteSave => "note.save",
124            LeaderAction::AppQuit => "app.quit",
125            LeaderAction::AppOnboarding => "app.onboarding",
126            LeaderAction::AppCheckUpdates => "app.check-updates",
127        }
128    }
129
130    /// Every action, for id lookup and docs.
131    pub const ALL: [LeaderAction; 46] = [
132        LeaderAction::OpenDrawer(DrawerView::Files),
133        LeaderAction::OpenDrawer(DrawerView::Find),
134        LeaderAction::OpenDrawer(DrawerView::Tags),
135        LeaderAction::OpenDrawer(DrawerView::Links),
136        LeaderAction::OpenDrawer(DrawerView::Outline),
137        LeaderAction::OpenDrawer(DrawerView::Config),
138        LeaderAction::FindFiles,
139        LeaderAction::FindGrep,
140        LeaderAction::FindTags,
141        LeaderAction::FindBacklinks,
142        LeaderAction::FindRecent,
143        LeaderAction::FindSaved,
144        LeaderAction::FindHeadings,
145        LeaderAction::NoteNew,
146        LeaderAction::NoteDaily,
147        LeaderAction::NoteFromTemplate,
148        LeaderAction::NoteRename,
149        LeaderAction::NoteMove,
150        LeaderAction::NoteDelete,
151        LeaderAction::LinksTab(LinksTab::Backlinks),
152        LeaderAction::LinksTab(LinksTab::Outgoing),
153        LeaderAction::LinksTab(LinksTab::Unlinked),
154        LeaderAction::LinksGraph,
155        LeaderAction::GitStatus,
156        LeaderAction::GitSync,
157        LeaderAction::GitLog,
158        LeaderAction::GitDiff,
159        LeaderAction::VaultSwitch,
160        LeaderAction::VaultReindex,
161        LeaderAction::VaultConfig,
162        LeaderAction::VaultTheme,
163        LeaderAction::VaultPreferences,
164        LeaderAction::WindowZen,
165        LeaderAction::WindowSplit,
166        LeaderAction::WindowGrowDrawer,
167        LeaderAction::WindowShrinkDrawer,
168        LeaderAction::NoteToggleTodo,
169        LeaderAction::NotePreview,
170        LeaderAction::NoteCopyWikilink,
171        LeaderAction::NoteExport,
172        LeaderAction::NoteYankPath,
173        LeaderAction::Palette,
174        LeaderAction::NoteSave,
175        LeaderAction::AppQuit,
176        LeaderAction::AppOnboarding,
177        LeaderAction::AppCheckUpdates,
178    ];
179
180    /// Look an action up by its config id. `Help` is included via ALL? It is
181    /// not — `help` resolves explicitly so ALL's length stays the leaf count.
182    pub fn from_id(id: &str) -> Option<LeaderAction> {
183        if id == "help" {
184            return Some(LeaderAction::Help);
185        }
186        // "vault.settings" is the stable id; accept the screen's current name
187        // as an alias.
188        if id == "vault.preferences" {
189            return Some(LeaderAction::VaultPreferences);
190        }
191        Self::ALL.into_iter().find(|a| a.id() == id)
192    }
193
194    /// Default display label for config-added leaves (the built-in tree
195    /// carries hand-written labels; an override that adds an action somewhere
196    /// new falls back to this).
197    pub fn default_label(&self) -> &'static str {
198        match self {
199            LeaderAction::OpenDrawer(_) => "open drawer",
200            LeaderAction::FindFiles => "files",
201            LeaderAction::FindGrep => "grep/query",
202            LeaderAction::FindTags => "tags",
203            LeaderAction::FindBacklinks => "backlinks",
204            LeaderAction::FindRecent => "recent",
205            LeaderAction::FindSaved => "saved searches",
206            LeaderAction::FindHeadings => "headings",
207            LeaderAction::NoteNew => "new note",
208            LeaderAction::NoteDaily => "daily",
209            LeaderAction::NoteFromTemplate => "from template",
210            LeaderAction::NoteRename => "rename",
211            LeaderAction::NoteMove => "move",
212            LeaderAction::NoteDelete => "delete",
213            LeaderAction::LinksTab(_) => "links",
214            LeaderAction::LinksGraph => "local graph",
215            LeaderAction::GitStatus => "git status",
216            LeaderAction::GitSync => "git sync",
217            LeaderAction::GitLog => "git log",
218            LeaderAction::GitDiff => "git diff",
219            LeaderAction::VaultSwitch => "switch vault",
220            LeaderAction::VaultReindex => "reindex",
221            LeaderAction::VaultConfig => "config",
222            LeaderAction::VaultTheme => "theme picker",
223            LeaderAction::VaultPreferences => "preferences",
224            LeaderAction::WindowZen => "zen",
225            LeaderAction::WindowSplit => "split",
226            LeaderAction::WindowGrowDrawer => "grow drawer",
227            LeaderAction::WindowShrinkDrawer => "shrink drawer",
228            LeaderAction::NoteToggleTodo => "toggle todo",
229            LeaderAction::NotePreview => "preview",
230            LeaderAction::NoteCopyWikilink => "copy wikilink",
231            LeaderAction::NoteExport => "export",
232            LeaderAction::NoteYankPath => "yank note path",
233            LeaderAction::Palette => "command palette",
234            LeaderAction::Help => "help / cheatsheet",
235            LeaderAction::NoteSave => "write (save now)",
236            LeaderAction::AppQuit => "quit kimün",
237            LeaderAction::AppOnboarding => "guided setup",
238            LeaderAction::AppCheckUpdates => "check for updates",
239        }
240    }
241}
242
243/// One node of the leader tree.
244pub enum LeaderNode {
245    Group {
246        /// Owned-or-static so config can rename groups (`[leader.labels]`).
247        label: std::borrow::Cow<'static, str>,
248        children: Vec<(char, LeaderNode)>,
249    },
250    Leaf {
251        label: &'static str,
252        action: LeaderAction,
253    },
254}
255
256impl LeaderNode {
257    fn child(&self, key: char) -> Option<&LeaderNode> {
258        match self {
259            LeaderNode::Group { children, .. } => children
260                .iter()
261                .find(|(k, _)| *k == key)
262                .map(|(_, node)| node),
263            LeaderNode::Leaf { .. } => None,
264        }
265    }
266
267    /// The node's display label (group caption or leaf description).
268    pub fn label(&self) -> &str {
269        match self {
270            LeaderNode::Group { label, .. } => label,
271            LeaderNode::Leaf { label, .. } => label,
272        }
273    }
274
275    /// Children of a group node, for the which-key overlay. Empty for leaves.
276    pub fn children(&self) -> &[(char, LeaderNode)] {
277        match self {
278            LeaderNode::Group { children, .. } => children,
279            LeaderNode::Leaf { .. } => &[],
280        }
281    }
282}
283
284/// The leader tree per spec §8c (gateway key deviations noted in the module
285/// docs). Group letters: f n l o g v w m, plus `?` for help.
286pub fn leader_tree() -> LeaderNode {
287    use DrawerView as DV;
288    use LeaderAction as A;
289    use LeaderNode::{Group, Leaf};
290
291    fn leaf(label: &'static str, action: LeaderAction) -> LeaderNode {
292        Leaf { label, action }
293    }
294
295    Group {
296        label: "leader — pick a group".into(),
297        children: vec![
298            (
299                'f',
300                Group {
301                    label: "+find".into(),
302                    children: vec![
303                        ('f', leaf("files", A::FindFiles)),
304                        ('g', leaf("grep/query", A::FindGrep)),
305                        ('t', leaf("tags", A::FindTags)),
306                        ('b', leaf("backlinks", A::FindBacklinks)),
307                        ('r', leaf("recent", A::FindRecent)),
308                        ('s', leaf("saved searches", A::FindSaved)),
309                        ('h', leaf("headings", A::FindHeadings)),
310                    ],
311                },
312            ),
313            (
314                'n',
315                Group {
316                    label: "+note".into(),
317                    children: vec![
318                        ('n', leaf("new", A::NoteNew)),
319                        ('d', leaf("daily", A::NoteDaily)),
320                        ('t', leaf("from template", A::NoteFromTemplate)),
321                        ('r', leaf("rename", A::NoteRename)),
322                        ('m', leaf("move", A::NoteMove)),
323                        ('D', leaf("delete", A::NoteDelete)),
324                        ('w', leaf("write (save now)", A::NoteSave)),
325                    ],
326                },
327            ),
328            (
329                'l',
330                Group {
331                    label: "+links".into(),
332                    children: vec![
333                        ('b', leaf("backlinks", A::LinksTab(LinksTab::Backlinks))),
334                        ('o', leaf("outgoing", A::LinksTab(LinksTab::Outgoing))),
335                        ('u', leaf("unlinked", A::LinksTab(LinksTab::Unlinked))),
336                        ('g', leaf("local graph", A::LinksGraph)),
337                    ],
338                },
339            ),
340            (
341                'o',
342                Group {
343                    label: "+open drawer".into(),
344                    children: vec![
345                        ('f', leaf("files", A::OpenDrawer(DV::Files))),
346                        ('q', leaf("find", A::OpenDrawer(DV::Find))),
347                        ('t', leaf("tags", A::OpenDrawer(DV::Tags))),
348                        ('k', leaf("links", A::OpenDrawer(DV::Links))),
349                        ('l', leaf("outline", A::OpenDrawer(DV::Outline))),
350                    ],
351                },
352            ),
353            (
354                'g',
355                Group {
356                    label: "+git/sync".into(),
357                    children: vec![
358                        ('s', leaf("status", A::GitStatus)),
359                        ('p', leaf("sync/push", A::GitSync)),
360                        ('l', leaf("log", A::GitLog)),
361                        ('d', leaf("diff", A::GitDiff)),
362                    ],
363                },
364            ),
365            (
366                'v',
367                Group {
368                    label: "+vault".into(),
369                    children: vec![
370                        ('s', leaf("switch vault", A::VaultSwitch)),
371                        ('r', leaf("reindex", A::VaultReindex)),
372                        ('c', leaf("config", A::VaultConfig)),
373                        ('t', leaf("theme picker", A::VaultTheme)),
374                        ('p', leaf("preferences", A::VaultPreferences)),
375                        ('o', leaf("guided setup", A::AppOnboarding)),
376                        ('u', leaf("check for updates", A::AppCheckUpdates)),
377                    ],
378                },
379            ),
380            (
381                'w',
382                Group {
383                    label: "+window".into(),
384                    children: vec![
385                        ('z', leaf("zen", A::WindowZen)),
386                        ('v', leaf("split (soon)", A::WindowSplit)),
387                        ('l', leaf("grow drawer", A::WindowGrowDrawer)),
388                        ('h', leaf("shrink drawer", A::WindowShrinkDrawer)),
389                    ],
390                },
391            ),
392            (
393                'm',
394                Group {
395                    label: "+this note".into(),
396                    children: vec![
397                        ('t', leaf("toggle todo", A::NoteToggleTodo)),
398                        ('p', leaf("preview", A::NotePreview)),
399                        ('c', leaf("copy wikilink", A::NoteCopyWikilink)),
400                        ('e', leaf("export (soon)", A::NoteExport)),
401                        // Same dialog as `n r` — every rename rewrites
402                        // backlinks (core LinkRewrite), so the labels match.
403                        ('r', leaf("rename", A::NoteRename)),
404                        ('y', leaf("yank note path", A::NoteYankPath)),
405                    ],
406                },
407            ),
408            ('p', leaf("command palette", A::Palette)),
409            ('q', leaf("quit kimün", A::AppQuit)),
410            ('?', leaf("help / cheatsheet", A::Help)),
411        ],
412    }
413}
414
415/// Apply config overrides onto the default tree: each entry maps a key
416/// sequence (space-separated keys after the gateway, e.g. `"o f"` or `"x"`)
417/// to an action id — or `"none"` to remove the binding. Unknown ids and
418/// empty sequences are skipped with a warning; intermediate groups are
419/// created on demand (labelled `+<key>`).
420pub fn apply_overrides<'a, I>(mut tree: LeaderNode, overrides: I) -> LeaderNode
421where
422    I: IntoIterator<Item = (&'a str, &'a str)>,
423{
424    for (seq, action_id) in overrides {
425        let keys: Vec<char> = seq
426            .split_whitespace()
427            .filter_map(|t| {
428                let mut chars = t.chars();
429                let c = chars.next()?;
430                chars.next().is_none().then_some(c)
431            })
432            .collect();
433        if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
434            tracing::warn!("[leader] ignoring invalid sequence {seq:?} (single-char keys only)");
435            continue;
436        }
437        if action_id.eq_ignore_ascii_case("none") {
438            remove_at(&mut tree, &keys);
439            continue;
440        }
441        let Some(action) = LeaderAction::from_id(action_id) else {
442            tracing::warn!("[leader] ignoring unknown action id {action_id:?} for {seq:?}");
443            continue;
444        };
445        insert_at(&mut tree, &keys, action);
446    }
447    tree
448}
449
450/// Caption for on-demand groups created by overrides; `[leader.labels]`
451/// renames them (and any built-in group).
452fn synth_group_label(key: char) -> std::borrow::Cow<'static, str> {
453    std::borrow::Cow::Owned(format!("+{key}"))
454}
455
456/// Apply `[leader.labels]` overrides: each entry maps the key sequence of a
457/// GROUP (e.g. `"f"`, or `"y z"` for a nested one) to its caption. Unknown
458/// sequences and leaves are skipped with a warning.
459pub fn apply_labels<'a, I>(mut tree: LeaderNode, labels: I) -> LeaderNode
460where
461    I: IntoIterator<Item = (&'a str, &'a str)>,
462{
463    for (seq, label) in labels {
464        let keys: Vec<char> = seq
465            .split_whitespace()
466            .filter_map(|t| {
467                let mut chars = t.chars();
468                let c = chars.next()?;
469                chars.next().is_none().then_some(c)
470            })
471            .collect();
472        if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
473            tracing::warn!("[leader.labels] ignoring invalid sequence {seq:?}");
474            continue;
475        }
476        let mut node = Some(&mut tree);
477        for key in &keys {
478            node = node.and_then(|n| match n {
479                LeaderNode::Group { children, .. } => children
480                    .iter_mut()
481                    .find(|(k, _)| k == key)
482                    .map(|(_, child)| child),
483                LeaderNode::Leaf { .. } => None,
484            });
485        }
486        match node {
487            Some(LeaderNode::Group { label: slot, .. }) => {
488                *slot = std::borrow::Cow::Owned(label.to_string());
489            }
490            _ => tracing::warn!("[leader.labels] {seq:?} is not a group; ignored"),
491        }
492    }
493    tree
494}
495
496fn insert_at(node: &mut LeaderNode, keys: &[char], action: LeaderAction) {
497    let LeaderNode::Group { children, .. } = node else {
498        return; // a leaf can't be descended into; overrides target groups
499    };
500    let (head, rest) = (keys[0], &keys[1..]);
501    if rest.is_empty() {
502        let leaf = LeaderNode::Leaf {
503            label: action.default_label(),
504            action,
505        };
506        if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
507            if matches!(child, LeaderNode::Group { .. }) {
508                // Loud: a one-key override replacing a whole group is more
509                // often a typo (`"f"` for `"f f"`) than an intent.
510                tracing::warn!(
511                    "[leader.bind] key {head:?} replaces an entire group with \
512                     a single action — its sub-bindings are gone"
513                );
514            }
515            *child = leaf;
516        } else {
517            children.push((head, leaf));
518        }
519        return;
520    }
521    // Descend, creating (or replacing a leaf with) a group as needed.
522    let needs_group = !matches!(
523        children.iter().find(|(k, _)| *k == head),
524        Some((_, LeaderNode::Group { .. }))
525    );
526    if needs_group {
527        let group = LeaderNode::Group {
528            label: synth_group_label(head),
529            children: Vec::new(),
530        };
531        if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
532            *child = group;
533        } else {
534            children.push((head, group));
535        }
536    }
537    let (_, child) = children
538        .iter_mut()
539        .find(|(k, _)| *k == head)
540        .expect("just ensured");
541    insert_at(child, rest, action);
542}
543
544fn remove_at(node: &mut LeaderNode, keys: &[char]) {
545    let LeaderNode::Group { children, .. } = node else {
546        return;
547    };
548    let (head, rest) = (keys[0], &keys[1..]);
549    if rest.is_empty() {
550        children.retain(|(k, _)| *k != head);
551        return;
552    }
553    if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
554        remove_at(child, rest);
555        // Drop a group emptied by the removal.
556        if matches!(child, LeaderNode::Group { children, .. } if children.is_empty()) {
557            children.retain(|(k, _)| *k != head);
558        }
559    }
560}
561
562/// What feeding a key into a pending sequence produced.
563#[derive(Debug, PartialEq, Eq)]
564pub enum LeaderOutcome {
565    /// Stepped into a group; sequence still pending.
566    Descended,
567    /// A leaf fired.
568    Fired(LeaderAction),
569    /// The key matched nothing; sequence stays where it was (gentle no-op).
570    Invalid,
571    /// Sequence cancelled (Esc).
572    Cancelled,
573    /// Stepped up one level (Backspace); pending unless at the root… in
574    /// which case it cancels.
575    SteppedUp,
576}
577
578/// The pending-sequence state machine. `start()` arms it; `feed()` walks the
579/// tree. Not pending = idle, all keys flow normally.
580pub struct LeaderEngine {
581    tree: LeaderNode,
582    /// Keys pressed since the gateway, in order. Empty = at the root.
583    path: Vec<char>,
584    /// When the sequence was last advanced — the which-key overlay reveals
585    /// itself when `now - since > timeout` (phase 06).
586    since: Option<Instant>,
587}
588
589impl LeaderEngine {
590    pub fn new() -> Self {
591        Self::with_tree(leader_tree())
592    }
593
594    /// Build the engine over a configured tree (defaults + `[leader]`
595    /// overrides) — the same tree the which-key overlay, the cheatsheet,
596    /// and the command palette must read.
597    pub fn with_tree(tree: LeaderNode) -> Self {
598        Self {
599            tree,
600            path: Vec::new(),
601            since: None,
602        }
603    }
604
605    /// The tree the engine walks — single source for every surface that
606    /// documents it.
607    pub fn tree(&self) -> &LeaderNode {
608        &self.tree
609    }
610
611    pub fn is_pending(&self) -> bool {
612        self.since.is_some()
613    }
614
615    /// The keys pressed since the gateway (for the which-key header).
616    pub fn path(&self) -> &[char] {
617        &self.path
618    }
619
620    /// When the pending sequence last advanced, for hesitation detection.
621    pub fn pending_since(&self) -> Option<Instant> {
622        self.since
623    }
624
625    /// The node the sequence currently sits on (root when just started).
626    pub fn current_node(&self) -> &LeaderNode {
627        let mut node = &self.tree;
628        for key in &self.path {
629            match node.child(*key) {
630                Some(next) => node = next,
631                None => break,
632            }
633        }
634        node
635    }
636
637    /// Arm the engine: the gateway was pressed.
638    pub fn start(&mut self) {
639        self.path.clear();
640        self.since = Some(Instant::now());
641    }
642
643    /// Disarm without firing.
644    pub fn cancel(&mut self) {
645        self.path.clear();
646        self.since = None;
647    }
648
649    /// Feed a printable key into the pending sequence.
650    pub fn feed(&mut self, key: char) -> LeaderOutcome {
651        debug_assert!(self.is_pending());
652        match self.current_node().child(key) {
653            Some(LeaderNode::Leaf { action, .. }) => {
654                let action = *action;
655                self.cancel();
656                LeaderOutcome::Fired(action)
657            }
658            Some(LeaderNode::Group { .. }) => {
659                self.path.push(key);
660                self.since = Some(Instant::now());
661                LeaderOutcome::Descended
662            }
663            None => {
664                // The user is clearly hesitating — restart the reveal timer
665                // so the which-key overlay (phase 06) can help.
666                self.since = Some(Instant::now());
667                LeaderOutcome::Invalid
668            }
669        }
670    }
671
672    /// Step up one level (Backspace). At the root this cancels.
673    pub fn step_up(&mut self) -> LeaderOutcome {
674        if self.path.pop().is_some() {
675            self.since = Some(Instant::now());
676            LeaderOutcome::SteppedUp
677        } else {
678            self.cancel();
679            LeaderOutcome::Cancelled
680        }
681    }
682}
683
684impl Default for LeaderEngine {
685    fn default() -> Self {
686        Self::new()
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn full_sequence_fires_leaf() {
696        let mut e = LeaderEngine::new();
697        e.start();
698        assert_eq!(e.feed('o'), LeaderOutcome::Descended);
699        assert_eq!(
700            e.feed('f'),
701            LeaderOutcome::Fired(LeaderAction::OpenDrawer(DrawerView::Files))
702        );
703        assert!(!e.is_pending());
704    }
705
706    #[test]
707    fn invalid_key_keeps_sequence_pending() {
708        let mut e = LeaderEngine::new();
709        e.start();
710        assert_eq!(e.feed('x'), LeaderOutcome::Invalid);
711        assert!(e.is_pending());
712        assert_eq!(e.feed('o'), LeaderOutcome::Descended);
713    }
714
715    #[test]
716    fn backspace_steps_up_then_cancels() {
717        let mut e = LeaderEngine::new();
718        e.start();
719        e.feed('f');
720        assert_eq!(e.step_up(), LeaderOutcome::SteppedUp);
721        assert!(e.is_pending());
722        assert_eq!(e.step_up(), LeaderOutcome::Cancelled);
723        assert!(!e.is_pending());
724    }
725
726    #[test]
727    fn cancel_disarms() {
728        let mut e = LeaderEngine::new();
729        e.start();
730        e.feed('n');
731        e.cancel();
732        assert!(!e.is_pending());
733        assert!(e.path().is_empty());
734    }
735
736    #[test]
737    fn tree_matches_spec_groups() {
738        let tree = leader_tree();
739        let groups: Vec<char> = tree.children().iter().map(|(k, _)| *k).collect();
740        assert_eq!(
741            groups,
742            vec!['f', 'n', 'l', 'o', 'g', 'v', 'w', 'm', 'p', 'q', '?']
743        );
744        // Doubled letters fire the group's most-common action.
745        let mut e = LeaderEngine::new();
746        e.start();
747        e.feed('f');
748        assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
749        e.start();
750        e.feed('n');
751        assert_eq!(e.feed('n'), LeaderOutcome::Fired(LeaderAction::NoteNew));
752    }
753
754    #[test]
755    fn overrides_remap_add_and_remove() {
756        let tree = apply_overrides(
757            leader_tree(),
758            [
759                ("o f", "find.files"),    // remap an existing leaf
760                ("x", "note.daily"),      // add a new top-level leaf
761                ("y z", "vault.theme"),   // add under a new on-demand group
762                ("g p", "none"),          // remove a leaf
763                ("bad seq!", "note.new"), // invalid (multi-char key) → skipped
764                ("A", "no.such.action"),  // unknown id → skipped
765            ],
766        );
767        let mut e = LeaderEngine::with_tree(tree);
768
769        e.start();
770        e.feed('o');
771        assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
772
773        e.start();
774        assert_eq!(e.feed('x'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
775
776        e.start();
777        assert_eq!(e.feed('y'), LeaderOutcome::Descended);
778        assert_eq!(e.feed('z'), LeaderOutcome::Fired(LeaderAction::VaultTheme));
779
780        e.start();
781        e.feed('g');
782        assert_eq!(e.feed('p'), LeaderOutcome::Invalid); // removed
783
784        e.start();
785        assert_eq!(e.feed('A'), LeaderOutcome::Invalid); // unknown id skipped
786    }
787
788    #[test]
789    fn labels_rename_groups_including_synth_ones() {
790        let tree = apply_overrides(leader_tree(), [("y z", "vault.theme")]);
791        let tree = apply_labels(
792            tree,
793            [
794                ("f", "+search"), // rename a built-in group
795                ("y", "+mine"),   // rename an override-created group
796                ("n n", "+nope"), // a leaf → warned + ignored
797                ("zz", "+bad"),   // invalid sequence → ignored
798            ],
799        );
800        let find = tree.children().iter().find(|(k, _)| *k == 'f').unwrap();
801        assert_eq!(find.1.label(), "+search");
802        let mine = tree.children().iter().find(|(k, _)| *k == 'y').unwrap();
803        assert_eq!(mine.1.label(), "+mine");
804        // Leaf labels untouched.
805        let note = tree.children().iter().find(|(k, _)| *k == 'n').unwrap();
806        let nn = note.1.children().iter().find(|(k, _)| *k == 'n').unwrap();
807        assert_eq!(nn.1.label(), "new");
808    }
809
810    /// Every action reachable through the default tree must resolve through
811    /// `from_id` — catches a new leaf variant missing its `ALL` entry, which
812    /// would silently break `[leader.bind]` overrides for it.
813    #[test]
814    fn every_tree_leaf_is_id_addressable() {
815        fn walk(node: &LeaderNode, out: &mut Vec<LeaderAction>) {
816            for (_, child) in node.children() {
817                match child {
818                    LeaderNode::Leaf { action, .. } => out.push(*action),
819                    LeaderNode::Group { .. } => walk(child, out),
820                }
821            }
822        }
823        let mut leaves = Vec::new();
824        walk(&leader_tree(), &mut leaves);
825        for action in leaves {
826            assert_eq!(
827                LeaderAction::from_id(action.id()),
828                Some(action),
829                "{action:?} (id {:?}) missing from LeaderAction::ALL",
830                action.id()
831            );
832        }
833    }
834
835    #[test]
836    fn action_ids_round_trip() {
837        for action in LeaderAction::ALL {
838            assert_eq!(
839                LeaderAction::from_id(action.id()),
840                Some(action),
841                "id round-trip failed for {action:?}"
842            );
843        }
844        assert_eq!(LeaderAction::from_id("help"), Some(LeaderAction::Help));
845        assert_eq!(LeaderAction::from_id("nope"), None);
846    }
847
848    #[test]
849    fn capital_letters_are_distinct_keys() {
850        let mut e = LeaderEngine::new();
851        e.start();
852        e.feed('n');
853        assert_eq!(e.feed('d'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
854        e.start();
855        e.feed('n');
856        assert_eq!(e.feed('D'), LeaderOutcome::Fired(LeaderAction::NoteDelete));
857    }
858
859    #[test]
860    fn app_onboarding_round_trip_from_id() {
861        assert_eq!(
862            LeaderAction::from_id("app.onboarding"),
863            Some(LeaderAction::AppOnboarding)
864        );
865        assert_eq!(LeaderAction::AppOnboarding.id(), "app.onboarding");
866    }
867
868    #[test]
869    fn note_save_and_app_quit_round_trip_from_id() {
870        assert_eq!(
871            LeaderAction::from_id("note.save"),
872            Some(LeaderAction::NoteSave)
873        );
874        assert_eq!(
875            LeaderAction::from_id("app.quit"),
876            Some(LeaderAction::AppQuit)
877        );
878        assert_eq!(LeaderAction::NoteSave.id(), "note.save");
879        assert_eq!(LeaderAction::AppQuit.id(), "app.quit");
880    }
881}