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