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:
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user