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 Palette,
62 Help,
64 NoteSave,
67 AppQuit,
69 AppOnboarding,
71 AppCheckUpdates,
73}
74
75impl LeaderAction {
76 pub fn id(&self) -> &'static str {
79 match self {
80 LeaderAction::OpenDrawer(DrawerView::Files) => "drawer.files",
81 LeaderAction::OpenDrawer(DrawerView::Find) => "drawer.find",
82 LeaderAction::OpenDrawer(DrawerView::Tags) => "drawer.tags",
83 LeaderAction::OpenDrawer(DrawerView::Links) => "drawer.links",
84 LeaderAction::OpenDrawer(DrawerView::Outline) => "drawer.outline",
85 LeaderAction::OpenDrawer(DrawerView::Config) => "drawer.config",
86 LeaderAction::FindFiles => "find.files",
87 LeaderAction::FindGrep => "find.grep",
88 LeaderAction::FindTags => "find.tags",
89 LeaderAction::FindBacklinks => "find.backlinks",
90 LeaderAction::FindRecent => "find.recent",
91 LeaderAction::FindSaved => "find.saved",
92 LeaderAction::FindHeadings => "find.headings",
93 LeaderAction::NoteNew => "note.new",
94 LeaderAction::NoteDaily => "note.daily",
95 LeaderAction::NoteFromTemplate => "note.template",
96 LeaderAction::NoteRename => "note.rename",
97 LeaderAction::NoteMove => "note.move",
98 LeaderAction::NoteDelete => "note.delete",
99 LeaderAction::LinksTab(LinksTab::Backlinks) => "links.backlinks",
100 LeaderAction::LinksTab(LinksTab::Outgoing) => "links.outgoing",
101 LeaderAction::LinksTab(LinksTab::Unlinked) => "links.unlinked",
102 LeaderAction::LinksGraph => "links.graph",
103 LeaderAction::GitStatus => "git.status",
104 LeaderAction::GitSync => "git.sync",
105 LeaderAction::GitLog => "git.log",
106 LeaderAction::GitDiff => "git.diff",
107 LeaderAction::VaultSwitch => "vault.switch",
108 LeaderAction::VaultReindex => "vault.reindex",
109 LeaderAction::VaultConfig => "vault.config",
110 LeaderAction::VaultTheme => "vault.theme",
111 LeaderAction::VaultPreferences => "vault.settings",
112 LeaderAction::WindowZen => "window.zen",
113 LeaderAction::WindowSplit => "window.split",
114 LeaderAction::WindowGrowDrawer => "window.grow",
115 LeaderAction::WindowShrinkDrawer => "window.shrink",
116 LeaderAction::NoteToggleTodo => "this.todo",
117 LeaderAction::NotePreview => "this.preview",
118 LeaderAction::NoteCopyWikilink => "this.copy-link",
119 LeaderAction::NoteExport => "this.export",
120 LeaderAction::NoteYankPath => "this.yank-path",
121 LeaderAction::Palette => "palette",
122 LeaderAction::Help => "help",
123 LeaderAction::NoteSave => "note.save",
124 LeaderAction::AppQuit => "app.quit",
125 LeaderAction::AppOnboarding => "app.onboarding",
126 LeaderAction::AppCheckUpdates => "app.check-updates",
127 }
128 }
129
130 pub const ALL: [LeaderAction; 46] = [
132 LeaderAction::OpenDrawer(DrawerView::Files),
133 LeaderAction::OpenDrawer(DrawerView::Find),
134 LeaderAction::OpenDrawer(DrawerView::Tags),
135 LeaderAction::OpenDrawer(DrawerView::Links),
136 LeaderAction::OpenDrawer(DrawerView::Outline),
137 LeaderAction::OpenDrawer(DrawerView::Config),
138 LeaderAction::FindFiles,
139 LeaderAction::FindGrep,
140 LeaderAction::FindTags,
141 LeaderAction::FindBacklinks,
142 LeaderAction::FindRecent,
143 LeaderAction::FindSaved,
144 LeaderAction::FindHeadings,
145 LeaderAction::NoteNew,
146 LeaderAction::NoteDaily,
147 LeaderAction::NoteFromTemplate,
148 LeaderAction::NoteRename,
149 LeaderAction::NoteMove,
150 LeaderAction::NoteDelete,
151 LeaderAction::LinksTab(LinksTab::Backlinks),
152 LeaderAction::LinksTab(LinksTab::Outgoing),
153 LeaderAction::LinksTab(LinksTab::Unlinked),
154 LeaderAction::LinksGraph,
155 LeaderAction::GitStatus,
156 LeaderAction::GitSync,
157 LeaderAction::GitLog,
158 LeaderAction::GitDiff,
159 LeaderAction::VaultSwitch,
160 LeaderAction::VaultReindex,
161 LeaderAction::VaultConfig,
162 LeaderAction::VaultTheme,
163 LeaderAction::VaultPreferences,
164 LeaderAction::WindowZen,
165 LeaderAction::WindowSplit,
166 LeaderAction::WindowGrowDrawer,
167 LeaderAction::WindowShrinkDrawer,
168 LeaderAction::NoteToggleTodo,
169 LeaderAction::NotePreview,
170 LeaderAction::NoteCopyWikilink,
171 LeaderAction::NoteExport,
172 LeaderAction::NoteYankPath,
173 LeaderAction::Palette,
174 LeaderAction::NoteSave,
175 LeaderAction::AppQuit,
176 LeaderAction::AppOnboarding,
177 LeaderAction::AppCheckUpdates,
178 ];
179
180 pub fn from_id(id: &str) -> Option<LeaderAction> {
183 if id == "help" {
184 return Some(LeaderAction::Help);
185 }
186 if id == "vault.preferences" {
189 return Some(LeaderAction::VaultPreferences);
190 }
191 Self::ALL.into_iter().find(|a| a.id() == id)
192 }
193
194 pub fn default_label(&self) -> &'static str {
198 match self {
199 LeaderAction::OpenDrawer(_) => "open drawer",
200 LeaderAction::FindFiles => "files",
201 LeaderAction::FindGrep => "grep/query",
202 LeaderAction::FindTags => "tags",
203 LeaderAction::FindBacklinks => "backlinks",
204 LeaderAction::FindRecent => "recent",
205 LeaderAction::FindSaved => "saved searches",
206 LeaderAction::FindHeadings => "headings",
207 LeaderAction::NoteNew => "new note",
208 LeaderAction::NoteDaily => "daily",
209 LeaderAction::NoteFromTemplate => "from template",
210 LeaderAction::NoteRename => "rename",
211 LeaderAction::NoteMove => "move",
212 LeaderAction::NoteDelete => "delete",
213 LeaderAction::LinksTab(_) => "links",
214 LeaderAction::LinksGraph => "local graph",
215 LeaderAction::GitStatus => "git status",
216 LeaderAction::GitSync => "git sync",
217 LeaderAction::GitLog => "git log",
218 LeaderAction::GitDiff => "git diff",
219 LeaderAction::VaultSwitch => "switch vault",
220 LeaderAction::VaultReindex => "reindex",
221 LeaderAction::VaultConfig => "config",
222 LeaderAction::VaultTheme => "theme picker",
223 LeaderAction::VaultPreferences => "preferences",
224 LeaderAction::WindowZen => "zen",
225 LeaderAction::WindowSplit => "split",
226 LeaderAction::WindowGrowDrawer => "grow drawer",
227 LeaderAction::WindowShrinkDrawer => "shrink drawer",
228 LeaderAction::NoteToggleTodo => "toggle todo",
229 LeaderAction::NotePreview => "preview",
230 LeaderAction::NoteCopyWikilink => "copy wikilink",
231 LeaderAction::NoteExport => "export",
232 LeaderAction::NoteYankPath => "yank note path",
233 LeaderAction::Palette => "command palette",
234 LeaderAction::Help => "help / cheatsheet",
235 LeaderAction::NoteSave => "write (save now)",
236 LeaderAction::AppQuit => "quit kimün",
237 LeaderAction::AppOnboarding => "guided setup",
238 LeaderAction::AppCheckUpdates => "check for updates",
239 }
240 }
241}
242
243pub enum LeaderNode {
245 Group {
246 label: std::borrow::Cow<'static, str>,
248 children: Vec<(char, LeaderNode)>,
249 },
250 Leaf {
251 label: &'static str,
252 action: LeaderAction,
253 },
254}
255
256impl LeaderNode {
257 fn child(&self, key: char) -> Option<&LeaderNode> {
258 match self {
259 LeaderNode::Group { children, .. } => children
260 .iter()
261 .find(|(k, _)| *k == key)
262 .map(|(_, node)| node),
263 LeaderNode::Leaf { .. } => None,
264 }
265 }
266
267 pub fn label(&self) -> &str {
269 match self {
270 LeaderNode::Group { label, .. } => label,
271 LeaderNode::Leaf { label, .. } => label,
272 }
273 }
274
275 pub fn children(&self) -> &[(char, LeaderNode)] {
277 match self {
278 LeaderNode::Group { children, .. } => children,
279 LeaderNode::Leaf { .. } => &[],
280 }
281 }
282}
283
284pub fn leader_tree() -> LeaderNode {
287 use DrawerView as DV;
288 use LeaderAction as A;
289 use LeaderNode::{Group, Leaf};
290
291 fn leaf(label: &'static str, action: LeaderAction) -> LeaderNode {
292 Leaf { label, action }
293 }
294
295 Group {
296 label: "leader — pick a group".into(),
297 children: vec![
298 (
299 'f',
300 Group {
301 label: "+find".into(),
302 children: vec![
303 ('f', leaf("files", A::FindFiles)),
304 ('g', leaf("grep/query", A::FindGrep)),
305 ('t', leaf("tags", A::FindTags)),
306 ('b', leaf("backlinks", A::FindBacklinks)),
307 ('r', leaf("recent", A::FindRecent)),
308 ('s', leaf("saved searches", A::FindSaved)),
309 ('h', leaf("headings", A::FindHeadings)),
310 ],
311 },
312 ),
313 (
314 'n',
315 Group {
316 label: "+note".into(),
317 children: vec![
318 ('n', leaf("new", A::NoteNew)),
319 ('d', leaf("daily", A::NoteDaily)),
320 ('t', leaf("from template", A::NoteFromTemplate)),
321 ('r', leaf("rename", A::NoteRename)),
322 ('m', leaf("move", A::NoteMove)),
323 ('D', leaf("delete", A::NoteDelete)),
324 ('w', leaf("write (save now)", A::NoteSave)),
325 ],
326 },
327 ),
328 (
329 'l',
330 Group {
331 label: "+links".into(),
332 children: vec![
333 ('b', leaf("backlinks", A::LinksTab(LinksTab::Backlinks))),
334 ('o', leaf("outgoing", A::LinksTab(LinksTab::Outgoing))),
335 ('u', leaf("unlinked", A::LinksTab(LinksTab::Unlinked))),
336 ('g', leaf("local graph", A::LinksGraph)),
337 ],
338 },
339 ),
340 (
341 'o',
342 Group {
343 label: "+open drawer".into(),
344 children: vec![
345 ('f', leaf("files", A::OpenDrawer(DV::Files))),
346 ('q', leaf("find", A::OpenDrawer(DV::Find))),
347 ('t', leaf("tags", A::OpenDrawer(DV::Tags))),
348 ('k', leaf("links", A::OpenDrawer(DV::Links))),
349 ('l', leaf("outline", A::OpenDrawer(DV::Outline))),
350 ],
351 },
352 ),
353 (
354 'g',
355 Group {
356 label: "+git/sync".into(),
357 children: vec![
358 ('s', leaf("status", A::GitStatus)),
359 ('p', leaf("sync/push", A::GitSync)),
360 ('l', leaf("log", A::GitLog)),
361 ('d', leaf("diff", A::GitDiff)),
362 ],
363 },
364 ),
365 (
366 'v',
367 Group {
368 label: "+vault".into(),
369 children: vec![
370 ('s', leaf("switch vault", A::VaultSwitch)),
371 ('r', leaf("reindex", A::VaultReindex)),
372 ('c', leaf("config", A::VaultConfig)),
373 ('t', leaf("theme picker", A::VaultTheme)),
374 ('p', leaf("preferences", A::VaultPreferences)),
375 ('o', leaf("guided setup", A::AppOnboarding)),
376 ('u', leaf("check for updates", A::AppCheckUpdates)),
377 ],
378 },
379 ),
380 (
381 'w',
382 Group {
383 label: "+window".into(),
384 children: vec![
385 ('z', leaf("zen", A::WindowZen)),
386 ('v', leaf("split (soon)", A::WindowSplit)),
387 ('l', leaf("grow drawer", A::WindowGrowDrawer)),
388 ('h', leaf("shrink drawer", A::WindowShrinkDrawer)),
389 ],
390 },
391 ),
392 (
393 'm',
394 Group {
395 label: "+this note".into(),
396 children: vec![
397 ('t', leaf("toggle todo", A::NoteToggleTodo)),
398 ('p', leaf("preview", A::NotePreview)),
399 ('c', leaf("copy wikilink", A::NoteCopyWikilink)),
400 ('e', leaf("export (soon)", A::NoteExport)),
401 ('r', leaf("rename", A::NoteRename)),
404 ('y', leaf("yank note path", A::NoteYankPath)),
405 ],
406 },
407 ),
408 ('p', leaf("command palette", A::Palette)),
409 ('q', leaf("quit kimün", A::AppQuit)),
410 ('?', leaf("help / cheatsheet", A::Help)),
411 ],
412 }
413}
414
415pub fn apply_overrides<'a, I>(mut tree: LeaderNode, overrides: I) -> LeaderNode
421where
422 I: IntoIterator<Item = (&'a str, &'a str)>,
423{
424 for (seq, action_id) in overrides {
425 let keys: Vec<char> = seq
426 .split_whitespace()
427 .filter_map(|t| {
428 let mut chars = t.chars();
429 let c = chars.next()?;
430 chars.next().is_none().then_some(c)
431 })
432 .collect();
433 if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
434 tracing::warn!("[leader] ignoring invalid sequence {seq:?} (single-char keys only)");
435 continue;
436 }
437 if action_id.eq_ignore_ascii_case("none") {
438 remove_at(&mut tree, &keys);
439 continue;
440 }
441 let Some(action) = LeaderAction::from_id(action_id) else {
442 tracing::warn!("[leader] ignoring unknown action id {action_id:?} for {seq:?}");
443 continue;
444 };
445 insert_at(&mut tree, &keys, action);
446 }
447 tree
448}
449
450fn synth_group_label(key: char) -> std::borrow::Cow<'static, str> {
453 std::borrow::Cow::Owned(format!("+{key}"))
454}
455
456pub fn apply_labels<'a, I>(mut tree: LeaderNode, labels: I) -> LeaderNode
460where
461 I: IntoIterator<Item = (&'a str, &'a str)>,
462{
463 for (seq, label) in labels {
464 let keys: Vec<char> = seq
465 .split_whitespace()
466 .filter_map(|t| {
467 let mut chars = t.chars();
468 let c = chars.next()?;
469 chars.next().is_none().then_some(c)
470 })
471 .collect();
472 if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
473 tracing::warn!("[leader.labels] ignoring invalid sequence {seq:?}");
474 continue;
475 }
476 let mut node = Some(&mut tree);
477 for key in &keys {
478 node = node.and_then(|n| match n {
479 LeaderNode::Group { children, .. } => children
480 .iter_mut()
481 .find(|(k, _)| k == key)
482 .map(|(_, child)| child),
483 LeaderNode::Leaf { .. } => None,
484 });
485 }
486 match node {
487 Some(LeaderNode::Group { label: slot, .. }) => {
488 *slot = std::borrow::Cow::Owned(label.to_string());
489 }
490 _ => tracing::warn!("[leader.labels] {seq:?} is not a group; ignored"),
491 }
492 }
493 tree
494}
495
496fn insert_at(node: &mut LeaderNode, keys: &[char], action: LeaderAction) {
497 let LeaderNode::Group { children, .. } = node else {
498 return; };
500 let (head, rest) = (keys[0], &keys[1..]);
501 if rest.is_empty() {
502 let leaf = LeaderNode::Leaf {
503 label: action.default_label(),
504 action,
505 };
506 if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
507 if matches!(child, LeaderNode::Group { .. }) {
508 tracing::warn!(
511 "[leader.bind] key {head:?} replaces an entire group with \
512 a single action — its sub-bindings are gone"
513 );
514 }
515 *child = leaf;
516 } else {
517 children.push((head, leaf));
518 }
519 return;
520 }
521 let needs_group = !matches!(
523 children.iter().find(|(k, _)| *k == head),
524 Some((_, LeaderNode::Group { .. }))
525 );
526 if needs_group {
527 let group = LeaderNode::Group {
528 label: synth_group_label(head),
529 children: Vec::new(),
530 };
531 if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
532 *child = group;
533 } else {
534 children.push((head, group));
535 }
536 }
537 let (_, child) = children
538 .iter_mut()
539 .find(|(k, _)| *k == head)
540 .expect("just ensured");
541 insert_at(child, rest, action);
542}
543
544fn remove_at(node: &mut LeaderNode, keys: &[char]) {
545 let LeaderNode::Group { children, .. } = node else {
546 return;
547 };
548 let (head, rest) = (keys[0], &keys[1..]);
549 if rest.is_empty() {
550 children.retain(|(k, _)| *k != head);
551 return;
552 }
553 if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
554 remove_at(child, rest);
555 if matches!(child, LeaderNode::Group { children, .. } if children.is_empty()) {
557 children.retain(|(k, _)| *k != head);
558 }
559 }
560}
561
562#[derive(Debug, PartialEq, Eq)]
564pub enum LeaderOutcome {
565 Descended,
567 Fired(LeaderAction),
569 Invalid,
571 Cancelled,
573 SteppedUp,
576}
577
578pub struct LeaderEngine {
581 tree: LeaderNode,
582 path: Vec<char>,
584 since: Option<Instant>,
587}
588
589impl LeaderEngine {
590 pub fn new() -> Self {
591 Self::with_tree(leader_tree())
592 }
593
594 pub fn with_tree(tree: LeaderNode) -> Self {
598 Self {
599 tree,
600 path: Vec::new(),
601 since: None,
602 }
603 }
604
605 pub fn tree(&self) -> &LeaderNode {
608 &self.tree
609 }
610
611 pub fn is_pending(&self) -> bool {
612 self.since.is_some()
613 }
614
615 pub fn path(&self) -> &[char] {
617 &self.path
618 }
619
620 pub fn pending_since(&self) -> Option<Instant> {
622 self.since
623 }
624
625 pub fn current_node(&self) -> &LeaderNode {
627 let mut node = &self.tree;
628 for key in &self.path {
629 match node.child(*key) {
630 Some(next) => node = next,
631 None => break,
632 }
633 }
634 node
635 }
636
637 pub fn start(&mut self) {
639 self.path.clear();
640 self.since = Some(Instant::now());
641 }
642
643 pub fn cancel(&mut self) {
645 self.path.clear();
646 self.since = None;
647 }
648
649 pub fn feed(&mut self, key: char) -> LeaderOutcome {
651 debug_assert!(self.is_pending());
652 match self.current_node().child(key) {
653 Some(LeaderNode::Leaf { action, .. }) => {
654 let action = *action;
655 self.cancel();
656 LeaderOutcome::Fired(action)
657 }
658 Some(LeaderNode::Group { .. }) => {
659 self.path.push(key);
660 self.since = Some(Instant::now());
661 LeaderOutcome::Descended
662 }
663 None => {
664 self.since = Some(Instant::now());
667 LeaderOutcome::Invalid
668 }
669 }
670 }
671
672 pub fn step_up(&mut self) -> LeaderOutcome {
674 if self.path.pop().is_some() {
675 self.since = Some(Instant::now());
676 LeaderOutcome::SteppedUp
677 } else {
678 self.cancel();
679 LeaderOutcome::Cancelled
680 }
681 }
682}
683
684impl Default for LeaderEngine {
685 fn default() -> Self {
686 Self::new()
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn full_sequence_fires_leaf() {
696 let mut e = LeaderEngine::new();
697 e.start();
698 assert_eq!(e.feed('o'), LeaderOutcome::Descended);
699 assert_eq!(
700 e.feed('f'),
701 LeaderOutcome::Fired(LeaderAction::OpenDrawer(DrawerView::Files))
702 );
703 assert!(!e.is_pending());
704 }
705
706 #[test]
707 fn invalid_key_keeps_sequence_pending() {
708 let mut e = LeaderEngine::new();
709 e.start();
710 assert_eq!(e.feed('x'), LeaderOutcome::Invalid);
711 assert!(e.is_pending());
712 assert_eq!(e.feed('o'), LeaderOutcome::Descended);
713 }
714
715 #[test]
716 fn backspace_steps_up_then_cancels() {
717 let mut e = LeaderEngine::new();
718 e.start();
719 e.feed('f');
720 assert_eq!(e.step_up(), LeaderOutcome::SteppedUp);
721 assert!(e.is_pending());
722 assert_eq!(e.step_up(), LeaderOutcome::Cancelled);
723 assert!(!e.is_pending());
724 }
725
726 #[test]
727 fn cancel_disarms() {
728 let mut e = LeaderEngine::new();
729 e.start();
730 e.feed('n');
731 e.cancel();
732 assert!(!e.is_pending());
733 assert!(e.path().is_empty());
734 }
735
736 #[test]
737 fn tree_matches_spec_groups() {
738 let tree = leader_tree();
739 let groups: Vec<char> = tree.children().iter().map(|(k, _)| *k).collect();
740 assert_eq!(
741 groups,
742 vec!['f', 'n', 'l', 'o', 'g', 'v', 'w', 'm', 'p', 'q', '?']
743 );
744 let mut e = LeaderEngine::new();
746 e.start();
747 e.feed('f');
748 assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
749 e.start();
750 e.feed('n');
751 assert_eq!(e.feed('n'), LeaderOutcome::Fired(LeaderAction::NoteNew));
752 }
753
754 #[test]
755 fn overrides_remap_add_and_remove() {
756 let tree = apply_overrides(
757 leader_tree(),
758 [
759 ("o f", "find.files"), ("x", "note.daily"), ("y z", "vault.theme"), ("g p", "none"), ("bad seq!", "note.new"), ("A", "no.such.action"), ],
766 );
767 let mut e = LeaderEngine::with_tree(tree);
768
769 e.start();
770 e.feed('o');
771 assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
772
773 e.start();
774 assert_eq!(e.feed('x'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
775
776 e.start();
777 assert_eq!(e.feed('y'), LeaderOutcome::Descended);
778 assert_eq!(e.feed('z'), LeaderOutcome::Fired(LeaderAction::VaultTheme));
779
780 e.start();
781 e.feed('g');
782 assert_eq!(e.feed('p'), LeaderOutcome::Invalid); e.start();
785 assert_eq!(e.feed('A'), LeaderOutcome::Invalid); }
787
788 #[test]
789 fn labels_rename_groups_including_synth_ones() {
790 let tree = apply_overrides(leader_tree(), [("y z", "vault.theme")]);
791 let tree = apply_labels(
792 tree,
793 [
794 ("f", "+search"), ("y", "+mine"), ("n n", "+nope"), ("zz", "+bad"), ],
799 );
800 let find = tree.children().iter().find(|(k, _)| *k == 'f').unwrap();
801 assert_eq!(find.1.label(), "+search");
802 let mine = tree.children().iter().find(|(k, _)| *k == 'y').unwrap();
803 assert_eq!(mine.1.label(), "+mine");
804 let note = tree.children().iter().find(|(k, _)| *k == 'n').unwrap();
806 let nn = note.1.children().iter().find(|(k, _)| *k == 'n').unwrap();
807 assert_eq!(nn.1.label(), "new");
808 }
809
810 #[test]
814 fn every_tree_leaf_is_id_addressable() {
815 fn walk(node: &LeaderNode, out: &mut Vec<LeaderAction>) {
816 for (_, child) in node.children() {
817 match child {
818 LeaderNode::Leaf { action, .. } => out.push(*action),
819 LeaderNode::Group { .. } => walk(child, out),
820 }
821 }
822 }
823 let mut leaves = Vec::new();
824 walk(&leader_tree(), &mut leaves);
825 for action in leaves {
826 assert_eq!(
827 LeaderAction::from_id(action.id()),
828 Some(action),
829 "{action:?} (id {:?}) missing from LeaderAction::ALL",
830 action.id()
831 );
832 }
833 }
834
835 #[test]
836 fn action_ids_round_trip() {
837 for action in LeaderAction::ALL {
838 assert_eq!(
839 LeaderAction::from_id(action.id()),
840 Some(action),
841 "id round-trip failed for {action:?}"
842 );
843 }
844 assert_eq!(LeaderAction::from_id("help"), Some(LeaderAction::Help));
845 assert_eq!(LeaderAction::from_id("nope"), None);
846 }
847
848 #[test]
849 fn capital_letters_are_distinct_keys() {
850 let mut e = LeaderEngine::new();
851 e.start();
852 e.feed('n');
853 assert_eq!(e.feed('d'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
854 e.start();
855 e.feed('n');
856 assert_eq!(e.feed('D'), LeaderOutcome::Fired(LeaderAction::NoteDelete));
857 }
858
859 #[test]
860 fn app_onboarding_round_trip_from_id() {
861 assert_eq!(
862 LeaderAction::from_id("app.onboarding"),
863 Some(LeaderAction::AppOnboarding)
864 );
865 assert_eq!(LeaderAction::AppOnboarding.id(), "app.onboarding");
866 }
867
868 #[test]
869 fn note_save_and_app_quit_round_trip_from_id() {
870 assert_eq!(
871 LeaderAction::from_id("note.save"),
872 Some(LeaderAction::NoteSave)
873 );
874 assert_eq!(
875 LeaderAction::from_id("app.quit"),
876 Some(LeaderAction::AppQuit)
877 );
878 assert_eq!(LeaderAction::NoteSave.id(), "note.save");
879 assert_eq!(LeaderAction::AppQuit.id(), "app.quit");
880 }
881}