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