From 7fe6ddfe055874ce135973fe9779ea7d6e5eb599 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 23 Jul 2026 19:43:47 +0300 Subject: [PATCH] Added filter. Improved UI. Fixed settings window --- src/app/action.rs | 6 +- src/app/mod.rs | 46 +++++++++++++-- src/app/popup.rs | 18 ++++++ src/app/state.rs | 12 ++-- src/app/update.rs | 21 +++++++ src/config/default_keymap.toml | 5 ++ src/config/mod.rs | 1 + src/config/settings.rs | 102 +++++++++++++++++++++++++++++++++ src/federation/mod.rs | 37 ++++++++++++ src/library/mod.rs | 97 ++++++++++++++++++++++++++----- src/ui/federation.rs | 17 ++++++ src/ui/global.rs | 24 +++++--- src/ui/popup.rs | 44 ++++++++++++++ 13 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 src/config/settings.rs diff --git a/src/app/action.rs b/src/app/action.rs index 3323825..52e2cb8 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -47,6 +47,7 @@ pub enum Action { NewPlaylist, ToggleHelp, ToggleViewMode, + OpenLibraryFilters, OpenCommandLine, OpenSearch, EditSelected, @@ -125,7 +126,9 @@ impl Action { | Action::GoToTab(_) | Action::GoToRelease | Action::ToggleViewMode => Category::Navigation, - Action::EditSelected | Action::DeleteSelected => Category::Library, + Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => { + Category::Library + } Action::OpenSearch | Action::OpenCommandLine => Category::Search, Action::ToggleHelp | Action::Quit => Category::System, } @@ -190,6 +193,7 @@ impl Action { Action::NewPlaylist => "Create a playlist".into(), Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(), + Action::OpenLibraryFilters => "Library filters…".into(), Action::OpenCommandLine => "Command line (:help for commands)".into(), Action::OpenSearch => "Search artists, releases, tracks".into(), Action::EditSelected => "Edit the selected item".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index f037376..db3a984 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -76,10 +76,18 @@ pub async fn run( let library = Arc::new(Library::open(&db_path)?); tracing::info!(path = %db_path.display(), "library opened"); + let (settings, settings_warning) = crate::config::settings::load(); + let status_message = match (startup_warning, settings_warning) { + (Some(left), Some(right)) => Some(format!("{left}; {right}")), + (Some(message), None) | (None, Some(message)) => Some(message), + (None, None) => None, + }; let mut state = AppState { - status_message: startup_warning, + status_message, ..AppState::default() }; + state.player.volume = settings.volume; + state.global.filters = settings.library; if let Err(err) = state.visualizer.load_library() { state.status_message = Some(format!("visualizations disabled: {err:#}")); } @@ -191,13 +199,16 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { { global.loading = true; let page = global.next_page; + let hide_featured_only = global.filters.hide_featured_only; let limit = *global .page_limit .get_or_insert_with(|| (needed as i64).clamp(48, 200)); let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { - let result = library.artists(page, limit).map_err(err_string); + let result = library + .artists(page, limit, hide_featured_only) + .map_err(err_string); let _ = tx.send(AppEvent::ArtistsLoaded(result)); }); } @@ -389,7 +400,10 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { .player .seek(std::time::Duration::from_secs_f64(target)); } - Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)), + Effect::SetVolume(volume) => { + runtime.player.set_volume(player::amplitude(volume)); + save_app_settings(state); + } Effect::SetOptions => {} Effect::EnqueueRelease { id, next } => { let library = Arc::clone(&runtime.library); @@ -1104,11 +1118,12 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) { let global = &mut state.global; let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN; let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000); + let hide_featured_only = global.filters.hide_featured_only; global.reloading = true; let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { - let event = match library.artists(1, limit) { + let event = match library.artists(1, limit, hide_featured_only) { Ok(page) => AppEvent::ArtistsReloaded { page, limit }, Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))), }; @@ -1116,6 +1131,29 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) { }); } +fn save_app_settings(state: &AppState) { + let settings = crate::config::settings::AppSettings { + volume: state.player.volume, + library: state.global.filters, + }; + if let Err(err) = crate::config::settings::save(&settings) { + tracing::warn!(%err, "saving app settings failed"); + } +} + +fn reset_artist_pagination(state: &mut AppState) { + let global = &mut state.global; + global.artists.clear(); + global.total = 0; + global.has_more = true; + global.next_page = 1; + global.loading = false; + global.error = None; + global.selected = 0; + global.page_limit = None; + global.reloading = false; +} + /// Swap queue entries for their fresh library copies; tracks that were /// deleted leave the queue. fn apply_queue_refresh( diff --git a/src/app/popup.rs b/src/app/popup.rs index cd0dc5e..04dda3c 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -40,6 +40,7 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { Popup::ConfirmDelete { target, label } => { handle_confirm_delete(state, runtime, target, label, key); } + Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key), Popup::TrackInfo { tracks, cursor, @@ -63,6 +64,23 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { } } +fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => {} + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::LibraryFilters { cursor: 0 }); + } + KeyCode::Enter | KeyCode::Char(' ') => { + state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only; + super::save_app_settings(state); + super::reset_artist_pagination(state); + super::refresh_artists(state, runtime); + state.popup = Some(Popup::LibraryFilters { cursor }); + } + _ => state.popup = Some(Popup::LibraryFilters { cursor }), + } +} + /// One-line text entry on the Federation tab (network id / peer ticket). fn handle_fed_input( state: &mut AppState, diff --git a/src/app/state.rs b/src/app/state.rs index d8e5125..d09ad1e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -17,7 +17,7 @@ pub enum Loadable { Failed(String), } -/// Tile geometry for the Global artist grid (kept here so selection math in +/// Tile geometry for the Library artist grid (kept here so selection math in /// update() and rendering in ui::global agree). Width × height in cells, /// including the tile border; the art area inside is 18×8 cells = 18×16 px. pub const TILE_WIDTH: u16 = 20; @@ -52,7 +52,7 @@ pub enum ArtState { Failed, } -/// A drill-down view pushed on top of the Global artist grid. Cursors live +/// A drill-down view pushed on top of the Library artist grid. Cursors live /// in the stack entry so going Back restores the previous position. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlobalView { @@ -82,7 +82,7 @@ pub enum GlobalView { }, } -/// The Global tab: the whole server library of artists. +/// The Library tab: the whole server library of artists. #[derive(Debug)] pub struct GlobalTab { pub artists: Vec, @@ -93,6 +93,7 @@ pub struct GlobalTab { pub error: Option, pub selected: usize, pub view: ViewMode, + pub filters: crate::config::settings::LibraryFilters, pub stack: Vec, /// Page size, fixed at the first request — the offset is /// `(page-1) * limit`, so it must not change between pages. @@ -113,6 +114,7 @@ impl Default for GlobalTab { error: None, selected: 0, view: ViewMode::default(), + filters: crate::config::settings::LibraryFilters::default(), stack: Vec::new(), page_limit: None, reloading: false, @@ -503,6 +505,8 @@ pub enum Popup { }, /// Delete confirmation; Enter/y deletes, Esc/n cancels. ConfirmDelete { target: DeleteTarget, label: String }, + /// Library-home filters. Cursor is kept for the next filters added here. + LibraryFilters { cursor: usize }, /// Track metadata viewer; left/right switch between selected tracks. TrackInfo { tracks: Vec, @@ -662,7 +666,7 @@ impl Tab { pub fn title(self) -> &'static str { match self { - Tab::Global => "Global", + Tab::Global => "Library", Tab::Playlists => "Playlists", Tab::Queue => "Queue", Tab::Federation => "Settings", diff --git a/src/app/update.rs b/src/app/update.rs index 5b7f4b2..c136e4c 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -218,6 +218,12 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } None } + Action::OpenLibraryFilters => { + if state.active_tab == Tab::Global && state.global.stack.is_empty() { + state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 }); + } + None + } Action::OpenCommandLine => { state.cmdline.active = true; state.cmdline.input.clear(); @@ -2571,6 +2577,21 @@ mod tests { assert_eq!(state.player.volume, 0); } + #[test] + fn library_filters_popup_opens_only_on_root() { + let mut state = AppState::default(); + update(&mut state, Action::OpenLibraryFilters); + assert!(matches!( + state.popup, + Some(crate::app::state::Popup::LibraryFilters { .. }) + )); + + state.popup = None; + state.global.stack.push(GlobalView::Search { cursor: 0 }); + update(&mut state, Action::OpenLibraryFilters); + assert!(state.popup.is_none()); + } + #[test] fn back_closes_help_first() { let mut state = AppState::default(); diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 124cdc8..39a243e 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -226,6 +226,11 @@ command = "DeleteSelected" key_sequence = "v" command = "ToggleViewMode" +[[keymaps]] +key_sequence = "f" +command = "OpenLibraryFilters" +context = "library" + [[keymaps]] key_sequence = ":" command = "OpenCommandLine" diff --git a/src/config/mod.rs b/src/config/mod.rs index 2f6631e..c904c2a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,6 @@ pub mod keymap; pub mod logging; +pub mod settings; use directories::ProjectDirs; diff --git a/src/config/settings.rs b/src/config/settings.rs new file mode 100644 index 0000000..d07281b --- /dev/null +++ b/src/config/settings.rs @@ -0,0 +1,102 @@ +use anyhow::{Context as _, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryFilters { + #[serde(default)] + pub hide_featured_only: bool, +} + +impl LibraryFilters { + pub fn is_active(&self) -> bool { + self.hide_featured_only + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppSettings { + #[serde(default = "default_volume")] + pub volume: u8, + #[serde(default)] + pub library: LibraryFilters, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + volume: default_volume(), + library: LibraryFilters::default(), + } + } +} + +impl AppSettings { + pub fn normalized(mut self) -> Self { + self.volume = self.volume.min(100); + self + } +} + +fn default_volume() -> u8 { + 80 +} + +pub fn load() -> (AppSettings, Option) { + let Some(path) = settings_path() else { + return (AppSettings::default(), None); + }; + match std::fs::read_to_string(&path) { + Ok(text) => match toml::from_str::(&text) { + Ok(settings) => (settings.normalized(), None), + Err(err) => ( + AppSettings::default(), + Some(format!("settings.toml is malformed: {err}")), + ), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => (AppSettings::default(), None), + Err(err) => ( + AppSettings::default(), + Some(format!("settings.toml could not be read: {err}")), + ), + } +} + +pub fn save(settings: &AppSettings) -> Result<()> { + let path = settings_path().context("cannot determine the config directory")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + &path, + toml::to_string_pretty(&settings.clone().normalized())?, + ) + .with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +fn settings_path() -> Option { + crate::config::project_dirs().map(|dirs| dirs.config_dir().join("settings.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_parse_and_normalize() { + let settings: AppSettings = toml::from_str( + r#" +volume = 150 + +[library] +hide_featured_only = true +"#, + ) + .unwrap(); + let settings = settings.normalized(); + + assert_eq!(settings.volume, 100); + assert!(settings.library.hide_featured_only); + assert!(settings.library.is_active()); + } +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 242f0e5..6f623fd 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -26,6 +26,7 @@ use music_dht::{ EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats, }; +use rusqlite::{Connection, OpenFlags, params}; use serde::{Deserialize, Serialize}; use crate::library::Library; @@ -159,6 +160,7 @@ pub struct FedStatus { pub connected_peers: Vec, pub known_contacts: usize, pub stored_dht_records: Option, + pub stored_dht_bytes: Option, pub published_items: usize, pub last_sync: Option, pub last_error: Option, @@ -250,6 +252,37 @@ fn now_label() -> String { ) } +fn unix_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result { + tokio::task::spawn_blocking(move || -> Result { + let path = data_dir.join("state.sqlite3"); + if !path.exists() { + return Ok(0); + } + let conn = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_context(|| format!("opening {}", path.display()))?; + let bytes: i64 = conn.query_row( + "SELECT COALESCE(SUM(length(payload)), 0) + FROM dht_records + WHERE expires_at_ms > ?1", + params![now_ms as i64], + |row| row.get(0), + )?; + Ok(bytes.max(0) as u64) + }) + .await + .context("DHT size query task failed")? +} + impl Federation { pub fn new(library: Arc) -> Arc { let dirs = crate::config::project_dirs(); @@ -534,6 +567,10 @@ impl Federation { .collect(); status.known_contacts = service.known_peers().len(); status.stored_dht_records = service.dht_record_count().await.ok(); + status.stored_dht_bytes = + dht_record_payload_bytes(self.data_dir.clone(), unix_time_ms()) + .await + .ok(); status.published_items = service .list_local_items() .await diff --git a/src/library/mod.rs b/src/library/mod.rs index 5c857aa..a95e19c 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -205,20 +205,42 @@ impl Library { // Reads (same shapes the API used to return) // ----------------------------------------------------------------- - pub fn artists(&self, page: i64, limit: i64) -> Result { + pub fn artists(&self, page: i64, limit: i64, hide_featured_only: bool) -> Result { let conn = self.lock(); - let total: i64 = conn.query_row("SELECT COUNT(*) FROM artists", [], |row| row.get(0))?; + let hide_featured_only = i64::from(hide_featured_only); + let total: i64 = conn.query_row( + "SELECT COUNT(*) + FROM artists a + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + )", + [hide_featured_only], + |row| row.get(0), + )?; let offset = (page.max(1) - 1) * limit; let mut statement = conn.prepare( "SELECT a.id, a.name, a.image_path, - (SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id), - (SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id) + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id) AS release_count, + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) AS track_count FROM artists a - ORDER BY a.name COLLATE NOCASE - LIMIT ?1 OFFSET ?2", + WHERE ?1 = 0 + OR EXISTS ( + SELECT 1 + FROM release_artists ra + WHERE ra.artist_id = a.id + ) + ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE + LIMIT ?2 OFFSET ?3", )?; let items = statement - .query_map(params![limit, offset], |row| { + .query_map(params![hide_featured_only, limit, offset], |row| { Ok(ArtistCard { id: row.get(0)?, name: row.get(1)?, @@ -371,8 +393,12 @@ impl Library { let conn = self.lock(); let mut statement = conn.prepare( "SELECT a.id, a.name, a.image_path, - (SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id), - (SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id) + (SELECT COUNT(DISTINCT ra.release_id) + FROM release_artists ra + WHERE ra.artist_id = a.id), + (SELECT COUNT(DISTINCT ta.track_id) + FROM track_artists ta + WHERE ta.artist_id = a.id) FROM artists a WHERE instr(norm(a.name), ?1) > 0 ORDER BY a.name COLLATE NOCASE LIMIT ?2", )?; @@ -1222,12 +1248,22 @@ mod tests { } fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { + add_track_with_featured(lib, title, artist, &[], album) + } + + fn add_track_with_featured( + lib: &Library, + title: &str, + artist: &str, + featured: &[&str], + album: &str, + ) -> i64 { let import = import::TrackImport { release_type: None, file_path: format!("/music/{artist}/{album}/{title}.mp3"), title: title.to_string(), artists: vec![artist.to_string()], - featured_artists: Vec::new(), + featured_artists: featured.iter().map(|name| (*name).to_string()).collect(), album_artists: vec![artist.to_string()], release_title: album.to_string(), year: Some(2020), @@ -1244,11 +1280,40 @@ mod tests { import::upsert_track(lib, &import).unwrap().0 } + #[test] + fn artists_page_prioritizes_releases_then_tracks() { + let lib = test_library(); + add_track(&lib, "Solo", "Zed", "Zed Album"); + add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album"); + add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album"); + + let page = lib.artists(1, 10, false).unwrap(); + let zed_pos = page + .items + .iter() + .position(|artist| artist.name == "Zed") + .unwrap(); + let guest_pos = page + .items + .iter() + .position(|artist| artist.name == "Guest") + .unwrap(); + let guest = &page.items[guest_pos]; + + assert_eq!(guest.release_count, 0); + assert_eq!(guest.track_count, 2); + assert!(zed_pos < guest_pos); + + let filtered = lib.artists(1, 10, true).unwrap(); + assert!(filtered.items.iter().all(|artist| artist.release_count > 0)); + assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); + } + #[test] fn import_creates_artist_release_track() { let lib = test_library(); let track_id = add_track(&lib, "Song", "Artist", "Album"); - let page = lib.artists(1, 10).unwrap(); + let page = lib.artists(1, 10, false).unwrap(); assert_eq!(page.total, 1); assert_eq!(page.items[0].name, "Artist"); assert_eq!(page.items[0].track_count, 1); @@ -1269,7 +1334,7 @@ mod tests { let first = add_track(&lib, "Song", "Artist", "Album"); let second = add_track(&lib, "Song", "Artist", "Album"); assert_eq!(first, second); - let page = lib.artists(1, 10).unwrap(); + let page = lib.artists(1, 10, false).unwrap(); assert_eq!(page.items[0].track_count, 1); } @@ -1357,9 +1422,9 @@ mod tests { fn deleting_artist_cleans_up_own_content() { let lib = test_library(); add_track(&lib, "Song", "Solo", "Solo Album"); - let page = lib.artists(1, 10).unwrap(); + let page = lib.artists(1, 10, false).unwrap(); lib.delete_artist(page.items[0].id).unwrap(); - assert_eq!(lib.artists(1, 10).unwrap().total, 0); + assert_eq!(lib.artists(1, 10, false).unwrap().total, 0); assert_eq!(lib.search("Song", 10).unwrap().len(), 0); } @@ -1368,7 +1433,9 @@ mod tests { let lib = test_library(); let track_id = add_track(&lib, "Only", "Artist", "Album"); lib.delete_track(track_id).unwrap(); - let detail = lib.artist(lib.artists(1, 10).unwrap().items[0].id).unwrap(); + let detail = lib + .artist(lib.artists(1, 10, false).unwrap().items[0].id) + .unwrap(); assert!(detail.releases.is_empty()); } diff --git a/src/ui/federation.rs b/src/ui/federation.rs index cad037c..10836bc 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -193,6 +193,16 @@ fn status_line(label: &str, value: String) -> Line<'static> { ]) } +fn bytes_label(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{bytes} B ({:.1} MiB)", bytes as f64 / 1024.0 / 1024.0) + } else if bytes >= 1024 { + format!("{bytes} B ({:.1} KiB)", bytes as f64 / 1024.0) + } else { + format!("{bytes} B") + } +} + fn short_id(id: &str) -> String { id.chars().take(12).collect::() + "…" } @@ -240,6 +250,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { .map(|count| count.to_string()) .unwrap_or_else(|| "unavailable".to_string()), )); + lines.push(status_line( + "Stored DHT bytes", + status + .stored_dht_bytes + .map(bytes_label) + .unwrap_or_else(|| "unavailable".to_string()), + )); lines.push(status_line( "Published items", status.published_items.to_string(), diff --git a/src/ui/global.rs b/src/ui/global.rs index 623b722..2e1be44 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -36,10 +36,11 @@ fn error_style() -> Style { } fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { - let block = Block::bordered() - .title(title) - .title_style(theme::header()) - .border_style(theme::dim()); + bordered_line(frame, area, Line::styled(title, theme::header())) +} + +fn bordered_line(frame: &mut Frame, area: Rect, title: Line<'static>) -> Rect { + let block = Block::bordered().title(title).border_style(theme::dim()); let inner = block.inner(area); frame.render_widget(block, area); inner @@ -271,11 +272,16 @@ fn scroll_offset(items: &[PlanItem], cursor_item: Option, viewport: u16) fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { let global = &state.global; let title = if global.total > 0 { - format!(" Global — {} artists ", global.total) + format!(" Library — {} artists ", global.total) } else { - " Global ".to_string() + " Library ".to_string() }; - let inner = bordered(frame, area, title); + let mut title_spans = vec![Span::styled(title, theme::tab_active())]; + if global.filters.is_active() { + title_spans.push(Span::raw(" ")); + title_spans.push(Span::styled(" FILTERED ", theme::tab_active())); + } + let inner = bordered_line(frame, area, Line::from(title_spans)); if global.artists.is_empty() { let message = if let Some(error) = &global.error { @@ -373,7 +379,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: Some(Loadable::Ready(detail)) => detail.name.clone(), _ => "Artist".to_string(), }; - let inner = bordered(frame, area, format!(" Global ▸ {name} ")); + let inner = bordered(frame, area, format!(" Library ▸ {name} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, @@ -640,7 +646,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor Some(Loadable::Ready(detail)) => detail.title.clone(), _ => "Release".to_string(), }; - let inner = bordered(frame, area, format!(" Global ▸ {title} ")); + let inner = bordered(frame, area, format!(" Library ▸ {title} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, diff --git a/src/ui/popup.rs b/src/ui/popup.rs index bde268e..8831180 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -21,6 +21,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) { .. }) => draw_edit(frame, title, fields, *focus, error.as_deref()), Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label), + Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor), Some(Popup::TrackInfo { tracks, cursor, @@ -39,6 +40,49 @@ pub fn draw(frame: &mut Frame, state: &AppState) { } } +fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { + let area = centered(frame.area(), 46, 6); + let block = Block::bordered() + .title(" Library filters ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [list_area, _, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + let checked = if state.global.filters.hide_featured_only { + "[x]" + } else { + "[ ]" + }; + let row = Rect { + height: 1, + ..list_area + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{checked} "), theme::accent()), + Span::raw("Hide featured only"), + ])), + row, + ); + if cursor == 0 { + frame.buffer_mut().set_style(row, theme::tab_active()); + } + frame.render_widget( + Paragraph::new(Line::styled("space/enter toggle · esc close", theme::dim())) + .alignment(Alignment::Center), + footer, + ); +} + /// One-line text entry on the Federation tab (network id / peer ticket). fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) { let area = centered(frame.area(), 64, 5);