1use std::time::Instant;
9
10use crate::components::drawer::DrawerView;
11use crate::components::drawer_views::LinksTab;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LeaderAction {
17 OpenDrawer(DrawerView),
19 FindFiles,
22 FindGrep,
23 FindTags,
24 FindBacklinks,
25 FindRecent,
26 FindSaved,
27 FindHeadings,
28 NoteNew,
30 NoteDaily,
31 NoteFromTemplate,
32 NoteRename,
33 NoteMove,
34 NoteDelete,
35 LinksTab(LinksTab),
37 LinksGraph,
38 GitStatus,
40 GitSync,
41 GitLog,
42 GitDiff,
43 VaultSwitch,
45 VaultReindex,
46 VaultConfig,
47 VaultTheme,
48 VaultPreferences,
49 WindowZen,
51 WindowSplit,
52 WindowGrowDrawer,
53 WindowShrinkDrawer,
54 NoteToggleTodo,
56 NotePreview,
57 NoteCopyWikilink,
58 NoteExport,
59 NoteYankPath,
60 PinnedJump(u8),
62 FindPinned,
64 NoteTogglePin,
66 AskFocus,
68 AskNew,
69 AskCopy,
70 AskSave,
71 AskRegenerate,
72 AskSource,
73 TextBold,
77 TextItalic,
78 TextStrikethrough,
79 Palette,
81 Help,
83 NoteSave,
86 AppQuit,
88 AppOnboarding,
90 AppCheckUpdates,
92}
93
94impl LeaderAction {
95 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 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 pub fn from_id(id: &str) -> Option<LeaderAction> {
245 if id == "help" {
246 return Some(LeaderAction::Help);
247 }
248 if id == "vault.preferences" {
251 return Some(LeaderAction::VaultPreferences);
252 }
253 Self::ALL.into_iter().find(|a| a.id() == id)
254 }
255
256 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
317pub enum LeaderNode {
319 Group {
320 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 pub fn label(&self) -> &str {
343 match self {
344 LeaderNode::Group { label, .. } => label,
345 LeaderNode::Leaf { label, .. } => label,
346 }
347 }
348
349 pub fn children(&self) -> &[(char, LeaderNode)] {
355 match self {
356 LeaderNode::Group { children, .. } => children,
357 LeaderNode::Leaf { .. } => &[],
358 }
359 }
360
361 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#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct DisplayChild {
403 pub keys: String,
404 pub label: String,
405 pub is_group: bool,
406}
407
408pub 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 ('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
581pub 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
616fn synth_group_label(key: char) -> std::borrow::Cow<'static, str> {
619 std::borrow::Cow::Owned(format!("+{key}"))
620}
621
622pub 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; };
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 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 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 if matches!(child, LeaderNode::Group { children, .. } if children.is_empty()) {
723 children.retain(|(k, _)| *k != head);
724 }
725 }
726}
727
728#[derive(Debug, PartialEq, Eq)]
730pub enum LeaderOutcome {
731 Descended,
733 Fired(LeaderAction),
735 Invalid,
737 Cancelled,
739 SteppedUp,
742}
743
744pub struct LeaderEngine {
747 tree: LeaderNode,
748 path: Vec<char>,
750 since: Option<Instant>,
753}
754
755impl LeaderEngine {
756 pub fn new() -> Self {
757 Self::with_tree(leader_tree())
758 }
759
760 pub fn with_tree(tree: LeaderNode) -> Self {
764 Self {
765 tree,
766 path: Vec::new(),
767 since: None,
768 }
769 }
770
771 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 pub fn path(&self) -> &[char] {
783 &self.path
784 }
785
786 pub fn pending_since(&self) -> Option<Instant> {
788 self.since
789 }
790
791 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 pub fn start(&mut self) {
805 self.path.clear();
806 self.since = Some(Instant::now());
807 }
808
809 pub fn cancel(&mut self) {
811 self.path.clear();
812 self.since = None;
813 }
814
815 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 self.since = Some(Instant::now());
833 LeaderOutcome::Invalid
834 }
835 }
836 }
837
838 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 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"), ("x", "note.daily"), ("y z", "vault.theme"), ("g p", "none"), ("bad seq!", "note.new"), ("A", "no.such.action"), ],
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); e.start();
954 assert_eq!(e.feed('A'), LeaderOutcome::Invalid); }
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"), ("y", "+mine"), ("n n", "+nope"), ("zz", "+bad"), ],
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 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 #[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 #[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 assert!(rows.iter().any(|r| r.keys == "f" && r.is_group));
1134 assert!(rows.iter().any(|r| r.keys == "p" && !r.is_group));
1135 let find = tree.child('f').unwrap();
1137 assert_eq!(find.display_children().len(), find.children().len());
1138 }
1139}