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
This commit is contained in:
Alexander
2026-05-08 13:26:09 +02:00
parent f967256708
commit fcefcc02a0
27 changed files with 4309 additions and 36 deletions
+286
View File
@@ -0,0 +1,286 @@
//! 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)),
],
),
),
],
)
}