Files
ui-agregator/src/input/leader.rs
T
Alexander fcefcc02a0 feat: implement harmony TUI with vim/evil-mode navigation and SPC leader
Full Ratatui implementation of the harmony music library manager prototype:
- 6 tab views (Library 3-pane, Wanted, Queue, History, Calendar, Settings)
- Vim/evil-mode keybindings (hjkl, counts, gg/G, w/b/e, Ctrl-d/u, H/M/L, marks, operator-pending)
- SPC leader key with which-key popup (Doom Emacs style)
- Command mode (:q, :theme, :help) and / search filter
- Help and quit confirmation modals
- Toast notification system with auto-dismiss
- Gruvbox dark theme throughout
2026-05-08 13:26:09 +02:00

287 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SPC leader key tree (Doom Emacs / which-key style).
//!
//! Builds a tree of keybindings accessible via the SPC leader key.
use crate::app::{Mode, Tab};
/// Action to execute when a leader key sequence is completed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeaderAction {
/// Switch to a tab
SetTab(Tab),
/// Set input mode
SetMode(Mode),
/// Navigate to next/prev tab
NextTab,
PrevTab,
/// Toggle monitor on selected item
ToggleMonitor,
/// Refresh/rescan library
Refresh,
/// Search for release
SearchRelease,
/// Cycle theme
CycleTheme,
/// Set specific theme
SetThemeDark,
SetThemeLight,
/// Show notifications
ShowNotifications,
/// Dismiss all notifications
DismissNotifications,
/// Show help
ShowHelp,
/// Show add modal
ShowAdd,
/// Show quit confirm
ShowQuit,
/// Sync/save library
SyncLibrary,
}
/// A node in the leader key tree.
#[derive(Debug, Clone)]
pub struct LeaderNode {
/// Display name for this node
pub name: String,
/// Child nodes (None if this is a leaf action)
pub children: Option<Vec<(char, LeaderNode)>>,
/// Action to execute (None if this is a group)
pub action: Option<LeaderAction>,
}
impl LeaderNode {
/// Create a new group node.
pub fn group(name: impl Into<String>, children: Vec<(char, LeaderNode)>) -> Self {
Self {
name: name.into(),
children: Some(children),
action: None,
}
}
/// Create a new leaf action node.
pub fn action(name: impl Into<String>, action: LeaderAction) -> Self {
Self {
name: name.into(),
children: None,
action: Some(action),
}
}
/// Get a child node by key.
pub fn get_child(&self, key: char) -> Option<&LeaderNode> {
self.children
.as_ref()
.and_then(|children| children.iter().find(|(k, _)| *k == key).map(|(_, n)| n))
}
/// Check if this is a group (has children).
pub fn is_group(&self) -> bool {
self.children.is_some()
}
}
/// Result of navigating the leader tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeaderResult {
/// Navigation is pending (entered a group)
Pending,
/// An action was executed
Executed(LeaderAction),
/// Key was not bound
NotBound,
}
/// State for the leader key system.
#[derive(Debug, Default)]
pub struct LeaderState {
/// Current path in the tree (e.g., ['b'] for SPC b ...)
pub path: Vec<char>,
/// Whether the leader menu is currently active
pub active: bool,
}
impl LeaderState {
/// Create a new leader state.
pub fn new() -> Self {
Self::default()
}
/// Activate the leader menu.
pub fn activate(&mut self) {
self.active = true;
self.path.clear();
}
/// Deactivate the leader menu.
pub fn deactivate(&mut self) {
self.active = false;
self.path.clear();
}
/// Go back one level (Backspace behavior).
pub fn go_back(&mut self) {
if self.path.is_empty() {
self.deactivate();
} else {
self.path.pop();
}
}
/// Navigate to a key in the tree.
pub fn navigate(&mut self, key: char, tree: &LeaderNode) -> LeaderResult {
let current = self.current_node(tree);
let Some(current) = current else {
self.deactivate();
return LeaderResult::NotBound;
};
match current.get_child(key) {
Some(child) => {
if child.is_group() {
self.path.push(key);
LeaderResult::Pending
} else if let Some(action) = &child.action {
let action = action.clone();
self.deactivate();
LeaderResult::Executed(action)
} else {
self.deactivate();
LeaderResult::NotBound
}
}
None => {
self.deactivate();
LeaderResult::NotBound
}
}
}
/// Get the current node based on the path.
pub fn current_node<'a>(&self, tree: &'a LeaderNode) -> Option<&'a LeaderNode> {
let mut node = tree;
for &key in &self.path {
node = node.get_child(key)?;
}
Some(node)
}
/// Get the breadcrumb string for display (e.g., "SPC b").
pub fn breadcrumb(&self) -> String {
if self.path.is_empty() {
"SPC".to_string()
} else {
let path_str: String = self.path.iter().map(|c| format!(" {}", c)).collect();
format!("SPC{}", path_str)
}
}
}
/// Build the leader key tree matching the app.jsx specification.
pub fn build_leader_tree() -> LeaderNode {
LeaderNode::group(
"leader",
vec![
// SPC SPC / SPC : → command mode
(' ', LeaderNode::action("M-x command", LeaderAction::SetMode(Mode::Command))),
(':', LeaderNode::action("command", LeaderAction::SetMode(Mode::Command))),
// SPC / → search mode
('/', LeaderNode::action("filter", LeaderAction::SetMode(Mode::Search))),
// SPC ? → help
('?', LeaderNode::action("help", LeaderAction::ShowHelp)),
// SPC b → +buffer (tabs)
(
'b',
LeaderNode::group(
"+buffer",
vec![
('l', LeaderNode::action("library", LeaderAction::SetTab(Tab::Library))),
('w', LeaderNode::action("wanted", LeaderAction::SetTab(Tab::Wanted))),
('q', LeaderNode::action("queue", LeaderAction::SetTab(Tab::Queue))),
('h', LeaderNode::action("history", LeaderAction::SetTab(Tab::History))),
('c', LeaderNode::action("calendar", LeaderAction::SetTab(Tab::Calendar))),
('s', LeaderNode::action("settings", LeaderAction::SetTab(Tab::Settings))),
('n', LeaderNode::action("next tab", LeaderAction::NextTab)),
('p', LeaderNode::action("prev tab", LeaderAction::PrevTab)),
],
),
),
// Quick-jump shortcuts
('l', LeaderNode::action("→ library", LeaderAction::SetTab(Tab::Library))),
('w', LeaderNode::action("→ wanted", LeaderAction::SetTab(Tab::Wanted))),
('h', LeaderNode::action("→ history", LeaderAction::SetTab(Tab::History))),
('c', LeaderNode::action("→ calendar", LeaderAction::SetTab(Tab::Calendar))),
// SPC a → +actions
(
'a',
LeaderNode::group(
"+actions / artist",
vec![
('a', LeaderNode::action("add artist", LeaderAction::ShowAdd)),
('m', LeaderNode::action("toggle monitor", LeaderAction::ToggleMonitor)),
('r', LeaderNode::action("refresh / rescan", LeaderAction::Refresh)),
],
),
),
// SPC s → +search
(
's',
LeaderNode::group(
"+search",
vec![
('s', LeaderNode::action("release for selected", LeaderAction::SearchRelease)),
('f', LeaderNode::action("filter library", LeaderAction::SetMode(Mode::Search))),
('a', LeaderNode::action("add new artist", LeaderAction::ShowAdd)),
],
),
),
// SPC t → +toggle/theme
(
't',
LeaderNode::group(
"+toggle / theme",
vec![
('t', LeaderNode::action("cycle theme", LeaderAction::CycleTheme)),
('d', LeaderNode::action("dark theme", LeaderAction::SetThemeDark)),
('l', LeaderNode::action("light theme", LeaderAction::SetThemeLight)),
],
),
),
// SPC n → +notifications
(
'n',
LeaderNode::group(
"+notifications",
vec![
('n', LeaderNode::action("open center", LeaderAction::ShowNotifications)),
('d', LeaderNode::action("dismiss all", LeaderAction::DismissNotifications)),
],
),
),
// SPC f → +file
(
'f',
LeaderNode::group(
"+file / library",
vec![
('s', LeaderNode::action("save (sync)", LeaderAction::SyncLibrary)),
('r', LeaderNode::action("refresh / rescan", LeaderAction::Refresh)),
],
),
),
// SPC q → +quit
(
'q',
LeaderNode::group(
"+queue / quit",
vec![
('q', LeaderNode::action("queue tab", LeaderAction::SetTab(Tab::Queue))),
('Q', LeaderNode::action("quit harmony", LeaderAction::ShowQuit)),
],
),
),
],
)
}