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