Skip to main content

kimun_notes/keys/
leader.rs

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