Added filter. Improved UI. Fixed settings window

This commit is contained in:
Ultradesu
2026-07-23 19:43:47 +03:00
parent 6dbfc83bf6
commit 7fe6ddfe05
13 changed files with 397 additions and 33 deletions
+5 -1
View File
@@ -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(),
+42 -4
View File
@@ -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(
+18
View File
@@ -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,
+8 -4
View File
@@ -17,7 +17,7 @@ pub enum Loadable<T> {
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<ArtistCard>,
@@ -93,6 +93,7 @@ pub struct GlobalTab {
pub error: Option<String>,
pub selected: usize,
pub view: ViewMode,
pub filters: crate::config::settings::LibraryFilters,
pub stack: Vec<GlobalView>,
/// 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<TrackItem>,
@@ -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",
+21
View File
@@ -218,6 +218,12 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
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();
+5
View File
@@ -226,6 +226,11 @@ command = "DeleteSelected"
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = "f"
command = "OpenLibraryFilters"
context = "library"
[[keymaps]]
key_sequence = ":"
command = "OpenCommandLine"
+1
View File
@@ -1,5 +1,6 @@
pub mod keymap;
pub mod logging;
pub mod settings;
use directories::ProjectDirs;
+102
View File
@@ -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<String>) {
let Some(path) = settings_path() else {
return (AppSettings::default(), None);
};
match std::fs::read_to_string(&path) {
Ok(text) => match toml::from_str::<AppSettings>(&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<std::path::PathBuf> {
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());
}
}
+37
View File
@@ -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<String>,
pub known_contacts: usize,
pub stored_dht_records: Option<usize>,
pub stored_dht_bytes: Option<u64>,
pub published_items: usize,
pub last_sync: Option<String>,
pub last_error: Option<String>,
@@ -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<u64> {
tokio::task::spawn_blocking(move || -> Result<u64> {
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<Library>) -> Arc<Self> {
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
+82 -15
View File
@@ -205,20 +205,42 @@ impl Library {
// Reads (same shapes the API used to return)
// -----------------------------------------------------------------
pub fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage> {
pub fn artists(&self, page: i64, limit: i64, hide_featured_only: bool) -> Result<ArtistsPage> {
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());
}
+17
View File
@@ -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::<String>() + ""
}
@@ -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(),
+15 -9
View File
@@ -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<usize>, 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,
+44
View File
@@ -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);