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