diff --git a/Cargo.lock b/Cargo.lock index 4174b5a..54ac03b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "color-eyre" version = "0.6.5" @@ -344,6 +350,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -632,6 +650,7 @@ version = "0.1.0" dependencies = [ "color-eyre", "crossterm", + "nix", "ratatui", ] diff --git a/Cargo.toml b/Cargo.toml index 8db7e45..0bea1cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" ratatui = "0.29" crossterm = "0.28" color-eyre = "0.6" +nix = { version = "0.29", features = ["fs"] } diff --git a/flake.nix b/flake.nix index 641378a..587d32d 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,8 @@ rust-analyzer clippy rustfmt + + opencode ]; }; }; diff --git a/src/app.rs b/src/app.rs index 573065a..53bcba6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,32 +1,23 @@ #![allow(dead_code)] -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::MouseButton; use ratatui::{ + Frame, 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::data::{Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry}; 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::library::{LibraryFocus, LibraryState, render_library}; +use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal}; +use crate::ui::notifications::NotificationManager; +use crate::ui::statusbar::render_statusbar; 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; +use crate::ui::views::{ + render_calendar, render_history, render_queue, render_settings, render_wanted, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Tab { @@ -49,18 +40,6 @@ impl Tab { 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, @@ -84,29 +63,11 @@ impl Tab { } } -#[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, @@ -116,55 +77,29 @@ pub struct App { 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, + topbar_area: Rect, + main_area: Rect, + statusbar_area: Rect, + tab_areas: Vec, } 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 artists: Vec = Vec::new(); + let wanted: Vec = Vec::new(); + let queue: Vec = Vec::new(); + let history: Vec = Vec::new(); + let calendar: Vec = Vec::new(); - 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)); - } + let wanted_state = ListState::default(); + let queue_state = ListState::default(); + let history_state = ListState::default(); 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, + library: LibraryState::new(artists), modal: None, wanted, wanted_state, @@ -174,6 +109,10 @@ impl Default for App { history_state, calendar, notifications: NotificationManager::new(), + topbar_area: Rect::default(), + main_area: Rect::default(), + statusbar_area: Rect::default(), + tab_areas: Vec::new(), } } } @@ -183,28 +122,6 @@ impl App { 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(); @@ -214,56 +131,26 @@ impl App { 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(); + self.topbar_area = chunks[0]; + self.main_area = chunks[1]; + self.statusbar_area = chunks[2]; - render_topbar( - frame, - chunks[0], - self.tab, - self.mode, - queue_count, - wanted_count, - ); + let queue_count = self.queue.len(); + let wanted_count = self.wanted.len(); + + self.tab_areas = render_topbar(frame, chunks[0], self.tab, 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); - } + render_statusbar(frame, chunks[2], position, queue_count, wanted_count); if let Some(modal) = &self.modal { match modal { @@ -298,64 +185,31 @@ impl App { } } - 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)) + if total > 0 { Some((idx, total)) } else { None } } Tab::Wanted => { + if self.wanted.is_empty() { + return None; + } let idx = self.wanted_state.selected().unwrap_or(0) + 1; Some((idx, self.wanted.len())) } Tab::Queue => { + if self.queue.is_empty() { + return None; + } let idx = self.queue_state.selected().unwrap_or(0) + 1; Some((idx, self.queue.len())) } Tab::History => { + if self.history.is_empty() { + return None; + } let idx = self.history_state.selected().unwrap_or(0) + 1; Some((idx, self.history.len())) } @@ -363,663 +217,213 @@ impl App { } } - pub fn handle_key(&mut self, key: KeyEvent) { - self.status_message = None; + pub fn handle_escape(&mut self) { + if self.modal.is_some() { + self.modal = None; + } + } - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - self.running = false; + pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) { + if button != MouseButton::Left { return; } if self.modal.is_some() { - self.handle_modal_key(key); + self.handle_modal_click(x, y); 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); + if y == self.topbar_area.y { + self.handle_topbar_click(x); return; } - self.handle_normal_key(key); + if y >= self.main_area.y && y < self.main_area.y + self.main_area.height { + self.handle_main_click(x, y); + } } - 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; + fn handle_modal_click(&mut self, _x: u16, _y: u16) { + self.modal = None; + } + + fn handle_topbar_click(&mut self, x: u16) { + for (i, area) in self.tab_areas.iter().enumerate() { + if x >= area.x && x < area.x + area.width { + if let Some(tab) = Tab::ALL.get(i) { + self.tab = *tab; } - } - 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; - } + fn handle_main_click(&mut self, x: u16, y: u16) { + let rel_y = y.saturating_sub(self.main_area.y) as usize; 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)); + Tab::Library => { + self.handle_library_click(x, rel_y); + } + Tab::Wanted => { + self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y); + if rel_y < self.wanted.len() { + self.wanted_state.select(Some(rel_y)); + } + } + Tab::Queue => { + if rel_y < self.queue.len() { + self.queue_state.select(Some(rel_y)); + } + } + Tab::History => { + if rel_y < self.history.len() { + self.history_state.select(Some(rel_y)); + } + } + Tab::Calendar | Tab::Settings => {} + } + } + + fn handle_library_click(&mut self, x: u16, rel_y: usize) { + let artists_width = 32u16; + + if x < artists_width { + if rel_y > 0 && rel_y <= self.library.artists.len() { + self.library.artist_state.select(Some(rel_y - 1)); + self.library.album_state.select(Some(0)); + self.library.track_state.select(Some(0)); + self.library.focus = LibraryFocus::Artists; + } + } else { + let detail_y = rel_y; + let header_height = 6; + let divider1 = 1; + let _albums_section_height = 40; + + if detail_y > header_height + divider1 { + let albums_start = header_height + divider1; + let albums_rel = detail_y - albums_start; + + if let Some(artist) = self.library.selected_artist() { + let album_count = artist.albums.len(); + let tracks_start = albums_start + album_count.min(6) + 1; + + if detail_y < tracks_start && albums_rel < album_count { + self.library.album_state.select(Some(albums_rel)); self.library.track_state.select(Some(0)); + self.library.focus = LibraryFocus::Albums; + } else if detail_y >= tracks_start { + let tracks_rel = detail_y - tracks_start; + if let Some(album) = self.library.selected_album() { + if tracks_rel < album.total as usize { + self.library.track_state.select(Some(tracks_rel)); + self.library.focus = LibraryFocus::Tracks; + } + } } } - LibraryFocus::Albums => { + } + } + } + + fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {} + + pub fn handle_scroll(&mut self, delta: i32) { + if self.modal.is_some() { + return; + } + + match self.tab { + Tab::Library => { + self.scroll_library_list(delta); + } + Tab::Wanted => { + let len = self.wanted.len(); + scroll_list_state(&mut self.wanted_state, len, delta); + } + Tab::Queue => { + let len = self.queue.len(); + scroll_list_state(&mut self.queue_state, len, delta); + } + Tab::History => { + let len = self.history.len(); + scroll_list_state(&mut self.history_state, len, delta); + } + Tab::Calendar | Tab::Settings => {} + } + } + + fn scroll_library_list(&mut self, delta: i32) { + match self.library.focus { + LibraryFocus::Artists => { + let len = self.library.artists.len(); + if len == 0 { + return; + } + let current = self.library.artist_state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + 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 => { + if let Some(artist) = self.library.selected_artist() { + let len = artist.albums.len(); + if len == 0 { + return; + } 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; + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; 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"), + LibraryFocus::Tracks => { + if let Some(album) = self.library.selected_album() { + let len = album.total as usize; + if len == 0 { + return; } - } else { - self.set_status("usage: :theme dark | light"); + let current = self.library.track_state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + self.library.track_state.select(Some(new_idx)); } } - "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); - } +} + +fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) { + if len == 0 { + return; + } + + let current = state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + state.select(Some(new_idx)); } diff --git a/src/data/mod.rs b/src/data/mod.rs index 6768b59..85557ec 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,7 +1,5 @@ //! Data layer modules. pub mod models; -pub mod sample; pub use models::*; -pub use sample::*; diff --git a/src/data/sample.rs b/src/data/sample.rs deleted file mode 100644 index e819a63..0000000 --- a/src/data/sample.rs +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index 6357de6..0000000 --- a/src/input/leader.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! 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 deleted file mode 100644 index 023bb9a..0000000 --- a/src/input/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! 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 deleted file mode 100644 index 5a44bba..0000000 --- a/src/input/vim.rs +++ /dev/null @@ -1,117 +0,0 @@ -#![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 a493343..b7d15e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,15 +4,17 @@ use std::time::Duration; use color_eyre::Result; use crossterm::{ - event::{self, Event, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + MouseEventKind, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::prelude::*; mod app; mod data; -mod input; mod theme; mod ui; @@ -39,10 +41,12 @@ fn main() -> Result<()> { fn setup_terminal() -> Result<()> { enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; + stdout().execute(EnableMouseCapture)?; Ok(()) } fn restore_terminal() -> Result<()> { + stdout().execute(DisableMouseCapture)?; disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; Ok(()) @@ -56,10 +60,30 @@ fn run() -> Result<()> { 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); + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + // Only handle Ctrl+C and Escape for quit + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { + app.running = false; + } else if key.code == KeyCode::Esc { + app.handle_escape(); + } } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::Down(button) => { + app.handle_click(mouse.column, mouse.row, button); + } + MouseEventKind::ScrollUp => { + app.handle_scroll(-1); + } + MouseEventKind::ScrollDown => { + app.handle_scroll(1); + } + _ => {} + }, + _ => {} } } else { app.handle_tick(); diff --git a/src/theme.rs b/src/theme.rs index 2ddea8e..25d8a22 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -4,31 +4,31 @@ 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 +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 +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 +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 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 +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) +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 deleted file mode 100644 index 621c008..0000000 --- a/src/ui/cmdline.rs +++ /dev/null @@ -1,73 +0,0 @@ -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 index c6e79f1..6ae5736 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -1,16 +1,16 @@ #![allow(dead_code)] use ratatui::{ + Frame, 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::pane::{Pane, section_divider}; use crate::ui::progress_bar::progress_bar; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -53,7 +53,9 @@ impl LibraryState { } pub fn selected_artist(&self) -> Option<&Artist> { - self.artist_state.selected().and_then(|i| self.artists.get(i)) + self.artist_state + .selected() + .and_then(|i| self.artists.get(i)) } pub fn selected_album(&self) -> Option<&Album> { @@ -101,7 +103,8 @@ impl LibraryState { } } LibraryFocus::Albums => { - let max = self.selected_artist() + let max = self + .selected_artist() .map(|a| a.albums.len().saturating_sub(1)) .unwrap_or(0); if let Some(i) = self.album_state.selected() { @@ -309,7 +312,13 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let artist = state.selected_artist(); let meta = artist - .map(|a| format!("{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or(""))) + .map(|a| { + format!( + "{} · {}", + a.country, + a.genres.first().map(|s| s.as_str()).unwrap_or("") + ) + }) .unwrap_or_default(); let have_tracks: u16 = artist @@ -319,21 +328,14 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { .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( + let footer = if artist.is_some() { + Line::from(vec![Span::styled( format!("{}/{} tracks", have_tracks, total_tracks), Style::default().fg(theme::GRAY), - ), - ]); + )]) + } else { + Line::from("") + }; let pane = Pane::new("Detail") .meta(&meta) @@ -344,6 +346,15 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let inner = block.inner(area); frame.render_widget(block, area); + let Some(artist) = artist else { + let msg = Paragraph::new(Span::styled( + "No artist selected", + Style::default().fg(theme::GRAY), + )); + frame.render_widget(msg, inner); + return; + }; + let chunks = Layout::vertical([ Constraint::Length(6), Constraint::Length(1), @@ -353,11 +364,9 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { ]) .split(inner); - if let Some(artist) = artist { - render_artist_header(frame, chunks[0], artist); - } + render_artist_header(frame, chunks[0], artist); - let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0); + let albums_count = artist.albums.len(); 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]); @@ -407,7 +416,9 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { let lines = vec![ Line::from(Span::styled( &artist.name, - Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(vec![ @@ -427,7 +438,10 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { ]), Line::from(vec![ Span::styled("albums ", Style::default().fg(theme::GRAY)), - Span::styled(artist.albums.len().to_string(), Style::default().fg(theme::FG1)), + 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)), @@ -463,7 +477,8 @@ fn render_albums_list( 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 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)); @@ -560,20 +575,32 @@ impl LibraryState { 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", + "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) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f317d7e..07db3ce 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,3 @@ -pub mod cmdline; pub mod library; pub mod modals; pub mod notifications; @@ -7,4 +6,3 @@ 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 index 4f479d8..ae68073 100644 --- a/src/ui/modals/help.rs +++ b/src/ui/modals/help.rs @@ -1,9 +1,9 @@ use ratatui::{ + Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, - Frame, }; use crate::theme; @@ -18,9 +18,7 @@ fn keybind_row<'a>(key: &'a str, desc: &'a str) -> Line<'a> { fn section_header(title: &str) -> Line<'static> { Line::from(Span::styled( title.to_string(), - Style::default() - .fg(theme::FG1) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD), )) } @@ -63,12 +61,10 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect) { 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), - ), - ])); + 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); } diff --git a/src/ui/modals/quit.rs b/src/ui/modals/quit.rs index 479e0c8..8ef07ec 100644 --- a/src/ui/modals/quit.rs +++ b/src/ui/modals/quit.rs @@ -1,9 +1,9 @@ use ratatui::{ + Frame, layout::Rect, style::Style, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, - Frame, }; use crate::theme; diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs index dcb071b..9951284 100644 --- a/src/ui/notifications.rs +++ b/src/ui/notifications.rs @@ -3,11 +3,11 @@ use std::time::Instant; use ratatui::{ + Frame, layout::Rect, style::Style, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, - Frame, }; use crate::theme; @@ -62,7 +62,13 @@ impl NotificationManager { } } - pub fn push(&mut self, title: impl Into, detail: Option, kind: NotifKind, icon: impl Into) { + 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(), @@ -81,9 +87,8 @@ impl NotificationManager { pub fn tick(&mut self) { let now = Instant::now(); - self.notifications.retain(|n| { - now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS - }); + self.notifications + .retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS); } pub fn render(&self, frame: &mut Frame, area: Rect) { @@ -151,7 +156,10 @@ impl NotificationManager { d.truncate(max_len.saturating_sub(1)); d.push('…'); } - lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY)))); + lines.push(Line::from(Span::styled( + d, + Style::default().fg(theme::GRAY), + ))); } let para = Paragraph::new(lines); diff --git a/src/ui/pane.rs b/src/ui/pane.rs index e7e051d..03b8515 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -43,8 +43,16 @@ impl<'a> Pane<'a> { } 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 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)), diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index f8bbc37..edc5a23 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -1,80 +1,51 @@ +use nix::sys::statvfs::statvfs; use ratatui::{ + Frame, 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, +fn get_free_space() -> String { + match statvfs("/") { + Ok(stat) => { + let free_bytes = stat.blocks_available() as u64 * stat.fragment_size() as u64; + let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + if free_gb >= 1000.0 { + format!("{:.1} TB free", free_gb / 1024.0) + } else { + format!("{:.1} GB free", free_gb) + } + } + Err(_) => "-- GB free".to_string(), + } } 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), @@ -84,7 +55,7 @@ pub fn render_statusbar( .add_modifier(Modifier::BOLD), )); } - + if wanted_count > 0 { right_spans.push(Span::styled( format!(" ! {} ", wanted_count), @@ -94,26 +65,29 @@ pub fn render_statusbar( .add_modifier(Modifier::BOLD), )); } - + right_spans.push(Span::styled( - " 47.3 GB free ", + format!(" {} ", get_free_space()), 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; - + 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 index fe2245b..aeaf5de 100644 --- a/src/ui/topbar.rs +++ b/src/ui/topbar.rs @@ -1,46 +1,73 @@ use ratatui::{ + Frame, layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, - Frame, }; -use crate::app::{Mode, Tab}; +use crate::app::Tab; use crate::theme; -pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode, queue_count: usize, wanted_count: usize) { +pub fn render_topbar( + frame: &mut Frame, + area: Rect, + active_tab: Tab, + queue_count: usize, + wanted_count: usize, +) -> Vec { let mut spans = Vec::new(); - + let mut tab_areas = Vec::new(); + let mut current_x = area.x; + + let logo = " ▲ harmony "; spans.push(Span::styled( - " ▲ harmony ", + logo, Style::default() .fg(theme::BG0) .bg(theme::ORANGE) .add_modifier(Modifier::BOLD), )); + current_x += logo.len() as u16; + spans.push(Span::raw(" ")); - + current_x += 1; + 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::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() { + + for (tab, label, badge) in tabs.iter() { let is_active = *tab == active_tab; - let num = format!("{}", i + 1); - + + let tab_start = current_x; + let text = format!(" {} ", label); + let mut tab_width = text.len() as u16; + if is_active { spans.push(Span::styled( - format!(" {}", num), - Style::default().fg(theme::ORANGE).bg(theme::BG0), - )); - spans.push(Span::styled( - format!(" {} ", label), + text, Style::default() .fg(theme::YELLOW) .bg(theme::BG0) @@ -48,52 +75,40 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode, )); } else { spans.push(Span::styled( - format!(" {}", num), - Style::default().fg(theme::GRAY).bg(theme::BG1), - )); - spans.push(Span::styled( - format!(" {} ", label), + text, Style::default().fg(theme::FG3).bg(theme::BG1), )); } - + + current_x += tab_width; + if let Some(count) = badge { + let badge_text = format!(" {} ", count); + let badge_width = badge_text.len() as u16; spans.push(Span::styled( - format!(" {} ", count), + badge_text, Style::default() .fg(theme::BG0) .bg(theme::RED) .add_modifier(Modifier::BOLD), )); + tab_width += badge_width; + current_x += badge_width; } + + tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1)); } - - 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; + + let used_width = current_x - area.x; + let remaining = area.width.saturating_sub(used_width) 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); + + tab_areas } diff --git a/src/ui/views/calendar.rs b/src/ui/views/calendar.rs index cb2909b..ae667ec 100644 --- a/src/ui/views/calendar.rs +++ b/src/ui/views/calendar.rs @@ -1,11 +1,11 @@ //! Calendar view - upcoming releases. use ratatui::{ + Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::Paragraph, - Frame, }; use crate::data::CalendarEntry; diff --git a/src/ui/views/history.rs b/src/ui/views/history.rs index 174aea8..ed90001 100644 --- a/src/ui/views/history.rs +++ b/src/ui/views/history.rs @@ -1,11 +1,11 @@ //! History view - recent activity. use ratatui::{ + Frame, layout::Rect, style::Style, text::{Line, Span}, widgets::{List, ListItem, ListState}, - Frame, }; use crate::data::HistoryEntry; @@ -73,7 +73,10 @@ pub fn render_history( } Line::from(vec![ - Span::styled(format!("{:<11}", entry.when), Style::default().fg(theme::GRAY)), + 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)), @@ -84,7 +87,6 @@ pub fn render_history( }) .collect(); - let list = List::new(items) - .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + 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/queue.rs b/src/ui/views/queue.rs index 1c385f1..2c2000e 100644 --- a/src/ui/views/queue.rs +++ b/src/ui/views/queue.rs @@ -1,11 +1,11 @@ //! Queue view - active downloads. use ratatui::{ + Frame, layout::Rect, style::Style, text::{Line, Span}, widgets::{List, ListItem, ListState}, - Frame, }; use crate::data::QueueEntry; @@ -22,12 +22,7 @@ fn progress_bar_aqua(progress: f64, width: usize) -> Vec> { ] } -pub fn render_queue( - frame: &mut Frame, - area: Rect, - queue: &[QueueEntry], - state: &mut ListState, -) { +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()) @@ -120,7 +115,10 @@ pub fn render_queue( 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::styled( + format!(" {:>3}%", pct), + Style::default().fg(theme::GRAY), + )); spans.push(Span::raw(" ")); spans.push(Span::styled( format!("{:>8}", entry.speed), @@ -135,7 +133,6 @@ pub fn render_queue( }) .collect(); - let list = List::new(items) - .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + 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 index 8c0f6ca..ff85546 100644 --- a/src/ui/views/settings.rs +++ b/src/ui/views/settings.rs @@ -1,11 +1,11 @@ //! Settings view - configuration display. use ratatui::{ + Frame, layout::{Constraint, Layout, Rect}, style::Style, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, - Frame, }; use crate::theme; @@ -50,9 +50,12 @@ pub fn render_settings(frame: &mut Frame, area: Rect) { 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]); + 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]); @@ -74,7 +77,10 @@ fn section_block(title: &str) -> Block<'_> { fn render_setting_row(row: &SettingRow) -> Line<'static> { Line::from(vec![ - Span::styled(format!("{:<18}", row.label), Style::default().fg(theme::GRAY)), + 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), @@ -227,8 +233,14 @@ fn render_indexers_section(frame: &mut Frame, area: Rect) { }; 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::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), ]) @@ -260,13 +272,19 @@ fn render_appearance_section(frame: &mut Frame, area: Rect) { ]), Line::from(vec![ Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)), - Span::styled("[ ] subtle scanline overlay", 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::styled( + "box-drawing · powerline · nerd", + Style::default().fg(theme::FG1), + ), Span::raw(" "), Span::styled("[on]", Style::default().fg(theme::GREEN)), ]), diff --git a/src/ui/views/wanted.rs b/src/ui/views/wanted.rs index c14f145..7175a6d 100644 --- a/src/ui/views/wanted.rs +++ b/src/ui/views/wanted.rs @@ -1,11 +1,11 @@ //! Wanted view - missing albums and tracks. use ratatui::{ + Frame, layout::Rect, style::Style, text::{Line, Span}, widgets::{List, ListItem, ListState}, - Frame, }; use crate::data::{AlbumStatus, WantedEntry}; @@ -20,12 +20,7 @@ fn status_icon(status: AlbumStatus) -> (char, Style) { } } -pub fn render_wanted( - frame: &mut Frame, - area: Rect, - wanted: &[WantedEntry], - state: &mut ListState, -) { +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()); @@ -57,7 +52,10 @@ pub fn render_wanted( 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( + "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)), @@ -108,8 +106,14 @@ pub fn render_wanted( 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::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)), ]) @@ -117,7 +121,6 @@ pub fn render_wanted( }) .collect(); - let list = List::new(items) - .highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + 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 deleted file mode 100644 index 354e374..0000000 --- a/src/ui/which_key.rs +++ /dev/null @@ -1,113 +0,0 @@ -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); -}