use std::time::Instant;
use crate::components::drawer::DrawerView;
use crate::components::drawer_views::LinksTab;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaderAction {
OpenDrawer(DrawerView),
FindFiles,
FindGrep,
FindTags,
FindBacklinks,
FindRecent,
FindSaved,
FindHeadings,
NoteNew,
NoteDaily,
NoteFromTemplate,
NoteRename,
NoteMove,
NoteDelete,
LinksTab(LinksTab),
LinksGraph,
GitStatus,
GitSync,
GitLog,
GitDiff,
VaultSwitch,
VaultReindex,
VaultConfig,
VaultTheme,
VaultPreferences,
WindowZen,
WindowSplit,
WindowGrowDrawer,
WindowShrinkDrawer,
NoteToggleTodo,
NotePreview,
NoteCopyWikilink,
NoteExport,
NoteYankPath,
Palette,
Help,
}
impl LeaderAction {
pub fn id(&self) -> &'static str {
match self {
LeaderAction::OpenDrawer(DrawerView::Files) => "drawer.files",
LeaderAction::OpenDrawer(DrawerView::Find) => "drawer.find",
LeaderAction::OpenDrawer(DrawerView::Tags) => "drawer.tags",
LeaderAction::OpenDrawer(DrawerView::Links) => "drawer.links",
LeaderAction::OpenDrawer(DrawerView::Outline) => "drawer.outline",
LeaderAction::OpenDrawer(DrawerView::Config) => "drawer.config",
LeaderAction::FindFiles => "find.files",
LeaderAction::FindGrep => "find.grep",
LeaderAction::FindTags => "find.tags",
LeaderAction::FindBacklinks => "find.backlinks",
LeaderAction::FindRecent => "find.recent",
LeaderAction::FindSaved => "find.saved",
LeaderAction::FindHeadings => "find.headings",
LeaderAction::NoteNew => "note.new",
LeaderAction::NoteDaily => "note.daily",
LeaderAction::NoteFromTemplate => "note.template",
LeaderAction::NoteRename => "note.rename",
LeaderAction::NoteMove => "note.move",
LeaderAction::NoteDelete => "note.delete",
LeaderAction::LinksTab(LinksTab::Backlinks) => "links.backlinks",
LeaderAction::LinksTab(LinksTab::Outgoing) => "links.outgoing",
LeaderAction::LinksTab(LinksTab::Unlinked) => "links.unlinked",
LeaderAction::LinksGraph => "links.graph",
LeaderAction::GitStatus => "git.status",
LeaderAction::GitSync => "git.sync",
LeaderAction::GitLog => "git.log",
LeaderAction::GitDiff => "git.diff",
LeaderAction::VaultSwitch => "vault.switch",
LeaderAction::VaultReindex => "vault.reindex",
LeaderAction::VaultConfig => "vault.config",
LeaderAction::VaultTheme => "vault.theme",
LeaderAction::VaultPreferences => "vault.settings",
LeaderAction::WindowZen => "window.zen",
LeaderAction::WindowSplit => "window.split",
LeaderAction::WindowGrowDrawer => "window.grow",
LeaderAction::WindowShrinkDrawer => "window.shrink",
LeaderAction::NoteToggleTodo => "this.todo",
LeaderAction::NotePreview => "this.preview",
LeaderAction::NoteCopyWikilink => "this.copy-link",
LeaderAction::NoteExport => "this.export",
LeaderAction::NoteYankPath => "this.yank-path",
LeaderAction::Palette => "palette",
LeaderAction::Help => "help",
}
}
pub const ALL: [LeaderAction; 42] = [
LeaderAction::OpenDrawer(DrawerView::Files),
LeaderAction::OpenDrawer(DrawerView::Find),
LeaderAction::OpenDrawer(DrawerView::Tags),
LeaderAction::OpenDrawer(DrawerView::Links),
LeaderAction::OpenDrawer(DrawerView::Outline),
LeaderAction::OpenDrawer(DrawerView::Config),
LeaderAction::FindFiles,
LeaderAction::FindGrep,
LeaderAction::FindTags,
LeaderAction::FindBacklinks,
LeaderAction::FindRecent,
LeaderAction::FindSaved,
LeaderAction::FindHeadings,
LeaderAction::NoteNew,
LeaderAction::NoteDaily,
LeaderAction::NoteFromTemplate,
LeaderAction::NoteRename,
LeaderAction::NoteMove,
LeaderAction::NoteDelete,
LeaderAction::LinksTab(LinksTab::Backlinks),
LeaderAction::LinksTab(LinksTab::Outgoing),
LeaderAction::LinksTab(LinksTab::Unlinked),
LeaderAction::LinksGraph,
LeaderAction::GitStatus,
LeaderAction::GitSync,
LeaderAction::GitLog,
LeaderAction::GitDiff,
LeaderAction::VaultSwitch,
LeaderAction::VaultReindex,
LeaderAction::VaultConfig,
LeaderAction::VaultTheme,
LeaderAction::VaultPreferences,
LeaderAction::WindowZen,
LeaderAction::WindowSplit,
LeaderAction::WindowGrowDrawer,
LeaderAction::WindowShrinkDrawer,
LeaderAction::NoteToggleTodo,
LeaderAction::NotePreview,
LeaderAction::NoteCopyWikilink,
LeaderAction::NoteExport,
LeaderAction::NoteYankPath,
LeaderAction::Palette,
];
pub fn from_id(id: &str) -> Option<LeaderAction> {
if id == "help" {
return Some(LeaderAction::Help);
}
if id == "vault.preferences" {
return Some(LeaderAction::VaultPreferences);
}
Self::ALL.into_iter().find(|a| a.id() == id)
}
pub fn default_label(&self) -> &'static str {
match self {
LeaderAction::OpenDrawer(_) => "open drawer",
LeaderAction::FindFiles => "files",
LeaderAction::FindGrep => "grep/query",
LeaderAction::FindTags => "tags",
LeaderAction::FindBacklinks => "backlinks",
LeaderAction::FindRecent => "recent",
LeaderAction::FindSaved => "saved searches",
LeaderAction::FindHeadings => "headings",
LeaderAction::NoteNew => "new note",
LeaderAction::NoteDaily => "daily",
LeaderAction::NoteFromTemplate => "from template",
LeaderAction::NoteRename => "rename",
LeaderAction::NoteMove => "move",
LeaderAction::NoteDelete => "delete",
LeaderAction::LinksTab(_) => "links",
LeaderAction::LinksGraph => "local graph",
LeaderAction::GitStatus => "git status",
LeaderAction::GitSync => "git sync",
LeaderAction::GitLog => "git log",
LeaderAction::GitDiff => "git diff",
LeaderAction::VaultSwitch => "switch vault",
LeaderAction::VaultReindex => "reindex",
LeaderAction::VaultConfig => "config",
LeaderAction::VaultTheme => "theme picker",
LeaderAction::VaultPreferences => "preferences",
LeaderAction::WindowZen => "zen",
LeaderAction::WindowSplit => "split",
LeaderAction::WindowGrowDrawer => "grow drawer",
LeaderAction::WindowShrinkDrawer => "shrink drawer",
LeaderAction::NoteToggleTodo => "toggle todo",
LeaderAction::NotePreview => "preview",
LeaderAction::NoteCopyWikilink => "copy wikilink",
LeaderAction::NoteExport => "export",
LeaderAction::NoteYankPath => "yank note path",
LeaderAction::Palette => "command palette",
LeaderAction::Help => "help / cheatsheet",
}
}
}
pub enum LeaderNode {
Group {
label: std::borrow::Cow<'static, str>,
children: Vec<(char, LeaderNode)>,
},
Leaf {
label: &'static str,
action: LeaderAction,
},
}
impl LeaderNode {
fn child(&self, key: char) -> Option<&LeaderNode> {
match self {
LeaderNode::Group { children, .. } => children
.iter()
.find(|(k, _)| *k == key)
.map(|(_, node)| node),
LeaderNode::Leaf { .. } => None,
}
}
pub fn label(&self) -> &str {
match self {
LeaderNode::Group { label, .. } => label,
LeaderNode::Leaf { label, .. } => label,
}
}
pub fn children(&self) -> &[(char, LeaderNode)] {
match self {
LeaderNode::Group { children, .. } => children,
LeaderNode::Leaf { .. } => &[],
}
}
}
pub fn leader_tree() -> LeaderNode {
use DrawerView as DV;
use LeaderAction as A;
use LeaderNode::{Group, Leaf};
fn leaf(label: &'static str, action: LeaderAction) -> LeaderNode {
Leaf { label, action }
}
Group {
label: "leader — pick a group".into(),
children: vec![
(
'f',
Group {
label: "+find".into(),
children: vec![
('f', leaf("files", A::FindFiles)),
('g', leaf("grep/query", A::FindGrep)),
('t', leaf("tags", A::FindTags)),
('b', leaf("backlinks", A::FindBacklinks)),
('r', leaf("recent", A::FindRecent)),
('s', leaf("saved searches", A::FindSaved)),
('h', leaf("headings", A::FindHeadings)),
],
},
),
(
'n',
Group {
label: "+note".into(),
children: vec![
('n', leaf("new", A::NoteNew)),
('d', leaf("daily", A::NoteDaily)),
('t', leaf("from template", A::NoteFromTemplate)),
('r', leaf("rename", A::NoteRename)),
('m', leaf("move", A::NoteMove)),
('D', leaf("delete", A::NoteDelete)),
],
},
),
(
'l',
Group {
label: "+links".into(),
children: vec![
('b', leaf("backlinks", A::LinksTab(LinksTab::Backlinks))),
('o', leaf("outgoing", A::LinksTab(LinksTab::Outgoing))),
('u', leaf("unlinked", A::LinksTab(LinksTab::Unlinked))),
('g', leaf("local graph", A::LinksGraph)),
],
},
),
(
'o',
Group {
label: "+open drawer".into(),
children: vec![
('f', leaf("files", A::OpenDrawer(DV::Files))),
('q', leaf("find", A::OpenDrawer(DV::Find))),
('t', leaf("tags", A::OpenDrawer(DV::Tags))),
('k', leaf("links", A::OpenDrawer(DV::Links))),
('l', leaf("outline", A::OpenDrawer(DV::Outline))),
],
},
),
(
'g',
Group {
label: "+git/sync".into(),
children: vec![
('s', leaf("status", A::GitStatus)),
('p', leaf("sync/push", A::GitSync)),
('l', leaf("log", A::GitLog)),
('d', leaf("diff", A::GitDiff)),
],
},
),
(
'v',
Group {
label: "+vault".into(),
children: vec![
('s', leaf("switch vault", A::VaultSwitch)),
('r', leaf("reindex", A::VaultReindex)),
('c', leaf("config", A::VaultConfig)),
('t', leaf("theme picker", A::VaultTheme)),
('p', leaf("preferences", A::VaultPreferences)),
],
},
),
(
'w',
Group {
label: "+window".into(),
children: vec![
('z', leaf("zen", A::WindowZen)),
('v', leaf("split (soon)", A::WindowSplit)),
('l', leaf("grow drawer", A::WindowGrowDrawer)),
('h', leaf("shrink drawer", A::WindowShrinkDrawer)),
],
},
),
(
'm',
Group {
label: "+this note".into(),
children: vec![
('t', leaf("toggle todo", A::NoteToggleTodo)),
('p', leaf("preview", A::NotePreview)),
('c', leaf("copy wikilink", A::NoteCopyWikilink)),
('e', leaf("export (soon)", A::NoteExport)),
('r', leaf("rename", A::NoteRename)),
('y', leaf("yank note path", A::NoteYankPath)),
],
},
),
('p', leaf("command palette", A::Palette)),
('?', leaf("help / cheatsheet", A::Help)),
],
}
}
pub fn apply_overrides<'a, I>(mut tree: LeaderNode, overrides: I) -> LeaderNode
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
for (seq, action_id) in overrides {
let keys: Vec<char> = seq
.split_whitespace()
.filter_map(|t| {
let mut chars = t.chars();
let c = chars.next()?;
chars.next().is_none().then_some(c)
})
.collect();
if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
tracing::warn!("[leader] ignoring invalid sequence {seq:?} (single-char keys only)");
continue;
}
if action_id.eq_ignore_ascii_case("none") {
remove_at(&mut tree, &keys);
continue;
}
let Some(action) = LeaderAction::from_id(action_id) else {
tracing::warn!("[leader] ignoring unknown action id {action_id:?} for {seq:?}");
continue;
};
insert_at(&mut tree, &keys, action);
}
tree
}
fn synth_group_label(key: char) -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Owned(format!("+{key}"))
}
pub fn apply_labels<'a, I>(mut tree: LeaderNode, labels: I) -> LeaderNode
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
for (seq, label) in labels {
let keys: Vec<char> = seq
.split_whitespace()
.filter_map(|t| {
let mut chars = t.chars();
let c = chars.next()?;
chars.next().is_none().then_some(c)
})
.collect();
if keys.is_empty() || keys.len() != seq.split_whitespace().count() {
tracing::warn!("[leader.labels] ignoring invalid sequence {seq:?}");
continue;
}
let mut node = Some(&mut tree);
for key in &keys {
node = node.and_then(|n| match n {
LeaderNode::Group { children, .. } => children
.iter_mut()
.find(|(k, _)| k == key)
.map(|(_, child)| child),
LeaderNode::Leaf { .. } => None,
});
}
match node {
Some(LeaderNode::Group { label: slot, .. }) => {
*slot = std::borrow::Cow::Owned(label.to_string());
}
_ => tracing::warn!("[leader.labels] {seq:?} is not a group; ignored"),
}
}
tree
}
fn insert_at(node: &mut LeaderNode, keys: &[char], action: LeaderAction) {
let LeaderNode::Group { children, .. } = node else {
return; };
let (head, rest) = (keys[0], &keys[1..]);
if rest.is_empty() {
let leaf = LeaderNode::Leaf {
label: action.default_label(),
action,
};
if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
if matches!(child, LeaderNode::Group { .. }) {
tracing::warn!(
"[leader.bind] key {head:?} replaces an entire group with \
a single action — its sub-bindings are gone"
);
}
*child = leaf;
} else {
children.push((head, leaf));
}
return;
}
let needs_group = !matches!(
children.iter().find(|(k, _)| *k == head),
Some((_, LeaderNode::Group { .. }))
);
if needs_group {
let group = LeaderNode::Group {
label: synth_group_label(head),
children: Vec::new(),
};
if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
*child = group;
} else {
children.push((head, group));
}
}
let (_, child) = children
.iter_mut()
.find(|(k, _)| *k == head)
.expect("just ensured");
insert_at(child, rest, action);
}
fn remove_at(node: &mut LeaderNode, keys: &[char]) {
let LeaderNode::Group { children, .. } = node else {
return;
};
let (head, rest) = (keys[0], &keys[1..]);
if rest.is_empty() {
children.retain(|(k, _)| *k != head);
return;
}
if let Some((_, child)) = children.iter_mut().find(|(k, _)| *k == head) {
remove_at(child, rest);
if matches!(child, LeaderNode::Group { children, .. } if children.is_empty()) {
children.retain(|(k, _)| *k != head);
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum LeaderOutcome {
Descended,
Fired(LeaderAction),
Invalid,
Cancelled,
SteppedUp,
}
pub struct LeaderEngine {
tree: LeaderNode,
path: Vec<char>,
since: Option<Instant>,
}
impl LeaderEngine {
pub fn new() -> Self {
Self::with_tree(leader_tree())
}
pub fn with_tree(tree: LeaderNode) -> Self {
Self {
tree,
path: Vec::new(),
since: None,
}
}
pub fn tree(&self) -> &LeaderNode {
&self.tree
}
pub fn is_pending(&self) -> bool {
self.since.is_some()
}
pub fn path(&self) -> &[char] {
&self.path
}
pub fn pending_since(&self) -> Option<Instant> {
self.since
}
pub fn current_node(&self) -> &LeaderNode {
let mut node = &self.tree;
for key in &self.path {
match node.child(*key) {
Some(next) => node = next,
None => break,
}
}
node
}
pub fn start(&mut self) {
self.path.clear();
self.since = Some(Instant::now());
}
pub fn cancel(&mut self) {
self.path.clear();
self.since = None;
}
pub fn feed(&mut self, key: char) -> LeaderOutcome {
debug_assert!(self.is_pending());
match self.current_node().child(key) {
Some(LeaderNode::Leaf { action, .. }) => {
let action = *action;
self.cancel();
LeaderOutcome::Fired(action)
}
Some(LeaderNode::Group { .. }) => {
self.path.push(key);
self.since = Some(Instant::now());
LeaderOutcome::Descended
}
None => {
self.since = Some(Instant::now());
LeaderOutcome::Invalid
}
}
}
pub fn step_up(&mut self) -> LeaderOutcome {
if self.path.pop().is_some() {
self.since = Some(Instant::now());
LeaderOutcome::SteppedUp
} else {
self.cancel();
LeaderOutcome::Cancelled
}
}
}
impl Default for LeaderEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_sequence_fires_leaf() {
let mut e = LeaderEngine::new();
e.start();
assert_eq!(e.feed('o'), LeaderOutcome::Descended);
assert_eq!(
e.feed('f'),
LeaderOutcome::Fired(LeaderAction::OpenDrawer(DrawerView::Files))
);
assert!(!e.is_pending());
}
#[test]
fn invalid_key_keeps_sequence_pending() {
let mut e = LeaderEngine::new();
e.start();
assert_eq!(e.feed('x'), LeaderOutcome::Invalid);
assert!(e.is_pending());
assert_eq!(e.feed('o'), LeaderOutcome::Descended);
}
#[test]
fn backspace_steps_up_then_cancels() {
let mut e = LeaderEngine::new();
e.start();
e.feed('f');
assert_eq!(e.step_up(), LeaderOutcome::SteppedUp);
assert!(e.is_pending());
assert_eq!(e.step_up(), LeaderOutcome::Cancelled);
assert!(!e.is_pending());
}
#[test]
fn cancel_disarms() {
let mut e = LeaderEngine::new();
e.start();
e.feed('n');
e.cancel();
assert!(!e.is_pending());
assert!(e.path().is_empty());
}
#[test]
fn tree_matches_spec_groups() {
let tree = leader_tree();
let groups: Vec<char> = tree.children().iter().map(|(k, _)| *k).collect();
assert_eq!(
groups,
vec!['f', 'n', 'l', 'o', 'g', 'v', 'w', 'm', 'p', '?']
);
let mut e = LeaderEngine::new();
e.start();
e.feed('f');
assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
e.start();
e.feed('n');
assert_eq!(e.feed('n'), LeaderOutcome::Fired(LeaderAction::NoteNew));
}
#[test]
fn overrides_remap_add_and_remove() {
let tree = apply_overrides(
leader_tree(),
[
("o f", "find.files"), ("x", "note.daily"), ("y z", "vault.theme"), ("g p", "none"), ("bad seq!", "note.new"), ("q", "no.such.action"), ],
);
let mut e = LeaderEngine::with_tree(tree);
e.start();
e.feed('o');
assert_eq!(e.feed('f'), LeaderOutcome::Fired(LeaderAction::FindFiles));
e.start();
assert_eq!(e.feed('x'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
e.start();
assert_eq!(e.feed('y'), LeaderOutcome::Descended);
assert_eq!(e.feed('z'), LeaderOutcome::Fired(LeaderAction::VaultTheme));
e.start();
e.feed('g');
assert_eq!(e.feed('p'), LeaderOutcome::Invalid);
e.start();
assert_eq!(e.feed('q'), LeaderOutcome::Invalid); }
#[test]
fn labels_rename_groups_including_synth_ones() {
let tree = apply_overrides(leader_tree(), [("y z", "vault.theme")]);
let tree = apply_labels(
tree,
[
("f", "+search"), ("y", "+mine"), ("n n", "+nope"), ("zz", "+bad"), ],
);
let find = tree.children().iter().find(|(k, _)| *k == 'f').unwrap();
assert_eq!(find.1.label(), "+search");
let mine = tree.children().iter().find(|(k, _)| *k == 'y').unwrap();
assert_eq!(mine.1.label(), "+mine");
let note = tree.children().iter().find(|(k, _)| *k == 'n').unwrap();
let nn = note.1.children().iter().find(|(k, _)| *k == 'n').unwrap();
assert_eq!(nn.1.label(), "new");
}
#[test]
fn every_tree_leaf_is_id_addressable() {
fn walk(node: &LeaderNode, out: &mut Vec<LeaderAction>) {
for (_, child) in node.children() {
match child {
LeaderNode::Leaf { action, .. } => out.push(*action),
LeaderNode::Group { .. } => walk(child, out),
}
}
}
let mut leaves = Vec::new();
walk(&leader_tree(), &mut leaves);
for action in leaves {
assert_eq!(
LeaderAction::from_id(action.id()),
Some(action),
"{action:?} (id {:?}) missing from LeaderAction::ALL",
action.id()
);
}
}
#[test]
fn action_ids_round_trip() {
for action in LeaderAction::ALL {
assert_eq!(
LeaderAction::from_id(action.id()),
Some(action),
"id round-trip failed for {action:?}"
);
}
assert_eq!(LeaderAction::from_id("help"), Some(LeaderAction::Help));
assert_eq!(LeaderAction::from_id("nope"), None);
}
#[test]
fn capital_letters_are_distinct_keys() {
let mut e = LeaderEngine::new();
e.start();
e.feed('n');
assert_eq!(e.feed('d'), LeaderOutcome::Fired(LeaderAction::NoteDaily));
e.start();
e.feed('n');
assert_eq!(e.feed('D'), LeaderOutcome::Fired(LeaderAction::NoteDelete));
}
}