Files
ui-agregator/src/app.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

1026 lines
35 KiB
Rust

#![allow(dead_code)]
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Layout, Rect},
style::Style,
widgets::{ListState, Paragraph},
Frame,
};
use crate::data::{
sample_artists, sample_calendar, sample_history, sample_queue, sample_wanted,
Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry,
};
use crate::input::{
build_leader_tree, LeaderAction, LeaderNode, LeaderResult, LeaderState, VimState,
};
use crate::theme;
use crate::ui::cmdline::{render_cmdline, CmdLineState};
use crate::ui::library::{render_library, LibraryFocus, LibraryState};
use crate::ui::modals::{render_help_modal, render_quit_modal, ModalKind};
use crate::ui::notifications::{NotifKind, NotificationManager};
use crate::ui::statusbar::{render_statusbar, StatusHint};
use crate::ui::topbar::render_topbar;
use crate::ui::views::{render_calendar, render_history, render_queue, render_settings, render_wanted};
use crate::ui::which_key::render_which_key;
const PAGE_SIZE: usize = 12;
const HALF_PAGE: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Library,
Wanted,
Queue,
History,
Calendar,
Settings,
}
impl Tab {
pub const ALL: [Tab; 6] = [
Tab::Library,
Tab::Wanted,
Tab::Queue,
Tab::History,
Tab::Calendar,
Tab::Settings,
];
pub fn from_number(n: u8) -> Option<Tab> {
match n {
1 => Some(Tab::Library),
2 => Some(Tab::Wanted),
3 => Some(Tab::Queue),
4 => Some(Tab::History),
5 => Some(Tab::Calendar),
6 => Some(Tab::Settings),
_ => None,
}
}
pub fn index(&self) -> usize {
match self {
Tab::Library => 0,
Tab::Wanted => 1,
Tab::Queue => 2,
Tab::History => 3,
Tab::Calendar => 4,
Tab::Settings => 5,
}
}
pub fn label(&self) -> &'static str {
match self {
Tab::Library => "Library",
Tab::Wanted => "Wanted",
Tab::Queue => "Queue",
Tab::History => "History",
Tab::Calendar => "Calendar",
Tab::Settings => "Settings",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Normal,
Command,
Search,
}
pub struct App {
pub running: bool,
pub tab: Tab,
pub mode: Mode,
pub size: Rect,
pub library: LibraryState,
pub vim: VimState,
pub leader: LeaderState,
pub leader_tree: LeaderNode,
pub cmd_line: CmdLineState,
pub search_filter: String,
pub status_message: Option<String>,
pub marks: std::collections::HashMap<char, MarkPosition>,
pub last_position: Option<MarkPosition>,
filtered_artists: Vec<Artist>,
pub modal: Option<ModalKind>,
pub wanted: Vec<WantedEntry>,
pub wanted_state: ListState,
pub queue: Vec<QueueEntry>,
pub queue_state: ListState,
pub history: Vec<HistoryEntry>,
pub history_state: ListState,
pub calendar: Vec<CalendarEntry>,
pub notifications: NotificationManager,
}
#[derive(Debug, Clone)]
pub struct MarkPosition {
pub tab: Tab,
pub focus: LibraryFocus,
pub artist_idx: usize,
pub album_idx: usize,
pub track_idx: usize,
}
impl Default for App {
fn default() -> Self {
let artists = sample_artists();
let wanted = sample_wanted();
let queue = sample_queue();
let history = sample_history();
let calendar = sample_calendar();
let mut wanted_state = ListState::default();
if !wanted.is_empty() {
wanted_state.select(Some(0));
}
let mut queue_state = ListState::default();
if !queue.is_empty() {
queue_state.select(Some(0));
}
let mut history_state = ListState::default();
if !history.is_empty() {
history_state.select(Some(0));
}
Self {
running: true,
tab: Tab::Library,
mode: Mode::Normal,
size: Rect::default(),
library: LibraryState::new(artists.clone()),
vim: VimState::new(),
leader: LeaderState::new(),
leader_tree: build_leader_tree(),
cmd_line: CmdLineState::new(),
search_filter: String::new(),
status_message: None,
marks: std::collections::HashMap::new(),
last_position: None,
filtered_artists: artists,
modal: None,
wanted,
wanted_state,
queue,
queue_state,
history,
history_state,
calendar,
notifications: NotificationManager::new(),
}
}
}
impl App {
pub fn new() -> Self {
Self::default()
}
fn capture_position(&self) -> MarkPosition {
MarkPosition {
tab: self.tab,
focus: self.library.focus,
artist_idx: self.library.artist_state.selected().unwrap_or(0),
album_idx: self.library.album_state.selected().unwrap_or(0),
track_idx: self.library.track_state.selected().unwrap_or(0),
}
}
fn restore_position(&mut self, pos: &MarkPosition) {
self.tab = pos.tab;
self.library.focus = pos.focus;
self.library.artist_state.select(Some(pos.artist_idx));
self.library.album_state.select(Some(pos.album_idx));
self.library.track_state.select(Some(pos.track_idx));
}
fn set_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
}
pub fn draw(&mut self, frame: &mut Frame) {
self.size = frame.area();
let area = frame.area();
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme::BG0)),
area,
);
let show_cmdline = self.mode != Mode::Normal;
let cmdline_height = if show_cmdline { 1 } else { 0 };
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(cmdline_height),
Constraint::Length(1),
])
.split(area);
let queue_count = sample_queue().len();
let wanted_count = sample_wanted().len();
render_topbar(
frame,
chunks[0],
self.tab,
self.mode,
queue_count,
wanted_count,
);
self.render_main_content(frame, chunks[1]);
if show_cmdline {
let hint = match self.mode {
Mode::Command => "try :help · :theme light · :wanted · :q",
Mode::Search => "filter artists by name",
Mode::Normal => "",
};
render_cmdline(frame, chunks[2], self.mode, &self.cmd_line.text, hint);
}
let hints = self.get_hints();
let position = self.get_position();
render_statusbar(
frame,
chunks[3],
self.mode,
&hints,
position,
queue_count,
wanted_count,
self.status_message.as_deref(),
);
if self.leader.active {
render_which_key(frame, area, &self.leader, &self.leader_tree);
}
if let Some(modal) = &self.modal {
match modal {
ModalKind::Help => render_help_modal(frame, area),
ModalKind::Quit => render_quit_modal(frame, area, self.queue.len()),
}
}
self.notifications.render(frame, area);
}
fn render_main_content(&mut self, frame: &mut Frame, area: Rect) {
match self.tab {
Tab::Library => {
render_library(frame, area, &mut self.library);
}
Tab::Wanted => {
render_wanted(frame, area, &self.wanted, &mut self.wanted_state);
}
Tab::Queue => {
render_queue(frame, area, &self.queue, &mut self.queue_state);
}
Tab::History => {
render_history(frame, area, &self.history, &mut self.history_state);
}
Tab::Calendar => {
render_calendar(frame, area, &self.calendar);
}
Tab::Settings => {
render_settings(frame, area);
}
}
}
fn get_hints(&self) -> Vec<StatusHint> {
match self.tab {
Tab::Library => vec![
StatusHint { key: "j/k", action: "move" },
StatusHint { key: "h/l", action: "pane" },
StatusHint { key: "SPC", action: "leader" },
StatusHint { key: "a", action: "add" },
StatusHint { key: "/", action: "filter" },
StatusHint { key: "?", action: "help" },
],
Tab::Wanted => vec![
StatusHint { key: "j/k", action: "move" },
StatusHint { key: "s", action: "search" },
StatusHint { key: "m", action: "unmonitor" },
StatusHint { key: "SPC", action: "leader" },
],
Tab::Queue => vec![
StatusHint { key: "j/k", action: "move" },
StatusHint { key: "x", action: "remove" },
StatusHint { key: "p", action: "pause" },
StatusHint { key: "SPC", action: "leader" },
],
Tab::History => vec![
StatusHint { key: "j/k", action: "move" },
StatusHint { key: "d", action: "clear" },
StatusHint { key: "r", action: "retry" },
StatusHint { key: "SPC", action: "leader" },
],
Tab::Calendar => vec![
StatusHint { key: "h/l", action: "month" },
StatusHint { key: "Enter", action: "details" },
StatusHint { key: "SPC", action: "leader" },
],
Tab::Settings => vec![
StatusHint { key: "Tab", action: "next" },
StatusHint { key: "Enter", action: "edit" },
StatusHint { key: ":w", action: "save" },
StatusHint { key: "SPC", action: "leader" },
],
}
}
fn get_position(&self) -> Option<(usize, usize)> {
match self.tab {
Tab::Library => {
let idx = self.library.selected_artist_index().unwrap_or(0) + 1;
let total = self.library.artist_count();
Some((idx, total))
}
Tab::Wanted => {
let idx = self.wanted_state.selected().unwrap_or(0) + 1;
Some((idx, self.wanted.len()))
}
Tab::Queue => {
let idx = self.queue_state.selected().unwrap_or(0) + 1;
Some((idx, self.queue.len()))
}
Tab::History => {
let idx = self.history_state.selected().unwrap_or(0) + 1;
Some((idx, self.history.len()))
}
Tab::Calendar | Tab::Settings => None,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
self.status_message = None;
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.running = false;
return;
}
if self.modal.is_some() {
self.handle_modal_key(key);
return;
}
match self.mode {
Mode::Command | Mode::Search => {
self.handle_cmdline_key(key);
return;
}
Mode::Normal => {}
}
if self.leader.active {
self.handle_leader_key(key);
return;
}
self.handle_normal_key(key);
}
fn handle_modal_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('n') => {
self.modal = None;
}
KeyCode::Char('y') => {
if self.modal == Some(ModalKind::Quit) {
self.running = false;
}
}
KeyCode::Char('q') => {
self.modal = None;
}
_ => {}
}
}
fn handle_cmdline_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.cmd_line.clear();
}
KeyCode::Enter => {
let text = self.cmd_line.take();
if self.mode == Mode::Command {
self.execute_command(&text);
} else {
self.execute_search(&text);
}
self.mode = Mode::Normal;
}
KeyCode::Char(c) => {
self.cmd_line.push(c);
}
KeyCode::Backspace => {
if self.cmd_line.pop().is_none() || self.cmd_line.is_empty() {
self.mode = Mode::Normal;
self.cmd_line.clear();
}
}
_ => {}
}
}
fn handle_leader_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.leader.deactivate();
}
KeyCode::Backspace => {
self.leader.go_back();
}
KeyCode::Char(c) => {
let result = self.leader.navigate(c, &self.leader_tree);
match result {
LeaderResult::Pending => {}
LeaderResult::Executed(action) => {
self.execute_leader_action(action);
}
LeaderResult::NotBound => {
let path: String = self.leader.path.iter().collect();
self.set_status(format!("SPC {} {} not bound", path, c));
}
}
}
_ => {}
}
}
fn execute_leader_action(&mut self, action: LeaderAction) {
match action {
LeaderAction::SetTab(tab) => self.tab = tab,
LeaderAction::SetMode(mode) => {
self.mode = mode;
self.cmd_line.clear();
}
LeaderAction::NextTab => {
let idx = (self.tab.index() + 1) % Tab::ALL.len();
self.tab = Tab::ALL[idx];
}
LeaderAction::PrevTab => {
let idx = (self.tab.index() + Tab::ALL.len() - 1) % Tab::ALL.len();
self.tab = Tab::ALL[idx];
}
LeaderAction::ToggleMonitor => {
self.set_status("monitor toggled");
self.notify("monitor toggled", None, NotifKind::Info, "");
}
LeaderAction::Refresh => {
self.set_status("scanning library...");
self.notify("scanning library", Some("18 artists".into()), NotifKind::Info, "");
}
LeaderAction::SearchRelease => {
self.set_status("search release");
}
LeaderAction::CycleTheme => {
self.set_status("theme cycled");
self.notify("theme", Some("dark".into()), NotifKind::Success, "");
}
LeaderAction::SetThemeDark => {
self.set_status("theme: dark");
self.notify("theme", Some("dark".into()), NotifKind::Success, "");
}
LeaderAction::SetThemeLight => {
self.set_status("theme: light");
self.notify("theme", Some("light".into()), NotifKind::Success, "");
}
LeaderAction::ShowNotifications => {
self.set_status("notifications");
}
LeaderAction::DismissNotifications => {
self.set_status("notifications dismissed");
}
LeaderAction::ShowHelp => {
self.modal = Some(ModalKind::Help);
}
LeaderAction::ShowAdd => {
self.set_status("add artist");
}
LeaderAction::ShowQuit => {
self.modal = Some(ModalKind::Quit);
}
LeaderAction::SyncLibrary => {
self.set_status("library synced · 0 changes");
self.notify("library synced", Some("0 changes".into()), NotifKind::Success, "");
}
}
}
fn handle_normal_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if self.vim.has_pending() {
self.handle_pending_key(key);
return;
}
if let KeyCode::Char(c) = key.code {
if self.vim.accumulate_count(c) {
return;
}
}
let count = self.vim.take_count();
if ctrl {
self.handle_ctrl_key(key, count);
return;
}
match key.code {
KeyCode::Char(' ') => {
self.leader.activate();
}
KeyCode::Char(':') => {
self.mode = Mode::Command;
self.cmd_line.clear();
}
KeyCode::Char('/') => {
self.last_position = Some(self.capture_position());
self.mode = Mode::Search;
self.cmd_line.clear();
}
KeyCode::Char('?') => {
self.modal = Some(ModalKind::Help);
}
KeyCode::Char('g') | KeyCode::Char('z') | KeyCode::Char('m') | KeyCode::Char('\'')
| KeyCode::Char('`') | KeyCode::Char('[') | KeyCode::Char(']') => {
if let KeyCode::Char(c) = key.code {
self.vim.set_pending(c);
}
}
KeyCode::Char('j') | KeyCode::Down => self.move_cursor(count as isize),
KeyCode::Char('k') | KeyCode::Up => self.move_cursor(-(count as isize)),
KeyCode::Char('h') | KeyCode::Left => {
if self.tab == Tab::Library {
self.library.focus_left();
}
}
KeyCode::Char('l') | KeyCode::Right => {
if self.tab == Tab::Library {
self.library.focus_right();
}
}
KeyCode::Char('w') => self.move_cursor(3 * count as isize),
KeyCode::Char('W') => self.move_cursor(5 * count as isize),
KeyCode::Char('b') => self.move_cursor(-3 * (count as isize)),
KeyCode::Char('B') => self.move_cursor(-5 * (count as isize)),
KeyCode::Char('e') => self.move_cursor(3 * count as isize),
KeyCode::Char('E') => self.move_cursor(5 * count as isize),
KeyCode::Char('{') => self.move_cursor(-5 * (count as isize)),
KeyCode::Char('}') => self.move_cursor(5 * count as isize),
KeyCode::Char('(') => self.move_cursor(-3 * (count as isize)),
KeyCode::Char(')') => self.move_cursor(3 * count as isize),
KeyCode::Char('0') | KeyCode::Char('^') => {
if self.tab == Tab::Library {
self.library.focus = LibraryFocus::Artists;
}
}
KeyCode::Char('$') => {
if self.tab == Tab::Library {
self.library.focus = LibraryFocus::Tracks;
}
}
KeyCode::Char('G') => {
self.last_position = Some(self.capture_position());
if self.vim.has_count() {
let line = self.vim.take_count();
self.move_to(line.saturating_sub(1));
} else {
self.move_to_end();
}
}
KeyCode::Char('H') => {
self.last_position = Some(self.capture_position());
self.move_to(0);
}
KeyCode::Char('M') => {
self.last_position = Some(self.capture_position());
let len = self.current_list_len();
self.move_to(len / 2);
}
KeyCode::Char('L') => {
self.last_position = Some(self.capture_position());
self.move_to_end();
}
KeyCode::Char('n') => {
self.set_status("n: repeat search forward");
}
KeyCode::Char('N') => {
self.set_status("N: repeat search backward");
}
KeyCode::Char('*') => {
self.set_status("*: search word under cursor forward");
}
KeyCode::Char('#') => {
self.set_status("#: search word under cursor backward");
}
KeyCode::Char('a') => {
self.set_status("add artist");
}
KeyCode::Char('s') => {
self.set_status("search release");
}
KeyCode::Char('r') => {
self.set_status("refresh");
}
KeyCode::Char('t') => {
self.set_status("monitor toggled");
}
KeyCode::Char('q') => {
self.modal = Some(ModalKind::Quit);
}
KeyCode::Char('1') => self.tab = Tab::Library,
KeyCode::Char('2') => self.tab = Tab::Wanted,
KeyCode::Char('3') => self.tab = Tab::Queue,
KeyCode::Char('4') => self.tab = Tab::History,
KeyCode::Char('5') => self.tab = Tab::Calendar,
KeyCode::Char('6') => self.tab = Tab::Settings,
KeyCode::Tab => {
if self.tab == Tab::Library {
self.library.cycle_focus();
}
}
KeyCode::Enter => {
if self.tab == Tab::Library {
self.library.focus_right();
}
}
KeyCode::Esc => {
self.vim.clear();
if !self.search_filter.is_empty() {
self.search_filter.clear();
self.update_filtered_artists();
self.set_status("filter cleared");
} else if self.library.focus != LibraryFocus::Artists {
self.library.focus_left();
}
}
_ => {}
}
}
fn handle_ctrl_key(&mut self, key: KeyEvent, count: usize) {
match key.code {
KeyCode::Char('d') => {
self.last_position = Some(self.capture_position());
self.move_cursor((HALF_PAGE * count) as isize);
}
KeyCode::Char('u') => {
self.last_position = Some(self.capture_position());
self.move_cursor(-((HALF_PAGE * count) as isize));
}
KeyCode::Char('f') => {
self.last_position = Some(self.capture_position());
self.move_cursor((PAGE_SIZE * count) as isize);
}
KeyCode::Char('b') => {
self.last_position = Some(self.capture_position());
self.move_cursor(-((PAGE_SIZE * count) as isize));
}
KeyCode::Char('e') => {
self.move_cursor(count as isize);
}
KeyCode::Char('y') => {
self.move_cursor(-(count as isize));
}
KeyCode::Char('o') => {
if let Some(pos) = self.last_position.take() {
let current = self.capture_position();
self.restore_position(&pos);
self.last_position = Some(current);
} else {
self.set_status("at start of jumplist");
}
}
KeyCode::Char('i') => {
self.set_status("Ctrl-i: forward in jumplist");
}
_ => {}
}
}
fn handle_pending_key(&mut self, key: KeyEvent) {
let pending = match self.vim.take_pending() {
Some(p) => p,
None => return,
};
let count = self.vim.take_count();
let KeyCode::Char(k) = key.code else {
return;
};
match pending {
'g' => match k {
'g' => {
self.last_position = Some(self.capture_position());
self.move_to(0);
}
'e' => self.move_cursor(-3 * (count as isize)),
'E' => self.move_cursor(-5 * (count as isize)),
'j' => self.move_cursor(count as isize),
'k' => self.move_cursor(-(count as isize)),
'_' => self.move_to_end(),
'0' => {
if self.tab == Tab::Library {
self.library.focus = LibraryFocus::Artists;
}
}
'$' => {
if self.tab == Tab::Library {
self.library.focus = LibraryFocus::Tracks;
}
}
't' => {
let idx = (self.tab.index() + 1) % Tab::ALL.len();
self.tab = Tab::ALL[idx];
}
'T' => {
let idx = (self.tab.index() + Tab::ALL.len() - 1) % Tab::ALL.len();
self.tab = Tab::ALL[idx];
}
_ => {}
},
'z' => match k {
'z' | '.' => self.set_status("zz: cursor centered"),
't' => self.set_status("zt: cursor → viewport top"),
'b' | '-' => self.set_status("zb: cursor → viewport bottom"),
_ => {}
},
'm' => {
if k.is_ascii_alphabetic() {
let pos = self.capture_position();
self.marks.insert(k, pos);
self.set_status(format!("mark '{}' set", k));
self.notify("mark set", Some(format!("'{}", k)), NotifKind::Info, "");
}
}
'\'' | '`' => {
if k == '\'' || k == '`' {
if let Some(pos) = self.last_position.take() {
let current = self.capture_position();
self.restore_position(&pos);
self.last_position = Some(current);
} else {
self.set_status("no previous position");
}
} else if k.is_ascii_alphabetic() {
if let Some(pos) = self.marks.get(&k).cloned() {
self.last_position = Some(self.capture_position());
self.restore_position(&pos);
} else {
self.set_status(format!("E20: mark not set: '{}'", k));
}
}
}
'[' => match k {
'[' => {
self.last_position = Some(self.capture_position());
self.move_to(0);
}
'c' => self.move_cursor(-(count as isize)),
_ => {}
},
']' => match k {
']' => {
self.last_position = Some(self.capture_position());
self.move_to_end();
}
'c' => self.move_cursor(count as isize),
_ => {}
},
_ => {}
}
}
fn current_list_len(&self) -> usize {
match self.tab {
Tab::Library => match self.library.focus {
LibraryFocus::Artists => self.library.artist_count(),
LibraryFocus::Albums => self
.library
.selected_artist()
.map(|a| a.albums.len())
.unwrap_or(0),
LibraryFocus::Tracks => self.library.selected_album().map(|a| a.total as usize).unwrap_or(0),
},
Tab::Wanted => self.wanted.len(),
Tab::Queue => self.queue.len(),
Tab::History => self.history.len(),
Tab::Calendar | Tab::Settings => 0,
}
}
fn move_cursor(&mut self, delta: isize) {
let len = self.current_list_len();
if len == 0 {
return;
}
match self.tab {
Tab::Library => match self.library.focus {
LibraryFocus::Artists => {
let current = self.library.artist_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
if new_idx != current {
self.library.artist_state.select(Some(new_idx));
self.library.album_state.select(Some(0));
self.library.track_state.select(Some(0));
}
}
LibraryFocus::Albums => {
let current = self.library.album_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
if new_idx != current {
self.library.album_state.select(Some(new_idx));
self.library.track_state.select(Some(0));
}
}
LibraryFocus::Tracks => {
let current = self.library.track_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
self.library.track_state.select(Some(new_idx));
}
},
Tab::Wanted => {
let current = self.wanted_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
self.wanted_state.select(Some(new_idx));
}
Tab::Queue => {
let current = self.queue_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
self.queue_state.select(Some(new_idx));
}
Tab::History => {
let current = self.history_state.selected().unwrap_or(0);
let new_idx = (current as isize + delta).clamp(0, (len - 1) as isize) as usize;
self.history_state.select(Some(new_idx));
}
Tab::Calendar | Tab::Settings => {}
}
}
fn move_to(&mut self, idx: usize) {
let len = self.current_list_len();
if len == 0 {
return;
}
let new_idx = idx.min(len - 1);
match self.tab {
Tab::Library => match self.library.focus {
LibraryFocus::Artists => {
self.library.artist_state.select(Some(new_idx));
self.library.album_state.select(Some(0));
self.library.track_state.select(Some(0));
}
LibraryFocus::Albums => {
self.library.album_state.select(Some(new_idx));
self.library.track_state.select(Some(0));
}
LibraryFocus::Tracks => {
self.library.track_state.select(Some(new_idx));
}
},
Tab::Wanted => {
self.wanted_state.select(Some(new_idx));
}
Tab::Queue => {
self.queue_state.select(Some(new_idx));
}
Tab::History => {
self.history_state.select(Some(new_idx));
}
Tab::Calendar | Tab::Settings => {}
}
}
fn move_to_end(&mut self) {
let len = self.current_list_len();
if len > 0 {
self.move_to(len - 1);
}
}
fn execute_command(&mut self, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
let parts: Vec<&str> = text.split_whitespace().collect();
let cmd = parts.first().map(|s| s.to_lowercase()).unwrap_or_default();
match cmd.as_str() {
"q" | "quit" => self.running = false,
"w" | "write" | "sync" => {
self.set_status("library synced · 0 changes");
}
"wq" => {
self.set_status("synced · bye");
self.running = false;
}
"help" | "h" => {
self.set_status("help");
}
"theme" => {
if let Some(theme) = parts.get(1) {
match *theme {
"dark" => self.set_status("theme: dark"),
"light" => self.set_status("theme: light"),
_ => self.set_status("usage: :theme dark | light"),
}
} else {
self.set_status("usage: :theme dark | light");
}
}
"lib" | "library" => self.tab = Tab::Library,
"wanted" => self.tab = Tab::Wanted,
"queue" => self.tab = Tab::Queue,
"history" => self.tab = Tab::History,
"cal" | "calendar" => self.tab = Tab::Calendar,
"settings" | "set" => self.tab = Tab::Settings,
"refresh" | "rescan" | "r" => {
self.set_status("library refreshed");
}
_ => {
self.set_status(format!("E492: not an editor command: {}", text));
}
}
}
fn execute_search(&mut self, text: &str) {
let filter = text.trim().to_string();
self.search_filter = filter.clone();
self.update_filtered_artists();
if filter.is_empty() {
self.set_status("filter cleared");
} else {
let count = self.filtered_artists.len();
self.set_status(format!("filter: /{} · {} matches", filter, count));
self.notify("filter", Some(format!("/{}", filter)), NotifKind::Info, "");
}
}
fn update_filtered_artists(&mut self) {
if self.search_filter.is_empty() {
self.filtered_artists = self.library.artists.clone();
} else {
let query = self.search_filter.to_lowercase();
self.filtered_artists = self
.library
.artists
.iter()
.filter(|a| a.name.to_lowercase().contains(&query))
.cloned()
.collect();
}
self.library.artist_state.select(Some(0));
}
pub fn handle_tick(&mut self) {
if self.status_message.is_some() {
self.status_message = None;
}
self.notifications.tick();
}
fn notify(&mut self, title: impl Into<String>, detail: Option<String>, kind: NotifKind, icon: impl Into<String>) {
self.notifications.push(title, detail, kind, icon);
}
}