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