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 AskFocus,
62 AskNew,
63 AskCopy,
64 AskSave,
65 AskRegenerate,
66 AskSource,
67 Palette,
69 Help,
71 NoteSave,
74 AppQuit,
76 AppOnboarding,
78 AppCheckUpdates,
80}
81
82impl LeaderAction {
83 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 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 pub fn from_id(id: &str) -> Option<LeaderAction> {
204 if id == "help" {
205 return Some(LeaderAction::Help);
206 }
207 if id == "vault.preferences" {
210 return Some(LeaderAction::VaultPreferences);
211 }
212 Self::ALL.into_iter().find(|a| a.id() == id)
213 }
214
215 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
270pub enum LeaderNode {
272 Group {
273 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 pub fn label(&self) -> &str {
296 match self {
297 LeaderNode::Group { label, .. } => label,
298 LeaderNode::Leaf { label, .. } => label,
299 }
300 }
301
302 pub fn children(&self) -> &[(char, LeaderNode)] {
304 match self {
305 LeaderNode::Group { children, .. } => children,
306 LeaderNode::Leaf { .. } => &[],
307 }
308 }
309}
310
311pub 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 ('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
456pub 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
491fn synth_group_label(key: char) -> std::borrow::Cow<'static, str> {
494 std::borrow::Cow::Owned(format!("+{key}"))
495}
496
497pub 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; };
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 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 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 if matches!(child, LeaderNode::Group { children, .. } if children.is_empty()) {
598 children.retain(|(k, _)| *k != head);
599 }
600 }
601}
602
603#[derive(Debug, PartialEq, Eq)]
605pub enum LeaderOutcome {
606 Descended,
608 Fired(LeaderAction),
610 Invalid,
612 Cancelled,
614 SteppedUp,
617}
618
619pub struct LeaderEngine {
622 tree: LeaderNode,
623 path: Vec<char>,
625 since: Option<Instant>,
628}
629
630impl LeaderEngine {
631 pub fn new() -> Self {
632 Self::with_tree(leader_tree())
633 }
634
635 pub fn with_tree(tree: LeaderNode) -> Self {
639 Self {
640 tree,
641 path: Vec::new(),
642 since: None,
643 }
644 }
645
646 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 pub fn path(&self) -> &[char] {
658 &self.path
659 }
660
661 pub fn pending_since(&self) -> Option<Instant> {
663 self.since
664 }
665
666 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 pub fn start(&mut self) {
680 self.path.clear();
681 self.since = Some(Instant::now());
682 }
683
684 pub fn cancel(&mut self) {
686 self.path.clear();
687 self.since = None;
688 }
689
690 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 self.since = Some(Instant::now());
708 LeaderOutcome::Invalid
709 }
710 }
711 }
712
713 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 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"), ("x", "note.daily"), ("y z", "vault.theme"), ("g p", "none"), ("bad seq!", "note.new"), ("A", "no.such.action"), ],
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); e.start();
826 assert_eq!(e.feed('A'), LeaderOutcome::Invalid); }
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"), ("y", "+mine"), ("n n", "+nope"), ("zz", "+bad"), ],
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 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 #[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}