From fcefcc02a0f36fa3232c32a03179553fd47c2d65 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 8 May 2026 13:26:09 +0200 Subject: [PATCH] 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 --- src/app.rs | 1025 ++++++++++++++++++++++++++++++++++++++ src/data/mod.rs | 7 + src/data/models.rs | 94 ++++ src/data/sample.rs | 262 ++++++++++ src/input/leader.rs | 286 +++++++++++ src/input/mod.rs | 7 + src/input/vim.rs | 117 +++++ src/main.rs | 86 ++-- src/theme.rs | 34 ++ src/ui/cmdline.rs | 73 +++ src/ui/library.rs | 606 ++++++++++++++++++++++ src/ui/mod.rs | 10 + src/ui/modals/help.rs | 152 ++++++ src/ui/modals/mod.rs | 11 + src/ui/modals/quit.rs | 59 +++ src/ui/notifications.rs | 169 +++++++ src/ui/pane.rs | 103 ++++ src/ui/progress_bar.rs | 62 +++ src/ui/statusbar.rs | 120 +++++ src/ui/topbar.rs | 99 ++++ src/ui/views/calendar.rs | 206 ++++++++ src/ui/views/history.rs | 90 ++++ src/ui/views/mod.rs | 13 + src/ui/views/queue.rs | 141 ++++++ src/ui/views/settings.rs | 277 ++++++++++ src/ui/views/wanted.rs | 123 +++++ src/ui/which_key.rs | 113 +++++ 27 files changed, 4309 insertions(+), 36 deletions(-) create mode 100644 src/app.rs create mode 100644 src/data/mod.rs create mode 100644 src/data/models.rs create mode 100644 src/data/sample.rs create mode 100644 src/input/leader.rs create mode 100644 src/input/mod.rs create mode 100644 src/input/vim.rs create mode 100644 src/theme.rs create mode 100644 src/ui/cmdline.rs create mode 100644 src/ui/library.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/modals/help.rs create mode 100644 src/ui/modals/mod.rs create mode 100644 src/ui/modals/quit.rs create mode 100644 src/ui/notifications.rs create mode 100644 src/ui/pane.rs create mode 100644 src/ui/progress_bar.rs create mode 100644 src/ui/statusbar.rs create mode 100644 src/ui/topbar.rs create mode 100644 src/ui/views/calendar.rs create mode 100644 src/ui/views/history.rs create mode 100644 src/ui/views/mod.rs create mode 100644 src/ui/views/queue.rs create mode 100644 src/ui/views/settings.rs create mode 100644 src/ui/views/wanted.rs create mode 100644 src/ui/which_key.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..573065a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1025 @@ +#![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 { + 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, + pub marks: std::collections::HashMap, + pub last_position: Option, + filtered_artists: Vec, + pub modal: Option, + pub wanted: Vec, + pub wanted_state: ListState, + pub queue: Vec, + pub queue_state: ListState, + pub history: Vec, + pub history_state: ListState, + pub calendar: Vec, + 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) { + 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 { + 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, detail: Option, kind: NotifKind, icon: impl Into) { + self.notifications.push(title, detail, kind, icon); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..6768b59 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,7 @@ +//! Data layer modules. + +pub mod models; +pub mod sample; + +pub use models::*; +pub use sample::*; diff --git a/src/data/models.rs b/src/data/models.rs new file mode 100644 index 0000000..8f01493 --- /dev/null +++ b/src/data/models.rs @@ -0,0 +1,94 @@ +#![allow(dead_code)] +//! Data models for harmony music library. + +/// Album completion status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlbumStatus { + Complete, + Partial, + Wanted, + Unmonitored, +} + +/// A music artist in the library. +#[derive(Debug, Clone)] +pub struct Artist { + pub id: String, + pub name: String, + pub country: String, + pub genres: Vec, + pub monitored: bool, + pub path: String, + pub quality: String, + pub size_gb: f64, + pub albums: Vec, +} + +/// An album belonging to an artist. +#[derive(Debug, Clone)] +pub struct Album { + pub id: String, + pub title: String, + pub year: u16, + pub album_type: String, + pub monitored: bool, + pub total: u16, + pub have: u16, + pub quality: String, + pub status: AlbumStatus, +} + +/// A track on an album. +#[derive(Debug, Clone)] +pub struct Track { + pub number: u16, + pub title: String, + pub duration: String, + pub have: bool, + pub quality: String, +} + +/// An entry in the wanted/missing queue. +#[derive(Debug, Clone)] +pub struct WantedEntry { + pub id: String, + pub artist: String, + pub album: String, + pub year: u16, + pub missing: u16, + pub release_date: String, + pub status: AlbumStatus, +} + +/// An active download in the queue. +#[derive(Debug, Clone)] +pub struct QueueEntry { + pub id: String, + pub title: String, + pub artist: String, + pub indexer: String, + pub size: String, + pub progress: f64, + pub eta: String, + pub speed: String, + pub client: String, +} + +/// A history log entry. +#[derive(Debug, Clone)] +pub struct HistoryEntry { + pub when: String, + pub event: String, + pub artist: String, + pub detail: String, +} + +/// A calendar entry for upcoming releases. +#[derive(Debug, Clone)] +pub struct CalendarEntry { + pub date: String, + pub artist: String, + pub album: String, + pub status: String, + pub entry_type: String, +} diff --git a/src/data/sample.rs b/src/data/sample.rs new file mode 100644 index 0000000..e819a63 --- /dev/null +++ b/src/data/sample.rs @@ -0,0 +1,262 @@ +use super::models::*; + +pub fn sample_artists() -> Vec { + vec![ + Artist { + id: "bcnr".into(), + name: "Black Country, New Road".into(), + country: "UK".into(), + genres: vec!["Post-rock".into(), "Avant-prog".into(), "Chamber".into()], + monitored: true, + path: "/music/B/Black Country New Road".into(), + quality: "FLAC".into(), + size_gb: 3.2, + albums: vec![ + Album { id: "ftft".into(), title: "For the first time".into(), year: 2021, album_type: "Album".into(), monitored: true, total: 6, have: 6, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "afut".into(), title: "Ants From Up There".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "bush".into(), title: "Live at Bush Hall".into(), year: 2023, album_type: "Live".into(), monitored: true, total: 9, have: 5, quality: "FLAC".into(), status: AlbumStatus::Partial }, + Album { id: "fhwl".into(), title: "Forever Howlong".into(), year: 2025, album_type: "Album".into(), monitored: true, total: 12, have: 0, quality: "—".into(), status: AlbumStatus::Wanted }, + ], + }, + Artist { + id: "rad".into(), + name: "Radiohead".into(), + country: "UK".into(), + genres: vec!["Alt-rock".into(), "Electronic".into(), "Art-rock".into()], + monitored: true, + path: "/music/R/Radiohead".into(), + quality: "FLAC".into(), + size_gb: 8.7, + albums: vec![ + Album { id: "okc".into(), title: "OK Computer".into(), year: 1997, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "kida".into(), title: "Kid A".into(), year: 2000, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "amne".into(), title: "Amnesiac".into(), year: 2001, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "htft".into(), title: "Hail to the Thief".into(), year: 2003, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "rain".into(), title: "In Rainbows".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "kol".into(), title: "The King of Limbs".into(), year: 2011, album_type: "Album".into(), monitored: true, total: 8, have: 8, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "moon".into(), title: "A Moon Shaped Pool".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "ken".into(), + name: "Kendrick Lamar".into(), + country: "US".into(), + genres: vec!["Hip-hop".into(), "Conscious".into()], + monitored: true, + path: "/music/K/Kendrick Lamar".into(), + quality: "FLAC > MP3 320".into(), + size_gb: 4.1, + albums: vec![ + Album { id: "skd".into(), title: "Section.80".into(), year: 2011, album_type: "Album".into(), monitored: true, total: 16, have: 16, quality: "MP3".into(), status: AlbumStatus::Complete }, + Album { id: "gkmc".into(), title: "good kid, m.A.A.d city".into(), year: 2012, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "tpab".into(), title: "To Pimp a Butterfly".into(), year: 2015, album_type: "Album".into(), monitored: true, total: 16, have: 16, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "damn".into(), title: "DAMN.".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "mmbs".into(), title: "Mr. Morale & The Big Steppers".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 18, have: 14, quality: "FLAC".into(), status: AlbumStatus::Partial }, + Album { id: "gnx".into(), title: "GNX".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 12, have: 0, quality: "—".into(), status: AlbumStatus::Wanted }, + ], + }, + Artist { + id: "tame".into(), + name: "Tame Impala".into(), + country: "AU".into(), + genres: vec!["Psychedelic".into(), "Synth-pop".into()], + monitored: true, + path: "/music/T/Tame Impala".into(), + quality: "FLAC".into(), + size_gb: 2.9, + albums: vec![ + Album { id: "inrn".into(), title: "Innerspeaker".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "lonr".into(), title: "Lonerism".into(), year: 2012, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "curr".into(), title: "Currents".into(), year: 2015, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "slow".into(), title: "The Slow Rush".into(), year: 2020, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "fka".into(), + name: "FKA twigs".into(), + country: "UK".into(), + genres: vec!["Art-pop".into(), "R&B".into(), "Electronic".into()], + monitored: true, + path: "/music/F/FKA twigs".into(), + quality: "FLAC".into(), + size_gb: 1.8, + albums: vec![ + Album { id: "lp1".into(), title: "LP1".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "magd".into(), title: "MAGDALENE".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 9, have: 9, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "caps".into(), title: "CAPRISONGS".into(), year: 2022, album_type: "Mixtape".into(), monitored: false, total: 17, have: 0, quality: "—".into(), status: AlbumStatus::Unmonitored }, + Album { id: "evt".into(), title: "EUSEXUA".into(), year: 2025, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "mit".into(), + name: "Mitski".into(), + country: "US".into(), + genres: vec!["Indie rock".into(), "Art-pop".into()], + monitored: true, + path: "/music/M/Mitski".into(), + quality: "FLAC".into(), + size_gb: 2.4, + albums: vec![ + Album { id: "bitc".into(), title: "Bury Me at Makeout Creek".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "puby".into(), title: "Puberty 2".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "bhdc".into(), title: "Be the Cowboy".into(), year: 2018, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "lhdh".into(), title: "Laurel Hell".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "tlid".into(), title: "The Land Is Inhospitable...".into(), year: 2023, album_type: "Album".into(), monitored: true, total: 11, have: 7, quality: "FLAC".into(), status: AlbumStatus::Partial }, + ], + }, + Artist { + id: "phbr".into(), + name: "Phoebe Bridgers".into(), + country: "US".into(), + genres: vec!["Indie folk".into(), "Singer-songwriter".into()], + monitored: true, + path: "/music/P/Phoebe Bridgers".into(), + quality: "FLAC".into(), + size_gb: 1.1, + albums: vec![ + Album { id: "stge".into(), title: "Stranger in the Alps".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "punr".into(), title: "Punisher".into(), year: 2020, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "vw".into(), + name: "Vampire Weekend".into(), + country: "US".into(), + genres: vec!["Indie rock".into(), "Baroque pop".into()], + monitored: true, + path: "/music/V/Vampire Weekend".into(), + quality: "FLAC".into(), + size_gb: 2.0, + albums: vec![ + Album { id: "vw1".into(), title: "Vampire Weekend".into(), year: 2008, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "cont".into(), title: "Contra".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "moav".into(), title: "Modern Vampires...".into(), year: 2013, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "fofb".into(), title: "Father of the Bride".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 18, have: 18, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "ognc".into(), title: "Only God Was Above Us".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "bigt".into(), + name: "Big Thief".into(), + country: "US".into(), + genres: vec!["Indie folk".into()], + monitored: true, + path: "/music/B/Big Thief".into(), + quality: "FLAC".into(), + size_gb: 3.6, + albums: vec![ + Album { id: "mskc".into(), title: "Masterpiece".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "cap".into(), title: "Capacity".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "uffp".into(), title: "U.F.O.F.".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "twoh".into(), title: "Two Hands".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "ddiy".into(), title: "Dragon New Warm Mountain...".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 20, have: 14, quality: "FLAC".into(), status: AlbumStatus::Partial }, + ], + }, + Artist { + id: "carp".into(), + name: "Caroline Polachek".into(), + country: "US".into(), + genres: vec!["Art-pop".into()], + monitored: true, + path: "/music/C/Caroline Polachek".into(), + quality: "FLAC".into(), + size_gb: 0.9, + albums: vec![ + Album { id: "panc".into(), title: "Pang".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "dsrt".into(), title: "Desire, I Want to Turn Into You".into(), year: 2023, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + Artist { + id: "nat".into(), + name: "The National".into(), + country: "US".into(), + genres: vec!["Indie rock".into()], + monitored: false, + path: "/music/T/The National".into(), + quality: "MP3 320".into(), + size_gb: 2.2, + albums: vec![ + Album { id: "boxer".into(), title: "Boxer".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "MP3".into(), status: AlbumStatus::Complete }, + Album { id: "hvcc".into(), title: "High Violet".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "MP3".into(), status: AlbumStatus::Complete }, + Album { id: "tbtb".into(), title: "Trouble Will Find Me".into(), year: 2013, album_type: "Album".into(), monitored: false, total: 13, have: 0, quality: "—".into(), status: AlbumStatus::Unmonitored }, + ], + }, + Artist { + id: "stv".into(), + name: "St. Vincent".into(), + country: "US".into(), + genres: vec!["Art-rock".into()], + monitored: true, + path: "/music/S/St. Vincent".into(), + quality: "FLAC".into(), + size_gb: 2.7, + albums: vec![ + Album { id: "marr".into(), title: "Marry Me".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "actr".into(), title: "Actor".into(), year: 2009, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "sssv".into(), title: "St. Vincent".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "mads".into(), title: "MASSEDUCTION".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "dhft".into(), title: "Daddy's Home".into(), year: 2021, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete }, + Album { id: "alai".into(), title: "All Born Screaming".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete }, + ], + }, + ] +} + +pub fn sample_tracks_bush_hall() -> Vec { + vec![ + Track { number: 1, title: "Up Song".into(), duration: "4:24".into(), have: true, quality: "FLAC".into() }, + Track { number: 2, title: "The Boy".into(), duration: "3:01".into(), have: true, quality: "FLAC".into() }, + Track { number: 3, title: "I Won't Always Love You".into(), duration: "5:13".into(), have: false, quality: "—".into() }, + Track { number: 4, title: "Across the Pond Friend".into(), duration: "6:48".into(), have: true, quality: "FLAC".into() }, + Track { number: 5, title: "Laughing Song".into(), duration: "5:02".into(), have: false, quality: "—".into() }, + Track { number: 6, title: "The Wrong Trousers".into(), duration: "4:39".into(), have: true, quality: "FLAC".into() }, + Track { number: 7, title: "Turbines / Pigs".into(), duration: "9:22".into(), have: true, quality: "FLAC".into() }, + Track { number: 8, title: "Dancers".into(), duration: "5:55".into(), have: false, quality: "—".into() }, + Track { number: 9, title: "Up Song (Reprise)".into(), duration: "1:33".into(), have: false, quality: "—".into() }, + ] +} + +pub fn sample_wanted() -> Vec { + vec![ + WantedEntry { id: "w1".into(), artist: "Black Country, New Road".into(), album: "Forever Howlong".into(), year: 2025, missing: 12, release_date: "2025-04-04".into(), status: AlbumStatus::Wanted }, + WantedEntry { id: "w2".into(), artist: "Kendrick Lamar".into(), album: "GNX".into(), year: 2024, missing: 12, release_date: "2024-11-22".into(), status: AlbumStatus::Wanted }, + WantedEntry { id: "w3".into(), artist: "Black Country, New Road".into(), album: "Live at Bush Hall".into(), year: 2023, missing: 4, release_date: "2023-03-31".into(), status: AlbumStatus::Partial }, + WantedEntry { id: "w4".into(), artist: "Kendrick Lamar".into(), album: "Mr. Morale...".into(), year: 2022, missing: 4, release_date: "2022-05-13".into(), status: AlbumStatus::Partial }, + WantedEntry { id: "w5".into(), artist: "Mitski".into(), album: "The Land Is...".into(), year: 2023, missing: 4, release_date: "2023-09-15".into(), status: AlbumStatus::Partial }, + WantedEntry { id: "w6".into(), artist: "Big Thief".into(), album: "Dragon New Warm...".into(), year: 2022, missing: 6, release_date: "2022-02-11".into(), status: AlbumStatus::Partial }, + ] +} + +pub fn sample_queue() -> Vec { + vec![ + QueueEntry { id: "q1".into(), title: "Forever Howlong".into(), artist: "Black Country, New Road".into(), indexer: "redacted.ch".into(), size: "412 MB".into(), progress: 0.73, eta: "0:42".into(), speed: "8.1 MB/s".into(), client: "qbittorrent".into() }, + QueueEntry { id: "q2".into(), title: "GNX".into(), artist: "Kendrick Lamar".into(), indexer: "orpheus.network".into(), size: "284 MB".into(), progress: 0.41, eta: "1:18".into(), speed: "3.4 MB/s".into(), client: "qbittorrent".into() }, + QueueEntry { id: "q3".into(), title: "Bush Hall (4 trk)".into(), artist: "Black Country, New Road".into(), indexer: "redacted.ch".into(), size: "98 MB".into(), progress: 0.92, eta: "0:08".into(), speed: "11.2 MB/s".into(), client: "qbittorrent".into() }, + ] +} + +pub fn sample_history() -> Vec { + vec![ + HistoryEntry { when: "12:04".into(), event: "imported".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us - 10/10 FLAC".into() }, + HistoryEntry { when: "11:58".into(), event: "downloaded".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us - redacted.ch - 348 MB".into() }, + HistoryEntry { when: "11:42".into(), event: "grabbed".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us [WEB FLAC]".into() }, + HistoryEntry { when: "11:40".into(), event: "search".into(), artist: "Vampire Weekend".into(), detail: "manual search - 14 results".into() }, + HistoryEntry { when: "10:31".into(), event: "imported".into(), artist: "St. Vincent".into(), detail: "All Born Screaming - 10/10 FLAC".into() }, + HistoryEntry { when: "10:24".into(), event: "downloaded".into(), artist: "St. Vincent".into(), detail: "All Born Screaming - orpheus.network - 412 MB".into() }, + HistoryEntry { when: "09:12".into(), event: "grabbed".into(), artist: "Caroline Polachek".into(), detail: "Desire, I Want... [WEB FLAC]".into() }, + HistoryEntry { when: "yesterday".into(), event: "refreshed".into(), artist: "Library".into(), detail: "scanned 18 artists - 62 albums - 0 changes".into() }, + HistoryEntry { when: "yesterday".into(), event: "imported".into(), artist: "Tame Impala".into(), detail: "The Slow Rush - 12/12 FLAC".into() }, + HistoryEntry { when: "yesterday".into(), event: "failed".into(), artist: "Mitski".into(), detail: "release rejected - audio quality below cutoff".into() }, + ] +} + +pub fn sample_calendar() -> Vec { + vec![ + CalendarEntry { date: "2026-05-09".into(), artist: "Phoebe Bridgers".into(), album: "TBD".into(), status: "announced".into(), entry_type: "Album".into() }, + CalendarEntry { date: "2026-05-15".into(), artist: "Tame Impala".into(), album: "Deadbeat".into(), status: "monitored".into(), entry_type: "Album".into() }, + CalendarEntry { date: "2026-05-22".into(), artist: "FKA twigs".into(), album: "EUSEXUA Afterglow EP".into(), status: "monitored".into(), entry_type: "EP".into() }, + CalendarEntry { date: "2026-06-03".into(), artist: "Big Thief".into(), album: "(unannounced)".into(), status: "announced".into(), entry_type: "Album".into() }, + CalendarEntry { date: "2026-06-14".into(), artist: "Mitski".into(), album: "Live at the Met".into(), status: "monitored".into(), entry_type: "Live".into() }, + CalendarEntry { date: "2026-07-01".into(), artist: "Caroline Polachek".into(), album: "Pang Reissue".into(), status: "monitored".into(), entry_type: "Reissue".into() }, + ] +} diff --git a/src/input/leader.rs b/src/input/leader.rs new file mode 100644 index 0000000..6357de6 --- /dev/null +++ b/src/input/leader.rs @@ -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>, + /// Action to execute (None if this is a group) + pub action: Option, +} + +impl LeaderNode { + /// Create a new group node. + pub fn group(name: impl Into, 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, 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, + /// 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)), + ], + ), + ), + ], + ) +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..023bb9a --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,7 @@ +//! Input handling modules: Vim state machine and SPC leader tree. + +pub mod leader; +pub mod vim; + +pub use leader::{build_leader_tree, LeaderAction, LeaderNode, LeaderResult, LeaderState}; +pub use vim::VimState; diff --git a/src/input/vim.rs b/src/input/vim.rs new file mode 100644 index 0000000..5a44bba --- /dev/null +++ b/src/input/vim.rs @@ -0,0 +1,117 @@ +#![allow(dead_code)] +//! Vim-style input state machine. +//! +//! Handles count prefix (5j), operator-pending sequences (g_, z_, m_, '_, `_, [_, ]_). + +/// Vim state machine for count prefixes and operator-pending sequences. +#[derive(Debug, Default)] +pub struct VimState { + /// Accumulating count prefix (e.g., "12" for 12G) + pub count: String, + /// Operator-pending character: g, z, m, ', `, [, ] + pub pending: Option, +} + +impl VimState { + /// Create a new VimState. + pub fn new() -> Self { + Self::default() + } + + /// Accumulate a digit into the count. Returns true if the character was a digit + /// and was accumulated (or if it was '0' but we already have a count started). + /// Returns false if the char is not a digit, or if it's a bare '0' (which should + /// be handled as line-start motion). + pub fn accumulate_count(&mut self, c: char) -> bool { + if !c.is_ascii_digit() { + return false; + } + // Bare '0' when no count started = line-start motion, not count + if c == '0' && self.count.is_empty() { + return false; + } + self.count.push(c); + true + } + + /// Take the accumulated count, returning the parsed value (minimum 1). + /// Clears the count buffer. + pub fn take_count(&mut self) -> usize { + let count = self.count.parse::().unwrap_or(1).max(1); + self.count.clear(); + count + } + + /// Peek at the current count without consuming it. + pub fn peek_count(&self) -> usize { + self.count.parse::().unwrap_or(1).max(1) + } + + /// Check if there's a pending count being accumulated. + pub fn has_count(&self) -> bool { + !self.count.is_empty() + } + + /// Set the pending operator character. + pub fn set_pending(&mut self, c: char) { + self.pending = Some(c); + } + + /// Take the pending operator, returning it and clearing the pending state. + pub fn take_pending(&mut self) -> Option { + self.pending.take() + } + + /// Check if there's a pending operator. + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } + + /// Clear all state (count and pending). + pub fn clear(&mut self) { + self.count.clear(); + self.pending = None; + } + + /// Check if this character starts an operator-pending sequence. + pub fn is_operator_pending_starter(c: char) -> bool { + matches!(c, 'g' | 'z' | 'm' | '\'' | '`' | '[' | ']') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_accumulate_count() { + let mut vim = VimState::new(); + assert!(vim.accumulate_count('5')); + assert!(vim.accumulate_count('2')); + assert_eq!(vim.take_count(), 52); + } + + #[test] + fn test_bare_zero() { + let mut vim = VimState::new(); + assert!(!vim.accumulate_count('0')); // bare 0 is not accumulated + assert_eq!(vim.take_count(), 1); + } + + #[test] + fn test_zero_in_count() { + let mut vim = VimState::new(); + assert!(vim.accumulate_count('1')); + assert!(vim.accumulate_count('0')); + assert_eq!(vim.take_count(), 10); + } + + #[test] + fn test_pending() { + let mut vim = VimState::new(); + vim.set_pending('g'); + assert!(vim.has_pending()); + assert_eq!(vim.take_pending(), Some('g')); + assert!(!vim.has_pending()); + } +} diff --git a/src/main.rs b/src/main.rs index 62535d8..a493343 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,56 +1,70 @@ -use std::io; +use std::io::stdout; +use std::panic; +use std::time::Duration; use color_eyre::Result; use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use ratatui::{prelude::*, widgets::*}; +use ratatui::prelude::*; + +mod app; +mod data; +mod input; +mod theme; +mod ui; + +use app::App; + +const TICK_RATE: Duration = Duration::from_millis(250); fn main() -> Result<()> { color_eyre::install()?; - enable_raw_mode()?; - io::stdout().execute(EnterAlternateScreen)?; + let hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + hook(info); + })); - let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; - - let result = run(&mut terminal); - - disable_raw_mode()?; - io::stdout().execute(LeaveAlternateScreen)?; + setup_terminal()?; + let result = run(); + restore_terminal()?; result } -fn run(terminal: &mut Terminal>) -> Result<()> { - loop { - terminal.draw(ui)?; +fn setup_terminal() -> Result<()> { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + Ok(()) +} - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; - } - match key.code { - KeyCode::Char('q') => return Ok(()), - _ => {} +fn restore_terminal() -> Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} + +fn run() -> Result<()> { + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + let mut app = App::new(); + + while app.running { + terminal.draw(|frame| app.draw(frame))?; + + if event::poll(TICK_RATE)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key(key); + } } + } else { + app.handle_tick(); } } -} - -fn ui(frame: &mut Frame) { - let area = frame.area(); - - let block = Block::bordered() - .title(" ui-agregator ") - .title_alignment(Alignment::Center) - .border_type(BorderType::Rounded); - - let text = Paragraph::new("press q to quit") - .alignment(Alignment::Center) - .block(block); - - frame.render_widget(text, area); + + Ok(()) } diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..2ddea8e --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] +//! Gruvbox dark palette for harmony TUI. + +use ratatui::style::Color; + +// Background shades (dark → light) +pub const BG0: Color = Color::Rgb(40, 40, 40); // #282828 +pub const BG1: Color = Color::Rgb(50, 48, 47); // #32302f +pub const BG2: Color = Color::Rgb(60, 56, 54); // #3c3836 +pub const BG3: Color = Color::Rgb(80, 73, 69); // #504945 +pub const BG4: Color = Color::Rgb(102, 92, 84); // #665c54 + +// Foreground shades (light → dark) +pub const FG0: Color = Color::Rgb(251, 241, 199); // #fbf1c7 +pub const FG1: Color = Color::Rgb(235, 219, 178); // #ebdbb2 +pub const FG2: Color = Color::Rgb(213, 196, 161); // #d5c4a1 +pub const FG3: Color = Color::Rgb(189, 174, 147); // #bdae93 + +// Gray +pub const GRAY: Color = Color::Rgb(146, 131, 116); // #928374 + +// Accent colors +pub const RED: Color = Color::Rgb(251, 73, 52); // #fb4934 +pub const GREEN: Color = Color::Rgb(184, 187, 38); // #b8bb26 +pub const YELLOW: Color = Color::Rgb(250, 189, 47); // #fabd2f +pub const BLUE: Color = Color::Rgb(131, 165, 152); // #83a598 +pub const PURPLE: Color = Color::Rgb(211, 134, 155); // #d3869b +pub const AQUA: Color = Color::Rgb(142, 192, 124); // #8ec07c +pub const ORANGE: Color = Color::Rgb(254, 128, 25); // #fe8019 + +// Selection and focus colors (from CSS) +pub const SELECT_BG: Color = Color::Rgb(69, 64, 61); // #45403d +pub const FOCUS_BG: Color = Color::Rgb(250, 189, 47); // #fabd2f (same as YELLOW) +pub const FOCUS_FG: Color = Color::Rgb(40, 40, 40); // #282828 (same as BG0) diff --git a/src/ui/cmdline.rs b/src/ui/cmdline.rs new file mode 100644 index 0000000..621c008 --- /dev/null +++ b/src/ui/cmdline.rs @@ -0,0 +1,73 @@ +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::Mode; +use crate::theme; + +pub struct CmdLineState { + pub text: String, +} + +impl Default for CmdLineState { + fn default() -> Self { + Self::new() + } +} + +impl CmdLineState { + pub fn new() -> Self { + Self { + text: String::new(), + } + } + + pub fn push(&mut self, c: char) { + self.text.push(c); + } + + pub fn pop(&mut self) -> Option { + self.text.pop() + } + + pub fn clear(&mut self) { + self.text.clear(); + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn take(&mut self) -> String { + std::mem::take(&mut self.text) + } +} + +pub fn render_cmdline(frame: &mut Frame, area: Rect, mode: Mode, text: &str, hint: &str) { + if mode == Mode::Normal { + return; + } + + let (prompt, prompt_style) = match mode { + Mode::Command => (":", Style::default().fg(theme::ORANGE)), + Mode::Search => ("/", Style::default().fg(theme::BLUE)), + Mode::Normal => return, + }; + + let cursor = if text.is_empty() { "█" } else { "" }; + let hint_text = if text.is_empty() { hint } else { "" }; + + let line = Line::from(vec![ + Span::styled(prompt, prompt_style), + Span::styled(text.to_string(), Style::default().fg(theme::FG1)), + Span::styled(cursor, Style::default().fg(theme::YELLOW)), + Span::styled(hint_text, Style::default().fg(theme::GRAY)), + ]); + + let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG0)); + frame.render_widget(paragraph, area); +} diff --git a/src/ui/library.rs b/src/ui/library.rs new file mode 100644 index 0000000..c6e79f1 --- /dev/null +++ b/src/ui/library.rs @@ -0,0 +1,606 @@ +#![allow(dead_code)] + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::data::{Album, AlbumStatus, Artist, Track}; +use crate::theme; +use crate::ui::pane::{section_divider, Pane}; +use crate::ui::progress_bar::progress_bar; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LibraryFocus { + #[default] + Artists, + Albums, + Tracks, +} + +pub struct LibraryState { + pub artists: Vec, + pub focus: LibraryFocus, + pub artist_state: ListState, + pub album_state: ListState, + pub track_state: ListState, +} + +impl LibraryState { + pub fn new(artists: Vec) -> Self { + let mut artist_state = ListState::default(); + let mut album_state = ListState::default(); + let mut track_state = ListState::default(); + + if !artists.is_empty() { + artist_state.select(Some(0)); + if !artists[0].albums.is_empty() { + album_state.select(Some(0)); + track_state.select(Some(0)); + } + } + + Self { + artists, + focus: LibraryFocus::Artists, + artist_state, + album_state, + track_state, + } + } + + pub fn selected_artist(&self) -> Option<&Artist> { + self.artist_state.selected().and_then(|i| self.artists.get(i)) + } + + pub fn selected_album(&self) -> Option<&Album> { + self.selected_artist() + .and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i))) + } + + pub fn move_up(&mut self) { + match self.focus { + LibraryFocus::Artists => { + if let Some(i) = self.artist_state.selected() { + if i > 0 { + self.artist_state.select(Some(i - 1)); + self.reset_album_selection(); + } + } + } + LibraryFocus::Albums => { + if let Some(i) = self.album_state.selected() { + if i > 0 { + self.album_state.select(Some(i - 1)); + self.reset_track_selection(); + } + } + } + LibraryFocus::Tracks => { + if let Some(i) = self.track_state.selected() { + if i > 0 { + self.track_state.select(Some(i - 1)); + } + } + } + } + } + + pub fn move_down(&mut self) { + match self.focus { + LibraryFocus::Artists => { + let max = self.artists.len().saturating_sub(1); + if let Some(i) = self.artist_state.selected() { + if i < max { + self.artist_state.select(Some(i + 1)); + self.reset_album_selection(); + } + } + } + LibraryFocus::Albums => { + let max = self.selected_artist() + .map(|a| a.albums.len().saturating_sub(1)) + .unwrap_or(0); + if let Some(i) = self.album_state.selected() { + if i < max { + self.album_state.select(Some(i + 1)); + self.reset_track_selection(); + } + } + } + LibraryFocus::Tracks => { + let max = self.track_count().saturating_sub(1); + if let Some(i) = self.track_state.selected() { + if i < max { + self.track_state.select(Some(i + 1)); + } + } + } + } + } + + fn track_count(&self) -> usize { + self.selected_album().map(|a| a.total as usize).unwrap_or(0) + } + + pub fn focus_left(&mut self) { + match self.focus { + LibraryFocus::Artists => {} + LibraryFocus::Albums => self.focus = LibraryFocus::Artists, + LibraryFocus::Tracks => self.focus = LibraryFocus::Albums, + } + } + + pub fn focus_right(&mut self) { + match self.focus { + LibraryFocus::Artists => { + if self.selected_artist().is_some() { + self.focus = LibraryFocus::Albums; + } + } + LibraryFocus::Albums => { + if self.selected_album().is_some() { + self.focus = LibraryFocus::Tracks; + } + } + LibraryFocus::Tracks => {} + } + } + + pub fn cycle_focus(&mut self) { + self.focus = match self.focus { + LibraryFocus::Artists => LibraryFocus::Albums, + LibraryFocus::Albums => LibraryFocus::Tracks, + LibraryFocus::Tracks => LibraryFocus::Artists, + }; + } + + fn reset_album_selection(&mut self) { + if let Some(artist) = self.selected_artist() { + if !artist.albums.is_empty() { + self.album_state.select(Some(0)); + } else { + self.album_state.select(None); + } + } + self.reset_track_selection(); + } + + fn reset_track_selection(&mut self) { + if self.selected_album().is_some() { + self.track_state.select(Some(0)); + } else { + self.track_state.select(None); + } + } + + pub fn artist_count(&self) -> usize { + self.artists.len() + } + + pub fn selected_artist_index(&self) -> Option { + self.artist_state.selected() + } +} + +fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) { + if !monitored { + return ('◌', Style::default().fg(theme::GRAY)); + } + match status { + AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)), + AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), + AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), + AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)), + } +} + +fn track_icon(have: bool) -> (char, Style) { + if have { + ('✓', Style::default().fg(theme::GREEN)) + } else { + ('✗', Style::default().fg(theme::RED)) + } +} + +fn artist_status(artist: &Artist) -> AlbumStatus { + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + if have == total { + AlbumStatus::Complete + } else if have == 0 { + AlbumStatus::Wanted + } else { + AlbumStatus::Partial + } +} + +fn fmt_size(gb: f64) -> String { + if gb >= 1.0 { + format!("{:.1} GB", gb) + } else { + format!("{} MB", (gb * 1024.0).round() as u32) + } +} + +pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area); + + render_artists_pane(frame, chunks[0], state); + render_detail_pane(frame, chunks[1], state); +} + +fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Artists; + let artist_count = state.artists.len(); + + let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum(); + let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum(); + + let footer = Line::from(vec![ + Span::styled( + format!("{} artists · {} alb", artist_count, total_albums), + Style::default().fg(theme::GRAY), + ), + Span::raw(" "), + Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)), + ]); + + let artist_count_str = artist_count.to_string(); + let pane = Pane::new("Artists") + .meta(&artist_count_str) + .focused(focused) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let items: Vec = state + .artists + .iter() + .map(|artist| { + let status = artist_status(artist); + let (icon_char, icon_style) = status_icon(status, artist.monitored); + + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + + let mut name_text = artist.name.clone(); + if !artist.monitored { + name_text.push_str(" ·unm"); + } + + let count_str = format!("{}/{}", have, total); + let name_width = inner.width as usize - 2 - count_str.len() - 2; + if name_text.len() > name_width { + name_text.truncate(name_width.saturating_sub(1)); + name_text.push('…'); + } + + let padding = name_width.saturating_sub(name_text.len()); + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(name_text, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(padding)), + Span::styled(count_str, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, inner, &mut state.artist_state); +} + +fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks; + + let artist = state.selected_artist(); + + let meta = artist + .map(|a| format!("{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or(""))) + .unwrap_or_default(); + + let have_tracks: u16 = artist + .map(|a| a.albums.iter().map(|al| al.have).sum()) + .unwrap_or(0); + let total_tracks: u16 = artist + .map(|a| a.albums.iter().map(|al| al.total).sum()) + .unwrap_or(0); + + let footer = Line::from(vec![ + Span::styled("[a]", Style::default().fg(theme::GRAY)), + Span::styled(" add ", Style::default().fg(theme::FG2)), + Span::styled("[m]", Style::default().fg(theme::GRAY)), + Span::styled(" monitor ", Style::default().fg(theme::FG2)), + Span::styled("[s]", Style::default().fg(theme::GRAY)), + Span::styled(" search ", Style::default().fg(theme::FG2)), + Span::styled("[r]", Style::default().fg(theme::GRAY)), + Span::styled(" refresh", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("{}/{} tracks", have_tracks, total_tracks), + Style::default().fg(theme::GRAY), + ), + ]); + + let pane = Pane::new("Detail") + .meta(&meta) + .focused(focused) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::vertical([ + Constraint::Length(6), + Constraint::Length(1), + Constraint::Percentage(40), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + if let Some(artist) = artist { + render_artist_header(frame, chunks[0], artist); + } + + let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0); + let albums_label = format!("{} releases", albums_count); + let album_divider = section_divider("albums", Some(&albums_label)); + frame.render_widget(Paragraph::new(album_divider), chunks[1]); + + let selected_artist_idx = state.artist_state.selected(); + if let Some(idx) = selected_artist_idx { + if let Some(artist) = state.artists.get(idx) { + let albums = artist.albums.clone(); + let focus = state.focus; + render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); + } + } + + let album_title = state + .selected_album() + .map(|a| a.title.clone()) + .unwrap_or_default(); + let track_counts = state + .selected_album() + .map(|a| format!("{}/{}", a.have, a.total)) + .unwrap_or_default(); + let track_label = format!("tracks · {}", album_title); + let track_divider = section_divider(&track_label, Some(&track_counts)); + frame.render_widget(Paragraph::new(track_divider), chunks[3]); + + render_tracks_list(frame, chunks[4], state); +} + +fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + + let (status_icon, status_text, status_style) = if artist.monitored { + ( + Span::styled("● ", Style::default().fg(theme::GREEN)), + "Monitored", + Style::default().fg(theme::FG2), + ) + } else { + ( + Span::styled("◌ ", Style::default().fg(theme::GRAY)), + "Unmonitored", + Style::default().fg(theme::GRAY), + ) + }; + + let lines = vec![ + Line::from(Span::styled( + &artist.name, + Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("status ", Style::default().fg(theme::GRAY)), + status_icon, + Span::styled(status_text, status_style), + Span::raw(" "), + Span::styled("path ", Style::default().fg(theme::GRAY)), + Span::styled(&artist.path, Style::default().fg(theme::AQUA)), + ]), + Line::from(vec![ + Span::styled("quality ", Style::default().fg(theme::GRAY)), + Span::styled(&artist.quality, Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("size ", Style::default().fg(theme::GRAY)), + Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)), + ]), + Line::from(vec![ + Span::styled("albums ", Style::default().fg(theme::GRAY)), + Span::styled(artist.albums.len().to_string(), Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("tracks ", Style::default().fg(theme::GRAY)), + Span::styled(have.to_string(), Style::default().fg(theme::FG1)), + Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)), + ]), + ]; + + let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0)); + frame.render_widget(paragraph, area); +} + +fn render_albums_list( + frame: &mut Frame, + area: Rect, + albums: &[Album], + focus: LibraryFocus, + album_state: &mut ListState, +) { + let focused = focus == LibraryFocus::Albums; + + let items: Vec = albums + .iter() + .map(|album| { + let (icon_char, icon_style) = status_icon(album.status, album.monitored); + let type_str = format!("[{}]", album.album_type); + let year_str = album.year.to_string(); + let progress = progress_bar(album.have, album.total, 10, album.status); + let count_str = format!("{}/{}", album.have, album.total); + + let quality_style = if album.quality == "—" { + Style::default().fg(theme::GRAY) + } else { + Style::default().fg(theme::AQUA) + }; + + let title_width = area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8; + let mut title = album.title.clone(); + if title.len() > title_width { + title.truncate(title_width.saturating_sub(1)); + title.push('…'); + } + + let mut spans = vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(title, Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled(type_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(year_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + ]; + spans.extend(progress.spans); + spans.push(Span::raw(" ")); + spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(&album.quality, quality_style)); + + Line::from(spans).into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, area, album_state); +} + +fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Tracks; + let tracks = state.get_tracks(); + + if tracks.is_empty() { + let msg = Paragraph::new(Span::styled( + "(no album selected)", + Style::default().fg(theme::GRAY), + )); + frame.render_widget(msg, area); + return; + } + + let items: Vec = tracks + .iter() + .map(|track| { + let (icon_char, icon_style) = track_icon(track.have); + let num_str = format!("{:02}", track.number); + + let title_style = if track.have { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::GRAY) + }; + + let quality_style = if track.have { + Style::default().fg(theme::AQUA) + } else { + Style::default().fg(theme::RED) + }; + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(num_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(&track.title, title_style), + Span::raw(" "), + Span::styled(&track.duration, Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(&track.quality, quality_style), + ]) + .into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, area, &mut state.track_state); +} + +impl LibraryState { + pub fn get_tracks(&self) -> Vec { + let Some(album) = self.selected_album() else { + return Vec::new(); + }; + + if album.id == "bush" { + return crate::data::sample_tracks_bush_hall(); + } + + tracks_for(album) + } +} + +fn tracks_for(album: &Album) -> Vec { + let titles = [ + "Opening", "Curtain Call", "Half-Light", "Polaroid", "Switchback", + "Slow Dancer", "The Inheritance", "Glassworks", "Interlude", "Aftermath", + "Static", "Returner", "Dust Bowl", "Postcard", "Late Reply", + "Honeymoon", "Northern Lights", "Cold Open", "Coda", "Reprise", + ]; + + (0..album.total) + .map(|i| { + let idx = i as usize; + let m = 2 + ((idx * 7) % 5); + let s = (idx * 13) % 60; + let title_idx = idx % titles.len(); + let title = if idx >= titles.len() { + format!("{} II", titles[title_idx]) + } else { + titles[title_idx].to_string() + }; + let have = i < album.have; + let quality = if have { + album.quality.clone() + } else { + "—".to_string() + }; + + Track { + number: i + 1, + title, + duration: format!("{}:{:02}", m, s), + have, + quality, + } + }) + .collect() +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..f317d7e --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,10 @@ +pub mod cmdline; +pub mod library; +pub mod modals; +pub mod notifications; +pub mod pane; +pub mod progress_bar; +pub mod statusbar; +pub mod topbar; +pub mod views; +pub mod which_key; diff --git a/src/ui/modals/help.rs b/src/ui/modals/help.rs new file mode 100644 index 0000000..4f479d8 --- /dev/null +++ b/src/ui/modals/help.rs @@ -0,0 +1,152 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::theme; + +fn keybind_row<'a>(key: &'a str, desc: &'a str) -> Line<'a> { + Line::from(vec![ + Span::styled(format!("{:<14}", key), Style::default().fg(theme::YELLOW)), + Span::styled(desc, Style::default().fg(theme::FG2)), + ]) +} + +fn section_header(title: &str) -> Line<'static> { + Line::from(Span::styled( + title.to_string(), + Style::default() + .fg(theme::FG1) + .add_modifier(Modifier::BOLD), + )) +} + +pub fn render_help_modal(frame: &mut Frame, area: Rect) { + let modal_width = 96u16.min(area.width.saturating_sub(4)); + let modal_height = 28u16.min(area.height.saturating_sub(2)); + + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::YELLOW)) + .title(Line::from(vec![ + Span::styled("─ ", Style::default().fg(theme::YELLOW)), + Span::styled( + "Keybindings · evil-mode (Doom Emacs)", + Style::default().fg(theme::YELLOW), + ), + Span::styled(" ─", Style::default().fg(theme::YELLOW)), + ])) + .style(Style::default().bg(theme::BG0)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let cols = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(inner); + + render_col1(frame, cols[0]); + render_col2(frame, cols[1]); + render_col3(frame, cols[2]); + + let footer_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let footer = Paragraph::new(Line::from(vec![ + Span::styled( + "harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs)", + Style::default().fg(theme::GRAY), + ), + ])); + frame.render_widget(footer, footer_area); +} + +fn render_col1(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("Motion · char/line"), + keybind_row("h j k l", "left / down / up / right"), + keybind_row("w / W", "next word / WORD"), + keybind_row("b / B", "prev word / WORD"), + keybind_row("e / E", "end of word / WORD"), + keybind_row("ge / gE", "back to end of (W)ORD"), + keybind_row("0 / ^", "line start (focus left)"), + keybind_row("$", "line end (focus right)"), + keybind_row("{N}", "repeat motion N times"), + Line::from(""), + section_header("Motion · file/page"), + keybind_row("g g", "first line"), + keybind_row("G", "last line"), + keybind_row("{N} G", "go to line N"), + keybind_row("g t / g T", "next / prev tab"), + keybind_row("C-d / C-u", "½ page down/up"), + keybind_row("C-f / C-b", "page down/up"), + keybind_row("C-e / C-y", "scroll line down/up"), + keybind_row("H / M / L", "viewport top / mid / bot"), + keybind_row("{ / }", "paragraph back/fwd"), + keybind_row("[[ / ]]", "section back/fwd"), + keybind_row("[c / ]c", "prev / next change"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} + +fn render_col2(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("Search & jumps"), + keybind_row("/ pat", "filter library"), + keybind_row("? pat", "search backward"), + keybind_row("n / N", "next / prev match"), + keybind_row("* / #", "search word fwd/back"), + keybind_row("C-o / C-i", "jumplist back / fwd"), + keybind_row("m{a-z}", "set mark"), + keybind_row("'{a-z}", "jump to mark line"), + keybind_row("`{a-z}", "jump to mark exact"), + keybind_row("''", "jump to last position"), + Line::from(""), + section_header("Center · z_"), + keybind_row("z z / z .", "center cursor"), + keybind_row("z t", "cursor → top"), + keybind_row("z b / z -", "cursor → bottom"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} + +fn render_col3(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("SPC leader (Doom)"), + keybind_row("SPC SPC", "M-x command"), + keybind_row("SPC b", "+buffer (tabs)"), + keybind_row("SPC f", "+file / library"), + keybind_row("SPC s", "+search"), + keybind_row("SPC w", "+window / pane"), + keybind_row("SPC t", "+toggle / theme"), + keybind_row("SPC n", "+notifications"), + keybind_row("SPC a", "+actions / artist"), + keybind_row("SPC q", "+quit"), + keybind_row("SPC h", "+help"), + keybind_row("SPC l/w/h/c", "→ tab quick"), + Line::from(""), + section_header("Modes & ex commands"), + keybind_row(":w / :sync", "save library"), + keybind_row(":q", "quit"), + keybind_row(":theme", "dark | light"), + keybind_row("a · t · s · r", "add·toggle·search·refresh"), + keybind_row("1‥6", "switch tab"), + keybind_row("Enter / Esc", "open / back"), + keybind_row("?", "this help"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} diff --git a/src/ui/modals/mod.rs b/src/ui/modals/mod.rs new file mode 100644 index 0000000..2bb4d01 --- /dev/null +++ b/src/ui/modals/mod.rs @@ -0,0 +1,11 @@ +pub mod help; +pub mod quit; + +pub use help::render_help_modal; +pub use quit::render_quit_modal; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModalKind { + Help, + Quit, +} diff --git a/src/ui/modals/quit.rs b/src/ui/modals/quit.rs new file mode 100644 index 0000000..479e0c8 --- /dev/null +++ b/src/ui/modals/quit.rs @@ -0,0 +1,59 @@ +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::theme; + +pub fn render_quit_modal(frame: &mut Frame, area: Rect, downloads_in_progress: usize) { + let modal_width = 48u16.min(area.width.saturating_sub(4)); + let modal_height = 7u16.min(area.height.saturating_sub(2)); + + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::YELLOW)) + .title(Line::from(vec![ + Span::styled("─ ", Style::default().fg(theme::YELLOW)), + Span::styled("quit harmony?", Style::default().fg(theme::YELLOW)), + Span::styled(" ─", Style::default().fg(theme::YELLOW)), + ])) + .style(Style::default().bg(theme::BG0)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let msg = if downloads_in_progress > 0 { + format!( + "{} downloads in progress will continue.", + downloads_in_progress + ) + } else { + "No downloads in progress.".to_string() + }; + + let lines = vec![ + Line::from(""), + Line::from(Span::styled(msg, Style::default().fg(theme::FG2))), + Line::from(""), + Line::from(vec![ + Span::styled("press ", Style::default().fg(theme::GRAY)), + Span::styled("y", Style::default().fg(theme::YELLOW)), + Span::styled(" to confirm, ", Style::default().fg(theme::GRAY)), + Span::styled("n", Style::default().fg(theme::YELLOW)), + Span::styled(" to cancel", Style::default().fg(theme::GRAY)), + ]), + ]; + + let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); + frame.render_widget(para, inner); +} diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs new file mode 100644 index 0000000..dcb071b --- /dev/null +++ b/src/ui/notifications.rs @@ -0,0 +1,169 @@ +#![allow(dead_code)] + +use std::time::Instant; + +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::theme; + +const NOTIFICATION_TTL_SECS: u64 = 4; +const MAX_VISIBLE: usize = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifKind { + Info, + Success, + Warn, + Error, +} + +impl NotifKind { + fn color(self) -> ratatui::style::Color { + match self { + NotifKind::Info => theme::BLUE, + NotifKind::Success => theme::GREEN, + NotifKind::Warn => theme::YELLOW, + NotifKind::Error => theme::RED, + } + } +} + +pub struct Notification { + pub id: u64, + pub title: String, + pub detail: Option, + pub kind: NotifKind, + pub icon: String, + pub created_at: Instant, +} + +pub struct NotificationManager { + notifications: Vec, + next_id: u64, +} + +impl Default for NotificationManager { + fn default() -> Self { + Self::new() + } +} + +impl NotificationManager { + pub fn new() -> Self { + Self { + notifications: Vec::new(), + next_id: 1, + } + } + + pub fn push(&mut self, title: impl Into, detail: Option, kind: NotifKind, icon: impl Into) { + let notification = Notification { + id: self.next_id, + title: title.into(), + detail, + kind, + icon: icon.into(), + created_at: Instant::now(), + }; + self.next_id += 1; + self.notifications.push(notification); + + while self.notifications.len() > MAX_VISIBLE * 2 { + self.notifications.remove(0); + } + } + + pub fn tick(&mut self) { + let now = Instant::now(); + self.notifications.retain(|n| { + now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS + }); + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let visible: Vec<&Notification> = self + .notifications + .iter() + .rev() + .take(MAX_VISIBLE) + .collect::>() + .into_iter() + .rev() + .collect(); + + if visible.is_empty() { + return; + } + + let notif_width = 36u16.min(area.width); + let notif_height = 3u16; + let spacing = 1u16; + let total_height = visible.len() as u16 * (notif_height + spacing); + + let start_y = area.y + area.height.saturating_sub(total_height + 1); + let start_x = area.x + area.width.saturating_sub(notif_width + 2); + + for (i, notif) in visible.iter().enumerate() { + let y = start_y + (i as u16) * (notif_height + spacing); + let notif_area = Rect::new(start_x, y, notif_width, notif_height); + + frame.render_widget(Clear, notif_area); + + let border_color = notif.kind.color(); + let block = Block::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme::BG1)); + + let inner = block.inner(notif_area); + frame.render_widget(block, notif_area); + + let elapsed = Instant::now().duration_since(notif.created_at).as_secs(); + let timestamp = if elapsed == 0 { + "now".to_string() + } else { + format!("{}s", elapsed) + }; + + let mut lines = vec![Line::from(vec![ + Span::styled(¬if.icon, Style::default().fg(border_color)), + Span::raw(" "), + Span::styled( + ¬if.title, + Style::default() + .fg(theme::FG1) + .add_modifier(ratatui::style::Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(timestamp, Style::default().fg(theme::GRAY)), + ])]; + + if let Some(detail) = ¬if.detail { + let mut d = detail.clone(); + let max_len = inner.width.saturating_sub(4) as usize; + if d.len() > max_len { + d.truncate(max_len.saturating_sub(1)); + d.push('…'); + } + lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY)))); + } + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); + } + } + + pub fn len(&self) -> usize { + self.notifications.len() + } + + pub fn is_empty(&self) -> bool { + self.notifications.is_empty() + } +} diff --git a/src/ui/pane.rs b/src/ui/pane.rs new file mode 100644 index 0000000..e7e051d --- /dev/null +++ b/src/ui/pane.rs @@ -0,0 +1,103 @@ +//! Reusable Pane widget with styled borders and title. + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Widget}, +}; + +use crate::theme; + +pub struct Pane<'a> { + title: &'a str, + meta: Option<&'a str>, + focused: bool, + footer: Option>, +} + +impl<'a> Pane<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + meta: None, + focused: false, + footer: None, + } + } + + pub fn meta(mut self, meta: &'a str) -> Self { + self.meta = Some(meta); + self + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn footer(mut self, footer: Line<'a>) -> Self { + self.footer = Some(footer); + self + } + + pub fn build_block(&self) -> Block<'a> { + let border_color = if self.focused { theme::YELLOW } else { theme::BG3 }; + let title_color = if self.focused { theme::YELLOW } else { theme::GRAY }; + + let mut title_spans = vec![ + Span::styled("─[ ", Style::default().fg(border_color)), + Span::styled(self.title, Style::default().fg(title_color)), + ]; + + if let Some(meta) = self.meta { + title_spans.push(Span::styled(" · ", Style::default().fg(theme::GRAY))); + title_spans.push(Span::styled(meta, Style::default().fg(theme::GRAY))); + } + + title_spans.push(Span::styled(" ]─", Style::default().fg(border_color))); + + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(Line::from(title_spans)) + .style(Style::default().bg(theme::BG0)) + } +} + +impl Widget for Pane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = self.build_block(); + block.render(area, buf); + + if let Some(footer) = self.footer { + if area.height > 2 { + let footer_y = area.y + area.height - 1; + let footer_x = area.x + 2; + let footer_width = area.width.saturating_sub(4); + + if footer_width > 0 { + buf.set_line(footer_x, footer_y, &footer, footer_width); + } + } + } + } +} + +pub fn section_divider<'a>(label: &'a str, right: Option<&'a str>) -> Line<'a> { + let mut spans = vec![ + Span::styled("─ ", Style::default().fg(theme::BG3)), + Span::styled(label, Style::default().fg(theme::GRAY)), + Span::styled(" ─", Style::default().fg(theme::BG3)), + ]; + + if let Some(r) = right { + spans.push(Span::styled( + format!(" {}", r), + Style::default().fg(theme::GRAY), + )); + } + + Line::from(spans) +} diff --git a/src/ui/progress_bar.rs b/src/ui/progress_bar.rs new file mode 100644 index 0000000..ba2715f --- /dev/null +++ b/src/ui/progress_bar.rs @@ -0,0 +1,62 @@ +//! Unicode progress bar widget. + +use ratatui::{ + style::Style, + text::{Line, Span}, +}; + +use crate::data::AlbumStatus; +use crate::theme; + +/// Renders a unicode progress bar using ▰ (filled) and ▱ (empty). +/// Returns a Line with colored spans based on status: +/// - Complete: green filled +/// - Partial: yellow filled +/// - Wanted: red filled +/// - Unmonitored: gray filled +pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) -> Line<'static> { + let filled_count = if total == 0 { + 0 + } else { + ((have as usize * width) + total as usize - 1) / total as usize + }; + let empty_count = width.saturating_sub(filled_count); + + let filled_color = match status { + AlbumStatus::Complete => theme::GREEN, + AlbumStatus::Partial => theme::YELLOW, + AlbumStatus::Wanted => theme::RED, + AlbumStatus::Unmonitored => theme::GRAY, + }; + + let filled_str: String = "▰".repeat(filled_count); + let empty_str: String = "▱".repeat(empty_count); + + Line::from(vec![ + Span::styled(filled_str, Style::default().fg(filled_color)), + Span::styled(empty_str, Style::default().fg(theme::BG3)), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_progress_bar_full() { + let line = progress_bar(10, 10, 10, AlbumStatus::Complete); + assert_eq!(line.spans.len(), 2); + } + + #[test] + fn test_progress_bar_empty() { + let line = progress_bar(0, 10, 10, AlbumStatus::Wanted); + assert_eq!(line.spans.len(), 2); + } + + #[test] + fn test_progress_bar_partial() { + let line = progress_bar(5, 10, 10, AlbumStatus::Partial); + assert_eq!(line.spans.len(), 2); + } +} diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs new file mode 100644 index 0000000..f8bbc37 --- /dev/null +++ b/src/ui/statusbar.rs @@ -0,0 +1,120 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::Mode; +use crate::theme; + +pub struct StatusHint { + pub key: &'static str, + pub action: &'static str, +} + +pub fn render_statusbar( + frame: &mut Frame, + area: Rect, + mode: Mode, + hints: &[StatusHint], + position: Option<(usize, usize)>, + queue_count: usize, + wanted_count: usize, + status_message: Option<&str>, +) { + let mut spans = Vec::new(); + + let mode_str = match mode { + Mode::Normal => "NORMAL", + Mode::Command => "COMMAND", + Mode::Search => "SEARCH", + }; + let mode_bg = match mode { + Mode::Normal => theme::GREEN, + Mode::Command => theme::ORANGE, + Mode::Search => theme::BLUE, + }; + spans.push(Span::styled( + format!(" {} ", mode_str), + Style::default() + .fg(theme::BG0) + .bg(mode_bg) + .add_modifier(Modifier::BOLD), + )); + + spans.push(Span::styled(" ", Style::default().bg(theme::BG2))); + + if let Some(msg) = status_message { + spans.push(Span::styled( + msg.to_string(), + Style::default().fg(theme::FG1).bg(theme::BG2), + )); + } else { + for hint in hints { + spans.push(Span::styled( + format!("[{}]", hint.key), + Style::default().fg(theme::ORANGE).bg(theme::BG2), + )); + spans.push(Span::styled( + format!(" {} ", hint.action), + Style::default().fg(theme::GRAY).bg(theme::BG2), + )); + } + } + + let left_width: usize = spans.iter().map(|s| s.content.len()).sum(); + + let mut right_spans = Vec::new(); + + if let Some((current, total)) = position { + right_spans.push(Span::styled( + format!(" {}/{} ", current, total), + Style::default().fg(theme::FG2).bg(theme::BG2), + )); + } + + if queue_count > 0 { + right_spans.push(Span::styled( + format!(" {} {} ", '\u{2193}', queue_count), + Style::default() + .fg(theme::BG0) + .bg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + )); + } + + if wanted_count > 0 { + right_spans.push(Span::styled( + format!(" ! {} ", wanted_count), + Style::default() + .fg(theme::BG0) + .bg(theme::BLUE) + .add_modifier(Modifier::BOLD), + )); + } + + right_spans.push(Span::styled( + " 47.3 GB free ", + Style::default().fg(theme::GRAY).bg(theme::BG2), + )); + + right_spans.push(Span::styled( + " harmony 0.4.2 ", + Style::default().fg(theme::GRAY).bg(theme::BG2), + )); + + let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum(); + let spacer_width = area.width.saturating_sub(left_width as u16).saturating_sub(right_width as u16) as usize; + + spans.push(Span::styled( + " ".repeat(spacer_width), + Style::default().bg(theme::BG2), + )); + spans.extend(right_spans); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG2)); + frame.render_widget(paragraph, area); +} diff --git a/src/ui/topbar.rs b/src/ui/topbar.rs new file mode 100644 index 0000000..fe2245b --- /dev/null +++ b/src/ui/topbar.rs @@ -0,0 +1,99 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::{Mode, Tab}; +use crate::theme; + +pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode, queue_count: usize, wanted_count: usize) { + let mut spans = Vec::new(); + + spans.push(Span::styled( + " ▲ harmony ", + Style::default() + .fg(theme::BG0) + .bg(theme::ORANGE) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + + let tabs = [ + (Tab::Library, "Library", None), + (Tab::Wanted, "Wanted", if wanted_count > 0 { Some(wanted_count) } else { None }), + (Tab::Queue, "Queue", if queue_count > 0 { Some(queue_count) } else { None }), + (Tab::History, "History", None), + (Tab::Calendar, "Calendar", None), + (Tab::Settings, "Settings", None), + ]; + + for (i, (tab, label, badge)) in tabs.iter().enumerate() { + let is_active = *tab == active_tab; + let num = format!("{}", i + 1); + + if is_active { + spans.push(Span::styled( + format!(" {}", num), + Style::default().fg(theme::ORANGE).bg(theme::BG0), + )); + spans.push(Span::styled( + format!(" {} ", label), + Style::default() + .fg(theme::YELLOW) + .bg(theme::BG0) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!(" {}", num), + Style::default().fg(theme::GRAY).bg(theme::BG1), + )); + spans.push(Span::styled( + format!(" {} ", label), + Style::default().fg(theme::FG3).bg(theme::BG1), + )); + } + + if let Some(count) = badge { + spans.push(Span::styled( + format!(" {} ", count), + Style::default() + .fg(theme::BG0) + .bg(theme::RED) + .add_modifier(Modifier::BOLD), + )); + } + } + + let used_width: usize = spans.iter().map(|s| s.content.len()).sum(); + let remaining = area.width.saturating_sub(used_width as u16).saturating_sub(10) as usize; + spans.push(Span::styled( + " ".repeat(remaining), + Style::default().bg(theme::BG1), + )); + + let mode_str = match mode { + Mode::Normal => "NORMAL", + Mode::Command => "COMMAND", + Mode::Search => "SEARCH", + }; + let mode_bg = match mode { + Mode::Normal => theme::GREEN, + Mode::Command => theme::ORANGE, + Mode::Search => theme::BLUE, + }; + spans.push(Span::styled( + format!(" {} ", mode_str), + Style::default() + .fg(theme::BG0) + .bg(mode_bg) + .add_modifier(Modifier::BOLD), + )); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1)); + frame.render_widget(paragraph, area); +} diff --git a/src/ui/views/calendar.rs b/src/ui/views/calendar.rs new file mode 100644 index 0000000..cb2909b --- /dev/null +++ b/src/ui/views/calendar.rs @@ -0,0 +1,206 @@ +//! Calendar view - upcoming releases. + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::data::CalendarEntry; +use crate::theme; +use crate::ui::pane::Pane; + +struct CalendarCell { + day: u8, + dim: bool, + is_today: bool, + events: Vec, +} + +pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]) { + let meta = "upcoming releases · May 2026"; + + let footer = Line::from(vec![ + Span::styled("[h/l]", Style::default().fg(theme::GRAY)), + Span::styled(" month · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" details", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("{} upcoming", calendar.len()), + Style::default().fg(theme::GRAY), + ), + ]); + + let pane = Pane::new("Calendar") + .meta(meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Build calendar grid for May 2026 + let today_day = 8u8; // May 8, 2026 + let year = 2026u16; + let month = 5u8; + + // May 2026 starts on Friday (day_of_week = 5) + let start_dow = 5u8; + let days_in_month = 31u8; + let days_in_prev = 30u8; // April has 30 days + + let mut cells: Vec = Vec::new(); + + // Previous month days + for i in 0..start_dow { + cells.push(CalendarCell { + day: days_in_prev - start_dow + i + 1, + dim: true, + is_today: false, + events: Vec::new(), + }); + } + + // Current month days + for d in 1..=days_in_month { + let events: Vec = calendar + .iter() + .filter(|e| { + if let Some(day_str) = e.date.split('-').nth(2) { + if let Ok(day) = day_str.parse::() { + let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); + return month_match && day == d; + } + } + false + }) + .cloned() + .collect(); + + cells.push(CalendarCell { + day: d, + dim: false, + is_today: d == today_day, + events, + }); + } + + // Next month days to fill 6 weeks (42 cells) + let mut next_day = 1u8; + while cells.len() < 42 { + cells.push(CalendarCell { + day: next_day, + dim: true, + is_today: false, + events: Vec::new(), + }); + next_day += 1; + } + + // Layout: header + day-of-week + 6 rows of days + let chunks = Layout::vertical([ + Constraint::Length(1), // Month header + Constraint::Length(1), // Day of week labels + Constraint::Fill(1), // Calendar grid + ]) + .split(inner); + + // Month header: "◀ May 2026 ▶" + let header = Line::from(vec![ + Span::styled("◀ ", Style::default().fg(theme::GRAY)), + Span::styled( + "May 2026", + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ▶", Style::default().fg(theme::GRAY)), + ]); + frame.render_widget( + Paragraph::new(header).alignment(ratatui::layout::Alignment::Center), + chunks[0], + ); + + // Day of week labels + let dow_labels = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + let cell_width = chunks[2].width / 7; + let dow_spans: Vec = dow_labels + .iter() + .map(|&d| { + let pad = cell_width.saturating_sub(3) as usize; + Span::styled( + format!("{}{}", d, " ".repeat(pad)), + Style::default().fg(theme::GRAY), + ) + }) + .collect(); + frame.render_widget(Paragraph::new(Line::from(dow_spans)), chunks[1]); + + // Calendar grid - 6 rows x 7 cols + let row_height = chunks[2].height / 6; + let grid_rows = Layout::vertical(vec![Constraint::Length(row_height); 6]).split(chunks[2]); + + for (row_idx, row_area) in grid_rows.iter().enumerate() { + let col_areas = Layout::horizontal(vec![Constraint::Ratio(1, 7); 7]).split(*row_area); + + for (col_idx, col_area) in col_areas.iter().enumerate() { + let cell_idx = row_idx * 7 + col_idx; + if cell_idx < cells.len() { + render_calendar_cell(frame, *col_area, &cells[cell_idx]); + } + } + } +} + +fn render_calendar_cell(frame: &mut Frame, area: Rect, cell: &CalendarCell) { + if area.height == 0 || area.width == 0 { + return; + } + + let day_style = if cell.dim { + Style::default().fg(theme::BG4) + } else if cell.is_today { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::FG2) + }; + + let mut lines: Vec = Vec::new(); + + // First line: day number with optional today marker + let day_line = if cell.is_today { + Line::from(vec![ + Span::styled(format!("{}", cell.day), day_style), + Span::styled(" ●", Style::default().fg(theme::ORANGE)), + ]) + } else { + Line::from(Span::styled(format!("{}", cell.day), day_style)) + }; + lines.push(day_line); + + // Event lines + let max_events = (area.height.saturating_sub(1)) as usize; + for event in cell.events.iter().take(max_events) { + let event_style = match event.status.as_str() { + "announced" => Style::default().fg(theme::YELLOW), + "monitored" => Style::default().fg(theme::GREEN), + _ => Style::default().fg(theme::FG2), + }; + + let mut display = event.artist.clone(); + let max_len = area.width.saturating_sub(1) as usize; + if display.len() > max_len { + display.truncate(max_len.saturating_sub(1)); + display.push('…'); + } + + lines.push(Line::from(Span::styled(display, event_style))); + } + + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} diff --git a/src/ui/views/history.rs b/src/ui/views/history.rs new file mode 100644 index 0000000..174aea8 --- /dev/null +++ b/src/ui/views/history.rs @@ -0,0 +1,90 @@ +//! History view - recent activity. + +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, + Frame, +}; + +use crate::data::HistoryEntry; +use crate::theme; +use crate::ui::pane::Pane; + +fn event_style(event: &str) -> (char, &'static str, Style) { + match event { + "imported" => ('✓', "imported", Style::default().fg(theme::GREEN)), + "downloaded" => ('↓', "downloaded", Style::default().fg(theme::AQUA)), + "grabbed" => ('⤓', "grabbed", Style::default().fg(theme::BLUE)), + "search" => ('?', "search", Style::default().fg(theme::YELLOW)), + "refreshed" => ('↻', "refreshed", Style::default().fg(theme::PURPLE)), + "failed" => ('✗', "failed", Style::default().fg(theme::RED)), + _ => ('·', "unknown", Style::default().fg(theme::GRAY)), + } +} + +pub fn render_history( + frame: &mut Frame, + area: Rect, + history: &[HistoryEntry], + state: &mut ListState, +) { + let meta = format!("{} events", history.len()); + + let footer = Line::from(vec![ + Span::styled("[d]", Style::default().fg(theme::GRAY)), + Span::styled(" clear · ", Style::default().fg(theme::FG2)), + Span::styled("[r]", Style::default().fg(theme::GRAY)), + Span::styled(" retry failed", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled("last sync 12:04", Style::default().fg(theme::GRAY)), + ]); + + let pane = Pane::new("History") + .meta(&meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let items: Vec = history + .iter() + .map(|entry| { + let (icon, label, style) = event_style(&entry.event); + + // Calculate widths + let total_fixed = 11 + 12 + 2 + 22; // when + event_label + icon + artist + let detail_width = (inner.width as usize).saturating_sub(total_fixed); + + let mut artist = entry.artist.clone(); + if artist.len() > 20 { + artist.truncate(19); + artist.push('…'); + } + let artist_pad = 22_usize.saturating_sub(artist.len()); + + let mut detail = entry.detail.clone(); + if detail.len() > detail_width { + detail.truncate(detail_width.saturating_sub(1)); + detail.push('…'); + } + + Line::from(vec![ + Span::styled(format!("{:<11}", entry.when), Style::default().fg(theme::GRAY)), + Span::styled(format!("{:<12}", label), style), + Span::styled(format!("{} ", icon), style), + Span::styled(artist, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(artist_pad)), + Span::styled(detail, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let list = List::new(items) + .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, inner, state); +} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs new file mode 100644 index 0000000..201cc90 --- /dev/null +++ b/src/ui/views/mod.rs @@ -0,0 +1,13 @@ +//! Tab view modules. + +pub mod calendar; +pub mod history; +pub mod queue; +pub mod settings; +pub mod wanted; + +pub use calendar::render_calendar; +pub use history::render_history; +pub use queue::render_queue; +pub use settings::render_settings; +pub use wanted::render_wanted; diff --git a/src/ui/views/queue.rs b/src/ui/views/queue.rs new file mode 100644 index 0000000..1c385f1 --- /dev/null +++ b/src/ui/views/queue.rs @@ -0,0 +1,141 @@ +//! Queue view - active downloads. + +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, + Frame, +}; + +use crate::data::QueueEntry; +use crate::theme; +use crate::ui::pane::Pane; + +fn progress_bar_aqua(progress: f64, width: usize) -> Vec> { + let filled = ((progress * width as f64).round() as usize).min(width); + let empty = width.saturating_sub(filled); + + vec![ + Span::styled("▰".repeat(filled), Style::default().fg(theme::AQUA)), + Span::styled("▱".repeat(empty), Style::default().fg(theme::BG3)), + ] +} + +pub fn render_queue( + frame: &mut Frame, + area: Rect, + queue: &[QueueEntry], + state: &mut ListState, +) { + let total_speed: f64 = queue + .iter() + .filter_map(|q| q.speed.trim_end_matches(" MB/s").parse::().ok()) + .sum(); + let meta = format!( + "{} active · {:.1}%", + queue.len(), + queue.iter().map(|q| q.progress * 100.0).sum::() / queue.len().max(1) as f64 + ); + + let footer = Line::from(vec![ + Span::styled("[x]", Style::default().fg(theme::GRAY)), + Span::styled(" remove · ", Style::default().fg(theme::FG2)), + Span::styled("[p]", Style::default().fg(theme::GRAY)), + Span::styled(" pause · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" details", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("↓ {:.1} MB/s", total_speed), + Style::default().fg(theme::AQUA), + ), + ]); + + let pane = Pane::new("Download queue") + .meta(&meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Header row + let header = Line::from(vec![ + Span::styled(" ", Style::default().fg(theme::GRAY)), + Span::styled("RELEASE", Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(inner.width.saturating_sub(65) as usize)), + Span::styled("INDEXER ", Style::default().fg(theme::GRAY)), + Span::styled("PROGRESS ", Style::default().fg(theme::GRAY)), + Span::styled("SPEED ", Style::default().fg(theme::GRAY)), + Span::styled("ETA", Style::default().fg(theme::GRAY)), + ]); + + let header_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area); + + // List area + let list_area = Rect { + x: inner.x, + y: inner.y + 1, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + + let items: Vec = queue + .iter() + .map(|entry| { + // Calculate widths + let total_fixed = 3 + 18 + 18 + 8 + 7; // icon + indexer + progress + speed + eta + let title_width = (inner.width as usize).saturating_sub(total_fixed); + + let title_artist = format!("{} · {}", entry.title, entry.artist); + let mut display_title = title_artist.clone(); + if display_title.len() > title_width { + display_title.truncate(title_width.saturating_sub(1)); + display_title.push('…'); + } + let title_pad = title_width.saturating_sub(display_title.len()); + + let mut indexer = entry.indexer.clone(); + if indexer.len() > 16 { + indexer.truncate(15); + indexer.push('…'); + } + let indexer_pad = 18_usize.saturating_sub(indexer.len()); + + let pct = (entry.progress * 100.0).round() as u8; + + let mut spans = vec![ + Span::styled("↓ ", Style::default().fg(theme::AQUA)), + Span::styled(display_title, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(title_pad)), + Span::styled(indexer, Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(indexer_pad)), + ]; + spans.extend(progress_bar_aqua(entry.progress, 12)); + spans.push(Span::styled(format!(" {:>3}%", pct), Style::default().fg(theme::GRAY))); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{:>8}", entry.speed), + Style::default().fg(theme::GREEN), + )); + spans.push(Span::styled( + format!("{:>7}", entry.eta), + Style::default().fg(theme::YELLOW), + )); + + Line::from(spans).into() + }) + .collect(); + + let list = List::new(items) + .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, list_area, state); +} diff --git a/src/ui/views/settings.rs b/src/ui/views/settings.rs new file mode 100644 index 0000000..8c0f6ca --- /dev/null +++ b/src/ui/views/settings.rs @@ -0,0 +1,277 @@ +//! Settings view - configuration display. + +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::theme; +use crate::ui::pane::Pane; + +struct SettingRow<'a> { + label: &'a str, + value: &'a str, + value_style: Style, + tag: &'a str, + tag_style: Style, +} + +struct Indexer<'a> { + name: &'a str, + priority: u8, + formats: &'a str, + state: &'a str, + tag_style: Style, + enabled: bool, +} + +pub fn render_settings(frame: &mut Frame, area: Rect) { + let footer = Line::from(vec![ + Span::styled("[Tab]", Style::default().fg(theme::GRAY)), + Span::styled(" next section · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" edit · ", Style::default().fg(theme::FG2)), + Span::styled("[:w]", Style::default().fg(theme::GRAY)), + Span::styled(" save", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled("config saved 12:04", Style::default().fg(theme::GRAY)), + ]); + + let pane = Pane::new("Settings") + .meta("config · /etc/harmony.toml") + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + // 2x2 grid layout + let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner); + let top_cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[0]); + let bot_cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]); + + render_library_section(frame, top_cols[0]); + render_quality_section(frame, top_cols[1]); + render_indexers_section(frame, bot_cols[0]); + render_appearance_section(frame, bot_cols[1]); +} + +fn section_block(title: &str) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BG3)) + .title(Line::from(vec![ + Span::styled("─[ ", Style::default().fg(theme::BG3)), + Span::styled(title, Style::default().fg(theme::GRAY)), + Span::styled(" ]─", Style::default().fg(theme::BG3)), + ])) + .style(Style::default().bg(theme::BG0)) +} + +fn render_setting_row(row: &SettingRow) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{:<18}", row.label), Style::default().fg(theme::GRAY)), + Span::styled(row.value.to_string(), row.value_style), + Span::raw(" "), + Span::styled(format!("[{}]", row.tag), row.tag_style), + ]) +} + +fn render_library_section(frame: &mut Frame, area: Rect) { + let block = section_block("Library"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = [ + SettingRow { + label: "root folder", + value: "/home/usr/music", + value_style: Style::default().fg(theme::AQUA), + tag: "edit", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "naming", + value: "{artist}/{year} - {album}/…", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "cover art", + value: "embed + folder.jpg", + value_style: Style::default().fg(theme::FG1), + tag: "on", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "file types", + value: "flac, mp3, m4a, ogg, opus", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "replace existing", + value: "if higher quality", + value_style: Style::default().fg(theme::FG1), + tag: "cycle", + tag_style: Style::default().fg(theme::YELLOW), + }, + ]; + + let lines: Vec = rows.iter().map(render_setting_row).collect(); + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_quality_section(frame: &mut Frame, area: Rect) { + let block = section_block("Quality Profile"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = [ + SettingRow { + label: "profile", + value: "FLAC ▸ MP3 320 ▸ MP3 V0", + value_style: Style::default().fg(theme::YELLOW), + tag: "cycle", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "cutoff", + value: "FLAC 16-44", + value_style: Style::default().fg(theme::FG1), + tag: "cycle", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "min size", + value: "12 MB / track", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "max size", + value: "300 MB / track", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "prefer single album", + value: "true", + value_style: Style::default().fg(theme::FG1), + tag: "on", + tag_style: Style::default().fg(theme::GREEN), + }, + ]; + + let lines: Vec = rows.iter().map(render_setting_row).collect(); + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_indexers_section(frame: &mut Frame, area: Rect) { + let block = section_block("Indexers"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let indexers = [ + Indexer { + name: "redacted.ch", + priority: 25, + formats: "FLAC, MP3", + state: "ok", + tag_style: Style::default().fg(theme::GREEN), + enabled: true, + }, + Indexer { + name: "orpheus.network", + priority: 25, + formats: "FLAC", + state: "ok", + tag_style: Style::default().fg(theme::GREEN), + enabled: true, + }, + Indexer { + name: "rutracker", + priority: 10, + formats: "FLAC, MP3", + state: "slow", + tag_style: Style::default().fg(theme::YELLOW), + enabled: true, + }, + Indexer { + name: "nzbgeek", + priority: 5, + formats: "usenet", + state: "off", + tag_style: Style::default().fg(theme::RED), + enabled: false, + }, + ]; + + let lines: Vec = indexers + .iter() + .map(|ix| { + let name_style = if ix.enabled { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::GRAY) + }; + Line::from(vec![ + Span::styled(format!("{:<18}", ix.name), name_style), + Span::styled(format!("priority {} ", ix.priority), Style::default().fg(theme::AQUA)), + Span::styled(format!("· {}", ix.formats), Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(format!("[{}]", ix.state), ix.tag_style), + ]) + }) + .collect(); + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_appearance_section(frame: &mut Frame, area: Rect) { + let block = section_block("Appearance"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("theme ", Style::default().fg(theme::GRAY)), + Span::styled("[x] gruvbox dark", Style::default().fg(theme::YELLOW)), + Span::styled(" [ ] gruvbox light", Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled("[cycle]", Style::default().fg(theme::GREEN)), + ]), + Line::from(vec![ + Span::styled("font size ", Style::default().fg(theme::GRAY)), + Span::styled("JetBrains Mono · 14px", Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("[edit]", Style::default().fg(theme::FG2)), + ]), + Line::from(vec![ + Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)), + Span::styled("[ ] subtle scanline overlay", Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled("[off]", Style::default().fg(theme::FG2)), + ]), + Line::from(vec![ + Span::styled("unicode glyphs ", Style::default().fg(theme::GRAY)), + Span::styled("box-drawing · powerline · nerd", Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("[on]", Style::default().fg(theme::GREEN)), + ]), + ]; + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} diff --git a/src/ui/views/wanted.rs b/src/ui/views/wanted.rs new file mode 100644 index 0000000..c14f145 --- /dev/null +++ b/src/ui/views/wanted.rs @@ -0,0 +1,123 @@ +//! Wanted view - missing albums and tracks. + +use ratatui::{ + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, + Frame, +}; + +use crate::data::{AlbumStatus, WantedEntry}; +use crate::theme; +use crate::ui::pane::Pane; + +fn status_icon(status: AlbumStatus) -> (char, Style) { + match status { + AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), + AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), + _ => ('●', Style::default().fg(theme::GREEN)), + } +} + +pub fn render_wanted( + frame: &mut Frame, + area: Rect, + wanted: &[WantedEntry], + state: &mut ListState, +) { + let total_missing: u16 = wanted.iter().map(|w| w.missing).sum(); + let count_str = format!("{} missing or partial", wanted.len()); + + let footer = Line::from(vec![ + Span::styled("[s]", Style::default().fg(theme::GRAY)), + Span::styled(" search · ", Style::default().fg(theme::FG2)), + Span::styled("[m]", Style::default().fg(theme::GRAY)), + Span::styled(" unmonitor · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" open", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("{} missing tracks", total_missing), + Style::default().fg(theme::GRAY), + ), + ]); + + let pane = Pane::new("Wanted") + .meta(&count_str) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Header row + let header = Line::from(vec![ + Span::styled(" ", Style::default().fg(theme::GRAY)), + Span::styled("ALBUM", Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(inner.width.saturating_sub(70) as usize)), + Span::styled("ARTIST ", Style::default().fg(theme::GRAY)), + Span::styled("YEAR ", Style::default().fg(theme::GRAY)), + Span::styled("MISSING ", Style::default().fg(theme::GRAY)), + Span::styled("RELEASE DATE", Style::default().fg(theme::GRAY)), + ]); + + let header_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area); + + // List area + let list_area = Rect { + x: inner.x, + y: inner.y + 1, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + + let items: Vec = wanted + .iter() + .map(|entry| { + let (icon_char, icon_style) = status_icon(entry.status); + + // Calculate widths + let total_fixed = 3 + 28 + 6 + 7 + 12; // icon + artist + year + missing + date + let album_width = (inner.width as usize).saturating_sub(total_fixed); + + let mut album = entry.album.clone(); + if album.len() > album_width { + album.truncate(album_width.saturating_sub(1)); + album.push('…'); + } + let album_pad = album_width.saturating_sub(album.len()); + + let mut artist = entry.artist.clone(); + if artist.len() > 26 { + artist.truncate(25); + artist.push('…'); + } + let artist_pad = 28_usize.saturating_sub(artist.len()); + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(album, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(album_pad)), + Span::styled(artist, Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(artist_pad)), + Span::styled(format!("{:<6}", entry.year), Style::default().fg(theme::GRAY)), + Span::styled(format!("{:>7}", entry.missing), Style::default().fg(theme::RED)), + Span::raw(" "), + Span::styled(&entry.release_date, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let list = List::new(items) + .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, list_area, state); +} diff --git a/src/ui/which_key.rs b/src/ui/which_key.rs new file mode 100644 index 0000000..354e374 --- /dev/null +++ b/src/ui/which_key.rs @@ -0,0 +1,113 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::input::{LeaderNode, LeaderState}; +use crate::theme; + +const MIN_COLUMN_WIDTH: u16 = 28; + +pub fn render_which_key(frame: &mut Frame, area: Rect, state: &LeaderState, tree: &LeaderNode) { + let Some(current_node) = state.current_node(tree) else { + return; + }; + + let Some(children) = ¤t_node.children else { + return; + }; + + let popup_height = calculate_popup_height(children.len(), area.width); + let popup_y = area.height.saturating_sub(popup_height + 1); + let popup_area = Rect::new(0, popup_y, area.width, popup_height); + + frame.render_widget(Clear, popup_area); + + let header = build_header(state, current_node); + let block = Block::default() + .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) + .border_style(Style::default().fg(theme::ORANGE)) + .style(Style::default().bg(theme::BG1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner); + + frame.render_widget(header, chunks[0]); + render_key_grid(frame, chunks[1], children); +} + +fn calculate_popup_height(child_count: usize, width: u16) -> u16 { + let columns = (width / MIN_COLUMN_WIDTH).max(1) as usize; + let rows = (child_count + columns - 1) / columns; + (rows as u16 + 2).min(10) +} + +fn build_header(state: &LeaderState, node: &LeaderNode) -> Paragraph<'static> { + let breadcrumb = state.breadcrumb(); + let description = node.name.clone(); + + let line = Line::from(vec![ + Span::styled( + format!(" {} ", breadcrumb), + Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD), + ), + Span::styled(description, Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled("Esc", Style::default().fg(theme::GRAY)), + Span::styled(" cancel · ", Style::default().fg(theme::FG3)), + Span::styled("Backspace", Style::default().fg(theme::GRAY)), + Span::styled(" back", Style::default().fg(theme::FG3)), + ]); + Paragraph::new(line) +} + +fn render_key_grid(frame: &mut Frame, area: Rect, children: &[(char, LeaderNode)]) { + let columns = (area.width / MIN_COLUMN_WIDTH).max(1) as usize; + let column_width = area.width / columns as u16; + + let mut lines: Vec = Vec::new(); + let mut current_row_spans: Vec = Vec::new(); + let mut col_idx = 0; + + for (key, node) in children { + let is_group = node.is_group(); + let key_style = Style::default().fg(theme::YELLOW); + let name_style = if is_group { + Style::default().fg(theme::ORANGE) + } else { + Style::default().fg(theme::FG2) + }; + + let prefix = if is_group { "+" } else { "" }; + let entry = format!("{} → {}{}", key, prefix, node.name); + + let key_span = Span::styled(format!("{}", key), key_style); + let arrow_span = Span::styled(" → ", Style::default().fg(theme::GRAY)); + let name_span = Span::styled(format!("{}{}", prefix, node.name), name_style); + let padding_len = column_width as usize - entry.len(); + let padding_span = Span::raw(" ".repeat(padding_len.min(20))); + + current_row_spans.push(key_span); + current_row_spans.push(arrow_span); + current_row_spans.push(name_span); + current_row_spans.push(padding_span); + + col_idx += 1; + if col_idx >= columns { + lines.push(Line::from(std::mem::take(&mut current_row_spans))); + col_idx = 0; + } + } + + if !current_row_spans.is_empty() { + lines.push(Line::from(current_row_spans)); + } + + let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG1)); + frame.render_widget(paragraph, area); +}