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