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