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