fcefcc02a0
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
287 lines
9.4 KiB
Rust
287 lines
9.4 KiB
Rust
//! 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)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
)
|
||
}
|