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