diff --git a/src/app/event.rs b/src/app/event.rs index 6ca33df..dd05efd 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -54,6 +54,8 @@ pub enum AppEvent { LikesLoaded(Result, String>), /// Local-library content ids for availability markers. LocalContentIdsLoaded(Result, String>), + /// Counts and storage footprint of the local library/database. + LocalLibraryStatsLoaded(Result), /// One content id became available locally while the UI is open. LocalContentAvailable { content_id: String, diff --git a/src/app/mod.rs b/src/app/mod.rs index e832451..70cd745 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -95,6 +95,15 @@ fn refresh_local_content_ids(runtime: &Runtime) { }); } +fn refresh_local_library_stats(runtime: &Runtime) { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.local_stats().map_err(err_string); + let _ = tx.send(AppEvent::LocalLibraryStatsLoaded(result)); + }); +} + fn spawn_artist_federation_enrichment(runtime: &Runtime, id: i64, name: String) { let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); @@ -870,6 +879,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { state.local_content_ids_loaded = true; refresh_local_content_ids(runtime); } + if state.local_library_stats.is_none() { + state.local_library_stats = Some(state::Loadable::Loading); + refresh_local_library_stats(runtime); + } // Playlists tab data (also wanted while the add-to-playlist picker is // open from any tab). @@ -1423,6 +1436,7 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { | Effect::DeviceSyncNow | Effect::DeviceSetName(_) | Effect::DeviceRevoke(_) + | Effect::DeviceLeaveGroup if !state.connected_devices_enabled() => { state.status_message = Some("enable federation before using connected devices".into()); @@ -1480,6 +1494,33 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { let _ = tx.send(AppEvent::StatusMessage(message)); }); } + Effect::DeviceLeaveGroup => { + state.status_message = Some("leaving device group…".to_string()); + let fed = Arc::clone(&runtime.federation); + let devices = Arc::clone(&runtime.devices); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let message = match devices.record_leave_group_revoke() { + Ok(op_id) => match fed.device_sync_now().await { + Ok(()) => match devices.finish_leave_group_reset() { + Ok(group_id) => format!("left device group · new group {group_id}"), + Err(err) => format!("leave failed after sync: {err:#}"), + }, + Err(err) => { + if let Err(rollback) = devices.cancel_leave_group_revoke(&op_id) { + tracing::warn!( + "rolling back failed leave-device-group op failed: {rollback:#}" + ); + } + format!("leave failed: revoke was not synced: {err:#}") + } + }, + Err(err) => format!("leave failed: {err:#}"), + }; + let _ = tx.send(AppEvent::DeviceSyncStatus(devices.status())); + let _ = tx.send(AppEvent::StatusMessage(message)); + }); + } Effect::FedOpenArtist(name) => { let fed = Arc::clone(&runtime.federation); let tx = runtime.event_tx.clone(); @@ -2449,6 +2490,7 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { // until then. state.likes_loaded = false; state.local_content_ids_loaded = false; + state.local_library_stats = None; // Fresh copies of whatever sits in the queue. Federated placeholders // and ephemeral tracks (negative ids) are not library rows and keep @@ -2785,7 +2827,26 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent }) .is_some_and(|device| device.revoked) }); - if active_revoked { + let active_missing = state.device_playback.is_control() + && state + .device_playback + .active_device_id + .as_ref() + .is_some_and(|active| { + active != &state.device_playback.self_device_id + && !state.federation.devices.as_ref().is_some_and(|status| { + status + .devices + .iter() + .any(|device| device.device_id == *active) + }) + }); + if active_revoked || active_missing { + runtime.player.stop(); + state.player.playing = false; + state.player.current = None; + state.player.paused = false; + state.player.position_secs = 0.0; become_active_device(state, runtime, false); state.status_message = Some("active playback moved to this device".into()); } @@ -3283,6 +3344,15 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent tracing::warn!(%message, "local content id load failed"); } }, + AppEvent::LocalLibraryStatsLoaded(result) => { + state.local_library_stats = Some(match result { + Ok(stats) => state::Loadable::Ready(stats), + Err(message) => { + tracing::warn!(%message, "local library stats load failed"); + state::Loadable::Failed(message) + } + }); + } AppEvent::LocalContentAvailable { content_id } => { if let Some(content_id) = music_dht::normalize_content_id(&content_id) { state.local_content_ids.insert(content_id); diff --git a/src/app/popup.rs b/src/app/popup.rs index 8bcf033..812aabb 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -192,6 +192,7 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { Popup::ConfirmDeviceRevoke { device_id, name } => { handle_device_revoke(state, runtime, device_id, name, key); } + Popup::ConfirmDeviceLeave => handle_device_leave(state, runtime, key), Popup::ConnectedDevices { cursor } => { handle_connected_devices(state, runtime, cursor, key); } @@ -585,6 +586,16 @@ fn handle_device_revoke( } } +fn handle_device_leave(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('n') | KeyCode::Char('q') => {} + KeyCode::Char('y') => { + super::perform_effect(state, runtime, crate::app::update::Effect::DeviceLeaveGroup); + } + _ => state.popup = Some(Popup::ConfirmDeviceLeave), + } +} + /// Pasted text goes into the focused text field when one is open. pub fn handle_paste(state: &mut AppState, pasted: &str) { let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); diff --git a/src/app/state.rs b/src/app/state.rs index bc95b9f..06567ee 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -728,6 +728,8 @@ pub enum Popup { }, /// Confirmation before revoking a trusted device. ConfirmDeviceRevoke { device_id: String, name: String }, + /// Confirmation before this device leaves the trusted-device group. + ConfirmDeviceLeave, /// Connected playback devices and their current role/status. ConnectedDevices { cursor: usize }, /// Full federation, transport and device status details. @@ -864,6 +866,7 @@ pub enum SettingsRow { DeviceInvite, DeviceConnect, DeviceSyncNow, + DeviceLeaveGroup, Device(usize), VisualizationClock, VisualizationScript(usize), @@ -1000,6 +1003,7 @@ pub fn settings_rows(state: &AppState) -> Vec { rows.push(SettingsRow::DeviceInvite); rows.push(SettingsRow::DeviceConnect); rows.push(SettingsRow::DeviceSyncNow); + rows.push(SettingsRow::DeviceLeaveGroup); rows.extend( device_status_order(state) .into_iter() @@ -1272,6 +1276,7 @@ pub struct AppState { pub local_content_ids: HashSet, pub likes_loaded: bool, pub local_content_ids_loaded: bool, + pub local_library_stats: Option>, pub logs: LogsTab, pub queue_tab: QueueTab, pub federation: FederationTab, diff --git a/src/app/update.rs b/src/app/update.rs index 09ea523..dd9a47a 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -64,6 +64,8 @@ pub enum Effect { DeviceSetName(String), /// Revoke a trusted device. DeviceRevoke(String), + /// Leave the current personal-device group after publishing self-revoke. + DeviceLeaveGroup, /// Assemble the federated artist card (fan-out to the owning peers). FedOpenArtist(String), /// Download federated tracks into the local library, one by one. @@ -2625,6 +2627,13 @@ fn federation_select(state: &mut AppState) -> Option { } Some(Effect::DeviceSyncNow) } + SettingsRow::DeviceLeaveGroup => { + if !require_connected_devices_enabled(state) { + return None; + } + state.popup = Some(Popup::ConfirmDeviceLeave); + None + } SettingsRow::Device(index) => { if !require_connected_devices_enabled(state) { return None; diff --git a/src/devices.rs b/src/devices.rs index 10a495f..e64c49d 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -957,6 +957,94 @@ impl DeviceSync { Ok(()) } + pub fn record_leave_group_revoke(&self) -> Result { + let op = self.record_local_op_generated( + |identity, seq| { + Ok(SyncOpPayload::DeviceRevoked { + target_device_id: identity.device_id.clone(), + target_max_seq_seen: seq, + }) + }, + false, + )?; + Ok(op.op_id) + } + + pub fn cancel_leave_group_revoke(&self, op_id: &str) -> Result<()> { + let identity = self.ensure_identity()?; + let conn = lock(&self.conn); + conn.execute("DELETE FROM sync_ops WHERE op_id = ?1", [op_id])?; + conn.execute( + "UPDATE sync_devices + SET revoked_at_ms = NULL, + revoked_by = NULL, + revoke_cutoff_seq = NULL + WHERE device_id = ?1", + [&identity.device_id], + )?; + Ok(()) + } + + pub fn finish_leave_group_reset(&self) -> Result { + let identity = self.ensure_identity()?; + let new_group_id = format!( + "grp_{}", + &blake3::hash(random_hex(32).as_bytes()).to_hex()[..24] + ); + let now = now_ms(); + { + let conn = lock(&self.conn); + set_meta(&conn, "group_id", &new_group_id)?; + set_meta(&conn, "last_sync", "left previous device group")?; + delete_meta(&conn, "last_error")?; + conn.execute("DELETE FROM sync_invites", [])?; + conn.execute("DELETE FROM sync_pending_pairing", [])?; + conn.execute("DELETE FROM sync_ops", [])?; + conn.execute("DELETE FROM sync_vectors", [])?; + conn.execute("DELETE FROM sync_peer_acks", [])?; + conn.execute("DELETE FROM sync_compacted", [])?; + conn.execute("DELETE FROM sync_playback_applied", [])?; + conn.execute( + "DELETE FROM sync_devices WHERE device_id != ?1", + [&identity.device_id], + )?; + conn.execute( + "INSERT INTO sync_devices + (device_id, name, client_version, protocol_version, endpoint_id, + endpoint_ticket, trusted_at_ms, last_seen_ms, revoked_at_ms, + revoked_by, revoke_cutoff_seq) + VALUES (?1, ?2, ?3, ?4, '', '', ?5, ?5, NULL, NULL, NULL) + ON CONFLICT(device_id) DO UPDATE SET + name = excluded.name, + client_version = excluded.client_version, + protocol_version = excluded.protocol_version, + trusted_at_ms = excluded.trusted_at_ms, + last_seen_ms = excluded.last_seen_ms, + revoked_at_ms = NULL, + revoked_by = NULL, + revoke_cutoff_seq = NULL", + params![ + identity.device_id, + identity.name, + CLIENT_VERSION, + PROTOCOL_VERSION, + now, + ], + )?; + let local_seq = get_meta(&conn, "local_seq")? + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + conn.execute( + "INSERT INTO sync_vectors (device_id, max_seq) + VALUES (?1, ?2) + ON CONFLICT(device_id) DO UPDATE SET max_seq = excluded.max_seq", + params![identity.device_id, local_seq], + )?; + } + lock(&self.playback).remote.clear(); + Ok(new_group_id) + } + pub fn record_content_like(&self, content_id: &str, liked: bool) -> Result<()> { let Some(content_id) = music_dht::normalize_content_id(content_id) else { return Ok(()); @@ -1269,6 +1357,15 @@ impl DeviceSync { } fn record_local_op(&self, payload: SyncOpPayload) -> Result<()> { + self.record_local_op_generated(|_, _| Ok(payload), true) + .map(|_| ()) + } + + fn record_local_op_generated( + &self, + make_payload: impl FnOnce(&LocalIdentity, i64) -> Result, + gc_after_record: bool, + ) -> Result { let identity = self.ensure_identity()?; let (op, payload_json, tombstone) = { let conn = lock(&self.conn); @@ -1281,6 +1378,7 @@ impl DeviceSync { .unwrap_or(0); let hlc_ms = now_ms().max(last_hlc + 1); let op_id = format!("{}:{seq}", identity.device_id); + let payload = make_payload(&identity, seq)?; let payload_json = serde_json::to_string(&payload)?; let tombstone = payload.is_tombstone(); conn.execute( @@ -1327,8 +1425,10 @@ impl DeviceSync { payload = %payload_json, "recorded personal-sync op" ); - let _ = self.gc_tombstones(); - Ok(()) + if gc_after_record { + let _ = self.gc_tombstones(); + } + Ok(op) } fn apply_ops(&self, ops: Vec) -> Result<()> { @@ -3588,6 +3688,50 @@ mod tests { assert!(!device_known(&sync, device_id)); } + #[test] + fn leave_group_self_revokes_then_resets_to_new_group() { + let sync = test_sync(); + let identity = sync.ensure_identity().unwrap(); + let old_group = identity.group_id.clone(); + sync.apply_device_trusted("dev_peer", 10).unwrap(); + + let op_id = sync.record_leave_group_revoke().unwrap(); + assert!(device_revoked(&sync, &identity.device_id)); + { + let conn = lock(&sync.conn); + let payload_json: String = conn + .query_row( + "SELECT payload_json FROM sync_ops WHERE op_id = ?1", + [&op_id], + |row| row.get(0), + ) + .unwrap(); + let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap(); + match payload { + SyncOpPayload::DeviceRevoked { + target_device_id, + target_max_seq_seen, + } => { + assert_eq!(target_device_id, identity.device_id); + assert_eq!(target_max_seq_seen, 1); + } + other => panic!("unexpected payload: {other:?}"), + } + } + + let new_group = sync.finish_leave_group_reset().unwrap(); + assert_ne!(old_group, new_group); + let status = sync.status(); + assert_eq!(status.group_id, new_group); + assert_eq!(status.active_devices, 1); + assert_eq!(status.devices.len(), 1); + assert!(status.devices[0].is_self); + assert!(!status.devices[0].revoked); + assert_eq!(status.ops_total, 0); + assert_eq!(status.outbox_ops, 0); + assert!(!device_known(&sync, "dev_peer")); + } + #[test] fn playback_command_is_targeted_and_deduplicated() { let sync = test_sync(); diff --git a/src/library/mod.rs b/src/library/mod.rs index aa6158e..2e82ba3 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -227,10 +227,30 @@ pub struct NetworkArtistImageRequest { pub struct Library { conn: Mutex, + db_path: PathBuf, /// Directory where extracted embedded covers are stored. covers_dir: PathBuf, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LocalLibraryStats { + pub artist_count: i64, + pub release_count: i64, + pub track_count: i64, + pub audio_bytes: u64, + pub tracks_without_size: i64, + pub cover_bytes: u64, + pub database_bytes: u64, +} + +impl LocalLibraryStats { + pub fn total_bytes(&self) -> u64 { + self.audio_bytes + .saturating_add(self.cover_bytes) + .saturating_add(self.database_bytes) + } +} + /// Default database location: `/furumi/library.db`. pub fn default_db_path() -> Result { let dirs = crate::config::project_dirs().context("cannot determine the data directory")?; @@ -256,6 +276,7 @@ impl Library { .unwrap_or_else(|| PathBuf::from("covers")); Ok(Self { conn: Mutex::new(conn), + db_path: db_path.to_path_buf(), covers_dir, }) } @@ -264,6 +285,42 @@ impl Library { &self.covers_dir } + pub fn local_stats(&self) -> Result { + let (artist_count, release_count, track_count, audio_bytes, tracks_without_size) = { + let conn = self.lock(); + conn.query_row( + "SELECT + (SELECT COUNT(*) FROM artists), + (SELECT COUNT(*) FROM releases), + (SELECT COUNT(*) FROM tracks), + (SELECT COALESCE(SUM(CASE + WHEN file_size_bytes IS NOT NULL AND file_size_bytes >= 0 + THEN file_size_bytes ELSE 0 END), 0) FROM tracks), + (SELECT COUNT(*) FROM tracks WHERE file_size_bytes IS NULL) + ", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + )? + }; + Ok(LocalLibraryStats { + artist_count, + release_count, + track_count, + audio_bytes: audio_bytes.max(0) as u64, + tracks_without_size, + cover_bytes: directory_size(&self.covers_dir), + database_bytes: sqlite_database_size(&self.db_path), + }) + } + /// Make `content_id` a local-library invariant. /// /// Old databases can have NULL/invalid ids because the column was added @@ -2559,6 +2616,40 @@ pub(crate) fn audio_content_id(path: &str) -> Option { Some(format!("b3:{}", hasher.finalize().to_hex())) } +fn directory_size(path: &Path) -> u64 { + let Ok(metadata) = std::fs::metadata(path) else { + return 0; + }; + if metadata.is_file() { + return metadata.len(); + } + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + entries + .filter_map(|entry| entry.ok()) + .map(|entry| directory_size(&entry.path())) + .sum() +} + +fn file_size(path: &Path) -> u64 { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) +} + +fn sqlite_database_size(path: &Path) -> u64 { + file_size(path) + .saturating_add(file_size(&path_with_suffix(path, "-wal"))) + .saturating_add(file_size(&path_with_suffix(path, "-shm"))) +} + +fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) +} + fn now_ms_i64() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -2664,7 +2755,8 @@ mod tests { conn.execute_batch(SCHEMA).unwrap(); Library { conn: Mutex::new(conn), - covers_dir: std::env::temp_dir(), + db_path: std::env::temp_dir().join("furumi-test-library.db"), + covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"), } } @@ -2716,6 +2808,20 @@ mod tests { } } + #[test] + fn local_stats_counts_library_rows_and_audio_bytes() { + let lib = test_library(); + add_track(&lib, "One", "Artist", "First"); + add_track(&lib, "Two", "Artist", "Second"); + + let stats = lib.local_stats().unwrap(); + assert_eq!(stats.artist_count, 1); + assert_eq!(stats.release_count, 2); + assert_eq!(stats.track_count, 2); + assert_eq!(stats.audio_bytes, 2); + assert_eq!(stats.tracks_without_size, 0); + } + #[test] fn artists_page_prioritizes_releases_then_tracks() { let lib = test_library(); diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 45022b4..a480667 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -11,8 +11,8 @@ use crate::app::state::{AppState, DevicePresenceSection, FedRow, settings_rows}; pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { let block = Block::bordered() .title(" Settings ") - .title_style(theme::header()) - .border_style(theme::dim()); + .title_style(theme::header_for(state)) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); @@ -68,7 +68,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { let mut y = area.y; let mut cursor = 0usize; - draw_section(frame, area, &mut y, "Federation"); + draw_section(frame, area, state, &mut y, "Federation"); for row in FedRow::ALL { let (label, value) = match row { FedRow::Toggle => ("Federation", on_off(settings.enabled).to_string()), @@ -98,6 +98,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -109,12 +110,13 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { y = y.saturating_add(1); let connected_devices_enabled = state.connected_devices_enabled(); - draw_section(frame, area, &mut y, "Connected Devices"); + draw_section(frame, area, state, &mut y, "Connected Devices"); let disabled_value = "enable federation first".to_string(); let devices = state.federation.devices.as_ref(); draw_row_enabled( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -132,6 +134,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row_enabled( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -147,6 +150,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row_enabled( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -162,6 +166,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row_enabled( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -178,6 +183,22 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { connected_devices_enabled, ); cursor += 1; + draw_row_enabled( + frame, + area, + state, + &mut y, + cursor, + state.settings_cursor, + "Leave device group", + if connected_devices_enabled { + "↵".to_string() + } else { + disabled_value.clone() + }, + connected_devices_enabled, + ); + cursor += 1; if let Some(status) = devices { let now = crate::app::state::unix_time_ms(); let mut current_section = None; @@ -219,6 +240,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row_enabled( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -231,10 +253,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { } y = y.saturating_add(1); - draw_section(frame, area, &mut y, "Visualizations"); + draw_section(frame, area, state, &mut y, "Visualizations"); draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -266,6 +289,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -278,6 +302,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -290,6 +315,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -303,6 +329,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { draw_row( frame, area, + state, &mut y, cursor, state.settings_cursor, @@ -311,7 +338,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { ); } -fn draw_section(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) { +fn draw_section(frame: &mut Frame, area: Rect, state: &AppState, y: &mut u16, title: &'static str) { if *y >= area.y + area.height { return; } @@ -321,7 +348,10 @@ fn draw_section(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static str) width: area.width, height: 1, }; - frame.render_widget(Paragraph::new(Line::styled(title, theme::header())), rect); + frame.render_widget( + Paragraph::new(Line::styled(title, theme::header_for(state))), + rect, + ); *y = (*y).saturating_add(1); } @@ -348,18 +378,20 @@ fn draw_subsection(frame: &mut Frame, area: Rect, y: &mut u16, title: &'static s fn draw_row( frame: &mut Frame, area: Rect, + state: &AppState, y: &mut u16, row_index: usize, cursor: usize, label: &str, value: String, ) { - draw_row_enabled(frame, area, y, row_index, cursor, label, value, true); + draw_row_enabled(frame, area, state, y, row_index, cursor, label, value, true); } fn draw_row_enabled( frame: &mut Frame, area: Rect, + state: &AppState, y: &mut u16, row_index: usize, cursor: usize, @@ -383,7 +415,7 @@ fn draw_row_enabled( Span::styled( marker, if enabled { - theme::accent() + theme::accent_for(state) } else { theme::dim() }, @@ -393,7 +425,7 @@ fn draw_row_enabled( if !enabled { theme::dim() } else if selected { - theme::accent() + theme::accent_for(state) } else { ratatui::style::Style::default() }, @@ -444,7 +476,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { return; } - if area.height < 18 || area.width < 36 { + if area.height < 15 || area.width < 36 { frame.render_widget( Paragraph::new(compact_status_lines(state)) .wrap(ratatui::widgets::Wrap { trim: false }), @@ -453,40 +485,128 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { return; } - let [node_area, _, transport_area, _, devices_area, _] = Layout::vertical([ - Constraint::Length(5), + if area.width >= 60 && area.height >= 15 { + let [top_area, _, bottom_area] = Layout::vertical([ + Constraint::Length(7), + Constraint::Length(1), + Constraint::Length(7), + ]) + .areas(area); + let [status_area, _, local_area] = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(50), + ]) + .areas(top_area); + let [transport_area, _, devices_area] = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(50), + ]) + .areas(bottom_area); + draw_summary_card( + frame, + status_area, + state, + " Status ", + node_summary_lines(state), + ); + draw_summary_card( + frame, + local_area, + state, + " Local Data ", + local_data_summary_lines(state), + ); + draw_summary_card( + frame, + transport_area, + state, + " Iroh Transport ", + transport_summary_lines(state), + ); + draw_summary_card( + frame, + devices_area, + state, + " Connected Devices ", + device_summary_lines(state), + ); + return; + } + + if area.height < 31 { + frame.render_widget( + Paragraph::new(compact_status_lines(state)) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + return; + } + + let [ + node_area, + _, + transport_area, + _, + devices_area, + _, + local_area, + _, + ] = Layout::vertical([ + Constraint::Length(7), Constraint::Length(1), - Constraint::Length(5), + Constraint::Length(7), Constraint::Length(1), - Constraint::Length(5), + Constraint::Length(7), + Constraint::Length(1), + Constraint::Length(7), Constraint::Min(0), ]) .areas(area); - draw_summary_card(frame, node_area, " Status ", node_summary_lines(state)); + draw_summary_card( + frame, + node_area, + state, + " Status ", + node_summary_lines(state), + ); draw_summary_card( frame, transport_area, + state, " Iroh Transport ", transport_summary_lines(state), ); draw_summary_card( frame, devices_area, + state, " Connected Devices ", device_summary_lines(state), ); + draw_summary_card( + frame, + local_area, + state, + " Local Data ", + local_data_summary_lines(state), + ); } fn compact_status_lines(state: &AppState) -> Vec> { let mut lines = Vec::new(); - lines.push(Line::styled("Status", theme::header())); + lines.push(Line::styled("Status", theme::header_for(state))); lines.extend(node_summary_lines(state).into_iter().take(2)); lines.push(Line::default()); - lines.push(Line::styled("Iroh Transport", theme::header())); + lines.push(Line::styled("Local Data", theme::header_for(state))); + lines.extend(local_data_summary_lines(state).into_iter().take(3)); + lines.push(Line::default()); + lines.push(Line::styled("Iroh Transport", theme::header_for(state))); lines.extend(transport_summary_lines(state).into_iter().take(2)); lines.push(Line::default()); - lines.push(Line::styled("Connected Devices", theme::header())); + lines.push(Line::styled("Connected Devices", theme::header_for(state))); lines.extend(device_summary_lines(state).into_iter().take(2)); lines } @@ -494,6 +614,7 @@ fn compact_status_lines(state: &AppState) -> Vec> { fn draw_summary_card( frame: &mut Frame, area: Rect, + state: &AppState, title: &'static str, lines: Vec>, ) { @@ -502,8 +623,8 @@ fn draw_summary_card( } let block = Block::bordered() .title(title) - .title_style(theme::header()) - .border_style(theme::dim()); + .title_style(theme::header_for(state)) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget(Paragraph::new(lines), inner); @@ -626,6 +747,51 @@ fn transport_summary_lines(state: &AppState) -> Vec> { ] } +fn local_data_summary_lines(state: &AppState) -> Vec> { + match &state.local_library_stats { + None | Some(crate::app::state::Loadable::Loading) => vec![ + summary_line("Counts", "loading".to_string()), + summary_line("Media", "loading".to_string()), + summary_line("SQLite", "loading".to_string()), + summary_line("Total", "loading".to_string()), + ], + Some(crate::app::state::Loadable::Failed(message)) => vec![ + summary_line("Counts", "unavailable".to_string()), + summary_line("Problem", first_line(message)), + ], + Some(crate::app::state::Loadable::Ready(stats)) => { + let media = if stats.tracks_without_size > 0 { + format!( + "{} · {} unknown", + short_bytes_label(stats.audio_bytes), + stats.tracks_without_size + ) + } else { + short_bytes_label(stats.audio_bytes) + }; + vec![ + summary_line( + "Counts", + format!( + "{} artists / {} releases / {} tracks", + stats.artist_count, stats.release_count, stats.track_count + ), + ), + summary_line("Media", media), + summary_line("SQLite", short_bytes_label(stats.database_bytes)), + summary_line( + "Total", + format!( + "{} (covers {})", + short_bytes_label(stats.total_bytes()), + short_bytes_label(stats.cover_bytes) + ), + ), + ] + } + } +} + fn device_summary_lines(state: &AppState) -> Vec> { if !state.connected_devices_enabled() { return vec![ @@ -718,7 +884,7 @@ pub(super) fn status_detail_sections( } fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec> { - let mut lines: Vec = vec![Line::styled("Status", theme::header())]; + let mut lines: Vec = vec![Line::styled("Status", theme::header_for(state))]; match &state.federation.status { None => lines.push(Line::styled("loading…", theme::dim())), Some(status) if !status.running => { @@ -741,6 +907,7 @@ fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec Vec Vec Line<'static> { +fn status_action_line( + label: &str, + value: String, + state: &AppState, + selected: bool, +) -> Line<'static> { let marker = if selected { "▶" } else { " " }; Line::from(vec![ Span::styled( format!("{marker} {label:<16}"), if selected { - theme::accent() + theme::accent_for(state) } else { theme::dim() }, @@ -819,10 +992,11 @@ fn status_action_line(label: &str, value: String, selected: bool) -> Line<'stati fn push_transport_summary_status( lines: &mut Vec>, + state: &AppState, status: &crate::federation::FedStatus, ) { lines.push(Line::default()); - lines.push(Line::styled("Iroh Transport", theme::header())); + lines.push(Line::styled("Iroh Transport", theme::header_for(state))); let transport = &status.transport; let runtime_total = transport .runtime_tx_bytes @@ -934,7 +1108,7 @@ pub(super) fn status_detail_transport_logs(state: &AppState) -> Vec Vec> { - let mut lines = vec![Line::styled("Connected Devices", theme::header())]; + let mut lines = vec![Line::styled("Connected Devices", theme::header_for(state))]; match &state.federation.devices { None => lines.push(Line::styled("loading…", theme::dim())), Some(status) => { @@ -976,7 +1150,7 @@ fn status_detail_device_lines(state: &AppState) -> Vec> { lines.push(status_line("Error", first_line(last_error))); } lines.push(Line::default()); - lines.push(Line::styled("Device List", theme::header())); + lines.push(Line::styled("Device List", theme::header_for(state))); if status.devices.is_empty() { lines.push(status_line("Devices", "none recorded".to_string())); } else { @@ -1047,7 +1221,7 @@ fn push_device_detail_compact( format!("v{}", device.client_version) }; lines.push(Line::from(vec![ - Span::styled(format!("{icon} "), theme::accent()), + Span::styled(format!("{icon} "), theme::accent_for(state)), Span::raw(crate::app::state::device_display_name(device)), Span::styled( format!(" · {} · {}", version, badges.join(", ")), diff --git a/src/ui/global.rs b/src/ui/global.rs index 2aa8f9f..4b4ed65 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -37,12 +37,19 @@ fn error_style() -> Style { Style::new().fg(Color::Red) } -fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { - bordered_line(frame, area, Line::styled(title, theme::header())) +fn bordered(frame: &mut Frame, area: Rect, state: &AppState, title: String) -> Rect { + bordered_line( + frame, + area, + state, + Line::styled(title, theme::header_for(state)), + ) } -fn bordered_line(frame: &mut Frame, area: Rect, title: Line<'static>) -> Rect { - let block = Block::bordered().title(title).border_style(theme::dim()); +fn bordered_line(frame: &mut Frame, area: Rect, state: &AppState, title: Line<'static>) -> Rect { + let block = Block::bordered() + .title(title) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); inner @@ -88,6 +95,7 @@ fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) { fn draw_tile_with_availability( frame: &mut Frame, tile: Rect, + state: &AppState, art_state: Option<&ArtState>, title: &str, meta: &str, @@ -97,9 +105,9 @@ fn draw_tile_with_availability( let block = if selected { Block::bordered() .border_type(ratatui::widgets::BorderType::Thick) - .border_style(theme::accent()) + .border_style(theme::strong_border_for(state)) } else { - Block::bordered().border_style(theme::dim()) + Block::bordered().border_style(theme::border_for(state)) }; let inner = block.inner(tile); frame.render_widget(block, tile); @@ -121,7 +129,9 @@ fn draw_tile_with_availability( name_area, ); if selected { - frame.buffer_mut().set_style(name_area, theme::tab_active()); + frame + .buffer_mut() + .set_style(name_area, theme::tab_active_for(state)); } } if inner.height > ART_CELL_HEIGHT + 1 { @@ -130,13 +140,14 @@ fn draw_tile_with_availability( height: 1, ..inner }; - draw_tile_meta(frame, meta_area, meta, availability, selected); + draw_tile_meta(frame, meta_area, state, meta, availability, selected); } } fn draw_tile_meta( frame: &mut Frame, area: Rect, + state: &AppState, meta: &str, availability: Option, selected: bool, @@ -162,7 +173,9 @@ fn draw_tile_meta( text_area, ); if selected { - frame.buffer_mut().set_style(area, theme::tab_active()); + frame + .buffer_mut() + .set_style(area, theme::tab_active_for(state)); } if let Some((label, style)) = marker && marker_width > 0 @@ -294,7 +307,14 @@ fn marquee_phase(total_width: usize) -> usize { /// One selectable row: left content, optional right-aligned suffix, full-row /// highlight when selected. -fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option, selected: bool) { +fn draw_row( + frame: &mut Frame, + area: Rect, + state: &AppState, + line: Line, + right: Option, + selected: bool, +) { frame.render_widget(Paragraph::new(line), area); if let Some(right) = right { frame.render_widget( @@ -303,7 +323,9 @@ fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option, se ); } if selected { - frame.buffer_mut().set_style(area, theme::tab_active()); + frame + .buffer_mut() + .set_style(area, theme::tab_active_for(state)); } } @@ -368,12 +390,12 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { } else { format!(" Library · {} ", global.filters.source_mode.label()) }; - let mut title_spans = vec![Span::styled(title, theme::tab_active())]; + let mut title_spans = vec![Span::styled(title, theme::tab_active_for(state))]; if global.filters.is_active() { title_spans.push(Span::raw(" ")); - title_spans.push(Span::styled(" FILTERED ", theme::tab_active())); + title_spans.push(Span::styled(" FILTERED ", theme::tab_active_for(state))); } - let inner = bordered_line(frame, area, Line::from(title_spans)); + let inner = bordered_line(frame, area, state, Line::from(title_spans)); if global.artists.is_empty() { let message = if let Some(error) = &global.error { @@ -418,6 +440,7 @@ fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { draw_tile_with_availability( frame, tile, + state, tile_art(state, artist.image_path.as_ref()), &artist.name, &artist_tile_meta(artist), @@ -439,7 +462,7 @@ fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) { .map(|(offset, artist)| { let index = first + offset; let style = if index == global.selected { - theme::tab_active() + theme::tab_active_for(state) } else { Style::new() }; @@ -458,7 +481,7 @@ fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) { Constraint::Length(7), ], ) - .header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header())); + .header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header_for(state))); frame.render_widget(table, inner); } @@ -472,7 +495,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!(" Library ▸ {name} ")); + let inner = bordered(frame, area, state, format!(" Library ▸ {name} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, @@ -538,7 +561,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: }; let mut info = vec![ Line::default(), - Line::styled(detail.name.clone(), theme::header()), + Line::styled(detail.name.clone(), theme::header_for(state)), Line::default(), Line::styled( format!( @@ -569,7 +592,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ); } if tracks > 0 { - items.push(PlanItem::Header("Top tracks".to_string())); + items.push(PlanItem::Header("Liked tracks".to_string())); for index in 0..tracks { if cursor == index { cursor_item = Some(items.len()); @@ -674,6 +697,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: draw_tile_with_availability( frame, tile, + state, tile_art(state, release.cover_path.as_ref()), &release.title, &artist_release_tile_meta(release), @@ -688,6 +712,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: draw_row( frame, rect, + state, Line::from(vec![ Span::raw(release.title.clone()), Span::styled(format!(" {year}"), theme::dim()), @@ -732,7 +757,7 @@ fn release_tile_meta(release: &ReleaseCard) -> String { fn render_plan( frame: &mut Frame, area: Rect, - _state: &AppState, + state: &AppState, items: &[PlanItem], cursor_item: Option, draw_item: &mut dyn FnMut(&mut Frame, Rect, &PlanItem), @@ -761,7 +786,7 @@ fn render_plan( }; match item { PlanItem::Header(label) => frame.render_widget( - Paragraph::new(Line::styled(label.clone(), theme::header())), + Paragraph::new(Line::styled(label.clone(), theme::header_for(state))), rect, ), PlanItem::Gap => {} @@ -780,7 +805,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!(" Library ▸ {title} ")); + let inner = bordered(frame, area, state, format!(" Library ▸ {title} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, @@ -813,7 +838,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default(); let info = vec![ Line::default(), - Line::styled(detail.title.clone(), theme::header()), + Line::styled(detail.title.clone(), theme::header_for(state)), Line::raw(artists.join(", ")), Line::default(), Line::styled( @@ -868,7 +893,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { if search.loading { title.push_str("· searching… "); } - let inner = bordered(frame, area, title); + let inner = bordered(frame, area, state, title); let empty_results = SearchResults::default(); let results = match &search.results { @@ -905,7 +930,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { let mut rows: Vec<(Line, Option, Option)> = Vec::new(); let mut index = 0; if !results.artists.is_empty() { - rows.push((Line::styled("Artists", theme::header()), None, None)); + rows.push(( + Line::styled("Artists", theme::header_for(state)), + None, + None, + )); for artist in &results.artists { rows.push(( Line::raw(artist.name.clone()), @@ -917,7 +946,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { rows.push((Line::default(), None, None)); } if !results.releases.is_empty() { - rows.push((Line::styled("Releases", theme::header()), None, None)); + rows.push(( + Line::styled("Releases", theme::header_for(state)), + None, + None, + )); for release in &results.releases { rows.push(( Line::from(vec![ @@ -932,10 +965,10 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { rows.push((Line::default(), None, None)); } if !results.tracks.is_empty() { - rows.push((Line::styled("Tracks", theme::header()), None, None)); + rows.push((Line::styled("Tracks", theme::header_for(state)), None, None)); for track in &results.tracks { let heart = if state.track_liked(track) { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -968,7 +1001,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } else { "Federation" }; - rows.push((Line::styled(header, theme::header()), None, None)); + rows.push((Line::styled(header, theme::header_for(state)), None, None)); for hit in &state.search.fed_artists { rows.push(( Line::from(vec![ @@ -987,7 +1020,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } for fed in &state.search.fed_tracks { let heart = if state.fed_track_liked(fed) { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -1055,9 +1088,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { && fed_selected.contains(&row_index) && row_index != cursor { - frame.buffer_mut().set_style(rect, theme::selection()); + frame + .buffer_mut() + .set_style(rect, theme::selection_for(state)); } - draw_row(frame, rect, line, right, row_cursor == Some(cursor)); + draw_row(frame, rect, state, line, right, row_cursor == Some(cursor)); } } @@ -1069,7 +1104,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz let Some((name, data)) = &state.fed_artist_view else { return centered_line(frame, area, Line::styled("no card is open", theme::dim())); }; - let inner = bordered(frame, area, format!(" Federation ▸ {name} ")); + let inner = bordered(frame, area, state, format!(" Federation ▸ {name} ")); let card = match data { Loadable::Loading => { return centered_line( @@ -1128,7 +1163,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz } let info = vec![ Line::default(), - Line::styled(name.clone(), theme::header()), + Line::styled(name.clone(), theme::header_for(state)), Line::default(), Line::styled(stats, theme::dim()), Line::styled("enter: open release / play track · esc: back", theme::dim()), @@ -1203,6 +1238,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz draw_tile_with_availability( frame, tile, + state, tile_art(state, release.cover_path.as_ref()), &release.title, &meta, @@ -1246,7 +1282,7 @@ fn draw_fed_appearance_row( let track = &appearance.track; let liked = state.fed_card_track_liked(track); let heart = if liked { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -1284,11 +1320,14 @@ fn draw_fed_appearance_row( meta.push_str(&format!("{} peers", track.sources.len())); } if visual_selected && !selected { - frame.buffer_mut().set_style(area, theme::selection()); + frame + .buffer_mut() + .set_style(area, theme::selection_for(state)); } draw_row( frame, area, + state, line, (!meta.is_empty()).then_some(meta), selected, @@ -1338,6 +1377,7 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz let inner = bordered( frame, area, + state, format!(" Federation ▸ {name} ▸ {} ", release.title), ); @@ -1375,9 +1415,9 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz meta.push_str(&format!(" · {track_count} tracks · local only")); } let button_style = if cursor == 0 { - theme::tab_active() + theme::tab_active_for(state) } else { - theme::accent() + theme::accent_for(state) }; let action_line = if state.global.filters.source_mode.includes_network() { Line::styled( @@ -1389,7 +1429,7 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz }; let info = vec![ Line::default(), - Line::styled(release.title.clone(), theme::header()), + Line::styled(release.title.clone(), theme::header_for(state)), Line::styled(meta, theme::dim()), Line::default(), action_line, @@ -1454,7 +1494,7 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz let in_selection = state.track_selection.contains(&scope, position); let liked = state.fed_card_track_liked(track); let heart = if liked { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -1464,8 +1504,17 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz Span::raw(format!("{number}{}", track.title)), ]); if in_selection && cursor != position + 1 { - frame.buffer_mut().set_style(rect, theme::selection()); + frame + .buffer_mut() + .set_style(rect, theme::selection_for(state)); } - draw_row(frame, rect, line, Some(right), cursor == position + 1); + draw_row( + frame, + rect, + state, + line, + Some(right), + cursor == position + 1, + ); } } diff --git a/src/ui/logs.rs b/src/ui/logs.rs index a56ff00..9ccde1d 100644 --- a/src/ui/logs.rs +++ b/src/ui/logs.rs @@ -14,8 +14,8 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { let mode = if logs.follow { "follow" } else { "scroll" }; let block = Block::bordered() .title(format!(" Logs — {level}+ · {mode} ")) - .title_style(theme::header()) - .border_style(theme::dim()); + .title_style(theme::header_for(state)) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); @@ -39,13 +39,15 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { }; let line = Line::from(vec![ Span::styled(format!("{} ", entry.time), theme::dim()), - level_span(entry.level), + level_span(entry.level, state), Span::styled(format!(" {}: ", short_target(&entry.target)), theme::dim()), Span::raw(entry.message.clone()), ]); frame.render_widget(Paragraph::new(line), row); if view.cursor_row == Some(row_index) { - frame.buffer_mut().set_style(row, theme::tab_active()); + frame + .buffer_mut() + .set_style(row, theme::tab_active_for(state)); } } @@ -62,7 +64,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { " ↑{} of {} · enter: details · shift-g: follow · v: level ", view.from_end, view.matched ), - theme::tab_active(), + theme::tab_active_for(state), )) .alignment(Alignment::Right), footer, @@ -85,14 +87,14 @@ fn centered(frame: &mut Frame, area: Rect, text: &str) { ); } -fn level_span(level: tracing::Level) -> Span<'static> { +fn level_span(level: tracing::Level, state: &AppState) -> Span<'static> { match level { tracing::Level::ERROR => Span::styled( "ERROR", Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), ), tracing::Level::WARN => Span::styled("WARN ", Style::new().fg(Color::Yellow)), - tracing::Level::INFO => Span::styled("INFO ", theme::accent()), + tracing::Level::INFO => Span::styled("INFO ", theme::accent_for(state)), tracing::Level::DEBUG => Span::styled("DEBUG", theme::dim()), tracing::Level::TRACE => Span::styled("TRACE", theme::dim()), } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 51fa4ff..72e9e0e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -42,7 +42,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { if state.visualizer.active { crate::visualizer::draw(frame, state); if state.shutting_down { - draw_shutdown(frame); + draw_shutdown(frame, state); } return; } @@ -65,22 +65,22 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { draw_status(frame, status_area, state); if state.help_visible { - draw_help(frame, keymap); + draw_help(frame, keymap, state); } popup::draw(frame, state); if state.shutting_down { - draw_shutdown(frame); + draw_shutdown(frame, state); } } -fn draw_shutdown(frame: &mut Frame) { +fn draw_shutdown(frame: &mut Frame, state: &AppState) { let area = centered(frame.area(), 28, 5); frame.render_widget(Clear, area); - let block = Block::bordered().border_style(theme::accent()); + let block = Block::bordered().border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); frame.render_widget( - Paragraph::new(Line::styled("Shutting down...", theme::header())) + Paragraph::new(Line::styled("Shutting down...", theme::header_for(state))) .alignment(Alignment::Center), Rect { y: inner.y + inner.height / 2, @@ -103,7 +103,7 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { pub(crate) fn loading_line(state: &AppState, text: impl Into) -> Line<'static> { Line::from(vec![ - Span::styled(format!("{} ", state.spinner()), theme::accent()), + Span::styled(format!("{} ", state.spinner()), theme::accent_for(state)), Span::styled(text.into(), theme::dim()), ]) } @@ -128,7 +128,7 @@ fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) { let tabs = Tabs::new(titles) .select(state.active_tab.index()) .style(theme::dim()) - .highlight_style(theme::tab_active()) + .highlight_style(theme::tab_active_for(state)) .divider(""); frame.render_widget(tabs, area); } @@ -169,7 +169,7 @@ pub(crate) fn track_row_with_like_marker( let heart = if !show_like_marker { Span::raw("") } else if state.track_liked(track) { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -198,10 +198,14 @@ pub(crate) fn track_row_with_like_marker( area, ); if visual_selected { - frame.buffer_mut().set_style(area, theme::selection()); + frame + .buffer_mut() + .set_style(area, theme::selection_for(state)); } if selected { - frame.buffer_mut().set_style(area, theme::tab_active()); + frame + .buffer_mut() + .set_style(area, theme::tab_active_for(state)); } } @@ -246,8 +250,8 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { " Queue — {} tracks · enter: play · d: remove · shift-v: select · :clear ", player.queue.len() )) - .title_style(theme::header()) - .border_style(theme::dim()); + .title_style(theme::header_for(state)) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); @@ -329,7 +333,7 @@ fn player_right_line(state: &AppState, width: u16) -> Line<'static> { if bar_width > 0 && track.duration_seconds > 0.0 { let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0); let filled = (ratio * bar_width as f64).round() as usize; - spans.push(Span::styled("━".repeat(filled), theme::accent())); + spans.push(Span::styled("━".repeat(filled), theme::accent_for(state))); spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim())); spans.push(Span::raw(" ")); } else { @@ -347,14 +351,14 @@ fn player_right_line(state: &AppState, width: u16) -> Line<'static> { let volume_cells = usize::from(player.volume / 10); spans.extend([ Span::styled(" vol ", theme::dim()), - Span::styled("█".repeat(volume_cells), theme::accent()), + Span::styled("█".repeat(volume_cells), theme::accent_for(state)), Span::styled("░".repeat(10 - volume_cells), theme::dim()), Span::raw(format!(" {:3}%", player.volume)), Span::raw(" "), ]); // Enabled modes light up as filled chips; disabled stay dim text. if player.shuffle { - spans.push(Span::styled(" shuffle ", theme::tab_active())); + spans.push(Span::styled(" shuffle ", theme::tab_active_for(state))); } else { spans.push(Span::styled("shuffle off", theme::dim())); } @@ -364,21 +368,17 @@ fn player_right_line(state: &AppState, width: u16) -> Line<'static> { } else { spans.push(Span::styled( format!(" repeat {} ", player.repeat.label()), - theme::tab_active(), + theme::tab_active_for(state), )); } } else { spans.push(Span::styled(format!(" {}%", player.volume), theme::dim())); } if width >= 70 { - let role_style = match state.device_playback.role { - crate::app::state::DevicePlaybackRole::Active => Style::new().fg(Color::Green), - crate::app::state::DevicePlaybackRole::Control => Style::new().fg(Color::Yellow), - }; spans.push(Span::raw(" ")); spans.push(Span::styled( - state.device_playback.role.label().to_string(), - role_style, + format!(" {} ", state.device_playback.role.label()), + theme::role_pill(state.device_playback.role), )); spans.push(Span::styled( format!(" · online {}", state.device_playback.online_devices.max(1)), @@ -410,10 +410,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { if player.paused { spans.push(Span::styled("⏸ ", theme::dim())); } else { - spans.push(Span::styled("▶ ", theme::accent())); + spans.push(Span::styled("▶ ", theme::accent_for(state))); } if state.track_liked(track) { - spans.push(Span::styled("♥ ", theme::accent())); + spans.push(Span::styled("♥ ", theme::accent_for(state))); } spans.push(Span::raw(track.title.clone())); spans.push(Span::styled( @@ -430,7 +430,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { if state.cmdline.active { // Vim-style command line takes over the message row. - let mut spans = vec![Span::styled(":", theme::header())]; + let mut spans = vec![Span::styled(":", theme::header_for(state))]; spans.extend(line_edit_spans( &state.cmdline.input, usize::from(message_row.width.saturating_sub(2)), @@ -442,10 +442,10 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { let message = match &state.status_message { Some(message) if is_waiting_message(message) => Line::from(vec![ - Span::styled(format!("{} ", state.spinner()), theme::accent()), - Span::styled(message.clone(), theme::accent()), + Span::styled(format!("{} ", state.spinner()), theme::accent_for(state)), + Span::styled(message.clone(), theme::accent_for(state)), ]), - Some(message) => Line::styled(message.clone(), theme::accent()), + Some(message) => Line::styled(message.clone(), theme::accent_for(state)), None => match active_artist_peer_search(state) { Some(message) => loading_line(state, message), None => match &state.player.current { @@ -461,8 +461,11 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { draw_version(frame, message_row); if let Some(pending) = &state.pending_keys { - let pending = Paragraph::new(Line::styled(format!("{pending} …"), theme::header())) - .alignment(Alignment::Right); + let pending = Paragraph::new(Line::styled( + format!("{pending} …"), + theme::header_for(state), + )) + .alignment(Alignment::Right); frame.render_widget(pending, message_row); } } @@ -494,7 +497,7 @@ fn draw_version(frame: &mut Frame, area: Rect) { /// Help window: bindings merged per action (j / down on one row), grouped /// into titled sections and laid out in two balanced columns. -fn draw_help(frame: &mut Frame, keymap: &Keymap) { +fn draw_help(frame: &mut Frame, keymap: &Keymap, state: &AppState) { use crate::app::action::{Action, Category}; use crate::config::keymap::KeyContext; @@ -528,7 +531,7 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) { if rows.is_empty() { continue; } - let mut lines = vec![Line::styled(category.title(), theme::header())]; + let mut lines = vec![Line::styled(category.title(), theme::header_for(state))]; for row in rows { let keys = row.keys.join(" / "); let context = if row.context == KeyContext::Global { @@ -538,19 +541,19 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) { }; let command = row.action.command_hint().unwrap_or(""); lines.push(Line::from(vec![ - Span::styled(format!("{keys:<13}"), theme::accent()), + Span::styled(format!("{keys:<13}"), theme::accent_for(state)), Span::raw(format!( "{:<24}", format!("{}{context}", row.action.describe()) )), - Span::styled(command.to_string(), theme::accent()), + Span::styled(command.to_string(), theme::accent_for(state)), ])); } lines.push(Line::default()); blocks.push(lines); } blocks.push(vec![ - Line::styled("Status icons", theme::header()), + Line::styled("Status icons", theme::header_for(state)), Line::from(vec![ Span::styled("●", Style::new().fg(Color::Green)), Span::raw(" Local on this device"), @@ -560,7 +563,7 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) { Span::raw(" Local + peer sources"), ]), Line::from(vec![ - Span::styled("⇅", theme::accent()), + Span::styled("⇅", theme::accent_for(state)), Span::raw(" Network only"), ]), Line::default(), @@ -585,8 +588,8 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) { let block = Block::bordered() .title(" Keybindings & commands ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs index 61ff434..40ee748 100644 --- a/src/ui/playlists.rs +++ b/src/ui/playlists.rs @@ -15,11 +15,11 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { } } -fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { +fn bordered(frame: &mut Frame, area: Rect, state: &AppState, title: String) -> Rect { let block = Block::bordered() .title(title) - .title_style(theme::header()) - .border_style(theme::dim()); + .title_style(theme::header_for(state)) + .border_style(theme::border_for(state)); let inner = block.inner(area); frame.render_widget(block, area); inner @@ -38,7 +38,7 @@ fn centered_line(frame: &mut Frame, area: Rect, line: Line) { } fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { - let inner = bordered(frame, area, " Playlists ".to_string()); + let inner = bordered(frame, area, state, " Playlists ".to_string()); let selected = state.playlists.selected; let list = match &state.playlists.list { @@ -70,7 +70,7 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { height: 1, }; let marker = if playlist.kind == "likes" { - Span::styled("♥ ", theme::accent()) + Span::styled("♥ ", theme::accent_for(state)) } else { Span::raw(" ") }; @@ -87,7 +87,9 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { row, ); if index == selected { - frame.buffer_mut().set_style(row, theme::tab_active()); + frame + .buffer_mut() + .set_style(row, theme::tab_active_for(state)); } } } @@ -98,7 +100,7 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title), _ => " Playlists ▸ … ".to_string(), }; - let inner = bordered(frame, area, title); + let inner = bordered(frame, area, state, title); if let Some(Loadable::Failed(error)) = loadable { return centered_line( diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 9514802..acd5eb7 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -16,34 +16,34 @@ pub fn draw(frame: &mut Frame, state: &AppState) { Some(Popup::AddToPlaylist { target, cursor }) => { draw_picker(frame, state, &target.label(), *cursor) } - Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy), + Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, state, input, *busy), Some(Popup::Edit { title, fields, focus, error, .. - }) => draw_edit(frame, title, fields, *focus, error.as_deref()), - Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label), + }) => draw_edit(frame, state, title, fields, *focus, error.as_deref()), + Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label), Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor), Some(Popup::TrackInfo { tracks, cursor, scroll, - }) => draw_track_info(frame, tracks, *cursor, *scroll), + }) => draw_track_info(frame, state, tracks, *cursor, *scroll), Some(Popup::TrackArtists { tracks, cursor, selected, .. - }) => draw_track_artists(frame, tracks, *cursor, *selected), - Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), + }) => draw_track_artists(frame, state, tracks, *cursor, *selected), + Some(Popup::LogDetail(entry)) => draw_log_detail(frame, state, entry), Some(Popup::FedInput { field, input }) => { - draw_fed_input(frame, field.title(), field.help(), input) + draw_fed_input(frame, state, field.title(), field.help(), input) } - Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text), + Some(Popup::FedText { title, text }) => draw_fed_text(frame, state, title, text), Some(Popup::FedCopyText { title, text, help }) => { - draw_fed_copy_text(frame, title, text, help) + draw_fed_copy_text(frame, state, title, text, help) } Some(Popup::FederationStatusDetails { focus, @@ -63,7 +63,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) { text, scroll, parent: _, - }) => draw_federation_status_text(frame, title, text, *scroll), + }) => draw_federation_status_text(frame, state, title, text, *scroll), Some(Popup::FederationStatusLog { scroll, parent: _ }) => { draw_federation_status_log(frame, state, *scroll) } @@ -76,6 +76,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) { .. }) => draw_device_pairing( frame, + state, device_id, name, client_version, @@ -83,8 +84,9 @@ pub fn draw(frame: &mut Frame, state: &AppState) { *requester_group_active_devices, ), Some(Popup::ConfirmDeviceRevoke { device_id, name }) => { - draw_device_revoke(frame, device_id, name) + draw_device_revoke(frame, state, device_id, name) } + Some(Popup::ConfirmDeviceLeave) => draw_device_leave(frame, state), Some(Popup::ConnectedDevices { cursor }) => draw_connected_devices(frame, state, *cursor), None => {} } @@ -101,8 +103,8 @@ fn draw_federation_status_details( let area = federation_status_area(frame); let block = Block::bordered() .title(" Full status details ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -132,6 +134,7 @@ fn draw_federation_status_details( draw_status_detail_panel( frame, left, + state, " Status / Transport ", sections.status, 0, @@ -140,6 +143,7 @@ fn draw_federation_status_details( draw_status_detail_panel( frame, right, + state, " Connected Devices ", sections.devices, devices_scroll, @@ -152,6 +156,7 @@ fn draw_federation_status_details( draw_status_detail_panel( frame, top, + state, " Status / Transport ", sections.status, 0, @@ -160,6 +165,7 @@ fn draw_federation_status_details( draw_status_detail_panel( frame, bottom, + state, " Connected Devices ", sections.devices, devices_scroll, @@ -169,6 +175,7 @@ fn draw_federation_status_details( draw_status_log_panel( frame, logs_area, + state, sections.logs, focus == StatusDetailFocus::Logs, ); @@ -193,6 +200,7 @@ fn federation_status_area(frame: &Frame) -> Rect { fn draw_status_detail_panel( frame: &mut Frame, area: Rect, + state: &AppState, title: &'static str, lines: Vec>, scroll: usize, @@ -203,9 +211,9 @@ fn draw_status_detail_panel( } let block = Block::bordered() .title(title) - .title_style(theme::header()) + .title_style(theme::header_for(state)) .border_style(if focused { - theme::accent() + theme::strong_border_for(state) } else { theme::dim() }); @@ -218,15 +226,21 @@ fn draw_status_detail_panel( ); } -fn draw_status_log_panel(frame: &mut Frame, area: Rect, lines: Vec>, focused: bool) { +fn draw_status_log_panel( + frame: &mut Frame, + area: Rect, + state: &AppState, + lines: Vec>, + focused: bool, +) { if area.width == 0 || area.height == 0 { return; } let block = Block::bordered() .title(" Connection log ") - .title_style(theme::header()) + .title_style(theme::header_for(state)) .border_style(if focused { - theme::accent() + theme::strong_border_for(state) } else { theme::dim() }); @@ -236,12 +250,18 @@ fn draw_status_log_panel(frame: &mut Frame, area: Rect, lines: Vec frame.render_widget(Paragraph::new(preview), inner); } -fn draw_federation_status_text(frame: &mut Frame, title: &str, text: &str, scroll: usize) { +fn draw_federation_status_text( + frame: &mut Frame, + state: &AppState, + title: &str, + text: &str, + scroll: usize, +) { let area = federation_status_area(frame); let block = Block::bordered() .title(format!(" {title} ")) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -268,8 +288,8 @@ fn draw_federation_status_log(frame: &mut Frame, state: &AppState, scroll: usize let area = federation_status_area(frame); let block = Block::bordered() .title(" Connection log ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -313,8 +333,8 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { let area = centered(frame.area(), 76, height); let block = Block::bordered() .title(" Connected devices ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -330,7 +350,10 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Role ", theme::dim()), - Span::styled(state.device_playback.role.label(), theme::accent()), + Span::styled( + format!(" {} ", state.device_playback.role.label()), + theme::role_pill(state.device_playback.role), + ), Span::raw(" "), Span::styled("Active ", theme::dim()), Span::raw(active), @@ -338,7 +361,7 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { summary_area, ); - render_subtitle(frame, this_area, "This device"); + render_subtitle(frame, this_area, state, "This device"); let action_area = Rect { x: this_area.x, y: this_area.y + 1, @@ -364,9 +387,10 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { action_label, self_name, &self_status, + state, ); - render_subtitle(frame, other_area, "Other devices"); + render_subtitle(frame, other_area, state, "Other devices"); let list_area = Rect { x: other_area.x, y: other_area.y + 1, @@ -404,13 +428,13 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { continue; }; frame.render_widget( - Paragraph::new(Line::styled(section.title(), theme::header())), + Paragraph::new(Line::styled(section.title(), theme::header_for(state))), area, ); continue; }; let row = other_rows[*index]; - render_connected_device_row(frame, area, row, selected_remote == Some(*index)); + render_connected_device_row(frame, area, row, state, selected_remote == Some(*index)); } let [legend_area, controls_area] = @@ -433,14 +457,17 @@ fn draw_connected_devices(frame: &mut Frame, state: &AppState, cursor: usize) { ); } -fn render_subtitle(frame: &mut Frame, area: Rect, title: &'static str) { +fn render_subtitle(frame: &mut Frame, area: Rect, state: &AppState, title: &'static str) { let rect = Rect { x: area.x, y: area.y, width: area.width, height: 1, }; - frame.render_widget(Paragraph::new(Line::styled(title, theme::header())), rect); + frame.render_widget( + Paragraph::new(Line::styled(title, theme::header_for(state))), + rect, + ); } fn device_status_label(row: &crate::app::popup::ConnectedDevicePopupRow) -> String { @@ -469,6 +496,7 @@ fn render_connected_action( label: &str, device_name: &str, status: &str, + state: &AppState, ) { let marker = if selected { "▶ " } else { " " }; let prefix = format!("{marker}{label}"); @@ -491,7 +519,7 @@ fn render_connected_action( Span::styled( label, if selected { - theme::accent() + theme::accent_for(state) } else { theme::dim() }, @@ -501,7 +529,9 @@ fn render_connected_action( ]); frame.render_widget(Paragraph::new(line), area); if selected { - frame.buffer_mut().set_style(area, theme::tab_active()); + frame + .buffer_mut() + .set_style(area, theme::tab_active_for(state)); } } @@ -509,6 +539,7 @@ fn render_connected_device_row( frame: &mut Frame, area: Rect, row: &crate::app::popup::ConnectedDevicePopupRow, + state: &AppState, selected: bool, ) { let marker = if selected { "▶ " } else { " " }; @@ -524,7 +555,7 @@ fn render_connected_device_row( Span::styled( marker, if selected { - theme::accent() + theme::accent_for(state) } else { theme::dim() }, @@ -535,7 +566,9 @@ fn render_connected_device_row( ]); frame.render_widget(Paragraph::new(line), area); if selected { - frame.buffer_mut().set_style(area, theme::tab_active()); + frame + .buffer_mut() + .set_style(area, theme::tab_active_for(state)); } } @@ -568,8 +601,8 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { let area = centered(frame.area(), 54, 9); let block = Block::bordered() .title(" Library filters ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -588,7 +621,7 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { "[ ]" }; rows.push(Line::from(vec![ - Span::styled(format!("{checked} "), theme::accent()), + Span::styled(format!("{checked} "), theme::accent_for(state)), Span::raw("Hide featured only"), ])); for mode in crate::config::settings::LibrarySourceMode::ALL { @@ -598,7 +631,7 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { "( )" }; rows.push(Line::from(vec![ - Span::styled(format!("{marker} "), theme::accent()), + Span::styled(format!("{marker} "), theme::accent_for(state)), Span::raw(mode.label()), Span::styled(format!(" {}", mode.description()), theme::dim()), ])); @@ -611,7 +644,9 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { }; frame.render_widget(Paragraph::new(line), row); if cursor == index { - frame.buffer_mut().set_style(row, theme::tab_active()); + frame + .buffer_mut() + .set_style(row, theme::tab_active_for(state)); } } frame.render_widget( @@ -622,12 +657,18 @@ fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) { } /// One-line text entry on the Federation tab (network id / peer ticket). -fn draw_fed_input(frame: &mut Frame, title: &str, help: &str, input: &crate::app::input::LineEdit) { +fn draw_fed_input( + frame: &mut Frame, + state: &AppState, + title: &str, + help: &str, + input: &crate::app::input::LineEdit, +) { let area = centered(frame.area(), 72, 8); let block = Block::bordered() .title(format!(" {title} ")) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -653,7 +694,7 @@ fn draw_fed_input(frame: &mut Frame, title: &str, help: &str, input: &crate::app } /// Read-only wrapped text (this peer's federation ticket). -fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) { +fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) { let width = frame.area().width.saturating_sub(8).clamp(24, 90); let text_width = usize::from(width.saturating_sub(2)); let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16; @@ -664,8 +705,8 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) { ); let block = Block::bordered() .title(format!(" {title} ")) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -676,7 +717,7 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) { } /// Wrapped text with an explicit copy-and-close action. -fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) { +fn draw_fed_copy_text(frame: &mut Frame, state: &AppState, title: &str, text: &str, help: &str) { let width = frame.area().width.saturating_sub(8).clamp(36, 96); let text_width = usize::from(width.saturating_sub(2)); let text_lines = (text.chars().count() / text_width.max(1) + 1) as u16; @@ -685,8 +726,8 @@ fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) { let area = centered(frame.area(), width, height); let block = Block::bordered() .title(format!(" {title} ")) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -711,7 +752,7 @@ fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) { frame.render_widget( Paragraph::new(Line::styled( " Copy to clipboard and close ", - theme::tab_active(), + theme::tab_active_for(state), )) .alignment(Alignment::Center), button_area, @@ -725,6 +766,7 @@ fn draw_fed_copy_text(frame: &mut Frame, title: &str, text: &str, help: &str) { fn draw_device_pairing( frame: &mut Frame, + state: &AppState, device_id: &str, name: &str, client_version: &str, @@ -739,8 +781,8 @@ fn draw_device_pairing( ); let block = Block::bordered() .title(" Pair device ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -778,7 +820,7 @@ fn draw_device_pairing( ), Line::default(), Line::from(vec![ - Span::styled(" Recommended ", theme::tab_active()), + Span::styled(" Recommended ", theme::tab_active_for(state)), Span::raw(" "), Span::styled(" Cancel ", theme::danger_button()), ]) @@ -795,12 +837,12 @@ fn draw_device_pairing( frame.render_widget(Paragraph::new(lines), inner); } -fn draw_device_revoke(frame: &mut Frame, device_id: &str, name: &str) { +fn draw_device_revoke(frame: &mut Frame, state: &AppState, device_id: &str, name: &str) { let area = centered(frame.area(), 64, 8); let block = Block::bordered() .title(" Revoke device ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -827,7 +869,7 @@ fn draw_device_revoke(frame: &mut Frame, device_id: &str, name: &str) { Paragraph::new(Line::from(vec![ Span::styled(" Yes ", theme::danger_button()), Span::raw(" "), - Span::styled(" Cancel ", theme::tab_active()), + Span::styled(" Cancel ", theme::tab_active_for(state)), ])) .alignment(Alignment::Center), buttons, @@ -839,10 +881,58 @@ fn draw_device_revoke(frame: &mut Frame, device_id: &str, name: &str) { ); } +fn draw_device_leave(frame: &mut Frame, state: &AppState) { + let area = centered(frame.area(), 72, 9); + let block = Block::bordered() + .title(" Leave device group ") + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let [body, _, buttons, hint, _] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new(vec![ + Line::raw("This device will revoke itself from the current device group."), + Line::raw("After the revoke is synced, it will start a new empty group."), + Line::styled( + "Local music, likes and playlists stay on this device.", + theme::dim(), + ), + ]), + body, + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" Yes ", theme::danger_button()), + Span::raw(" "), + Span::styled(" Cancel ", theme::tab_active_for(state)), + ])) + .alignment(Alignment::Center), + buttons, + ); + frame.render_widget( + Paragraph::new(Line::styled( + "y leave group · enter/esc cancel", + theme::dim(), + )) + .alignment(Alignment::Center), + hint, + ); +} + /// Metadata edit form: one bordered input per field, the focused field gets /// the accent border and a cursor block. fn draw_edit( frame: &mut Frame, + state: &AppState, title: &str, fields: &[EditField], focus: usize, @@ -852,8 +942,8 @@ fn draw_edit( let area = centered(frame.area(), 60, height); let block = Block::bordered() .title(format!(" {title} ")) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -868,7 +958,7 @@ fn draw_edit( let field_block = Block::bordered() .title(field.label) .border_style(if focused { - theme::accent() + theme::strong_border_for(state) } else { theme::dim() }); @@ -890,19 +980,19 @@ fn draw_edit( let footer = areas[areas.len() - 1]; let hint = match error { - Some(error) => Line::styled(error.to_string(), theme::accent()), + Some(error) => Line::styled(error.to_string(), theme::accent_for(state)), None => Line::styled("tab/↑↓ field · enter save · esc cancel", theme::dim()), }; frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer); } -fn draw_confirm_delete(frame: &mut Frame, label: &str) { +fn draw_confirm_delete(frame: &mut Frame, state: &AppState, label: &str) { let width = 64.min(frame.area().width.saturating_sub(4)).max(30); let area = centered(frame.area(), width, 7); let block = Block::bordered() .title(" Delete? ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -921,15 +1011,15 @@ fn draw_confirm_delete(frame: &mut Frame, label: &str) { ); } -fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) { +fn draw_log_detail(frame: &mut Frame, state: &AppState, entry: &crate::config::logging::LogEntry) { let width = 90.min(frame.area().width.saturating_sub(4)).max(40); let height = 18.min(frame.area().height.saturating_sub(2)).max(7); let area = centered(frame.area(), width, height); let block = Block::bordered() .title(format!(" Log entry — {} {} ", entry.time, entry.level)) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -955,7 +1045,13 @@ fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) ); } -fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scroll: usize) { +fn draw_track_info( + frame: &mut Frame, + state: &AppState, + tracks: &[TrackItem], + cursor: usize, + scroll: usize, +) { let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else { return; }; @@ -970,8 +1066,8 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol let block = Block::bordered() .title(title) - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -1002,7 +1098,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol ); } -fn draw_track_artists(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, selected: usize) { +fn draw_track_artists( + frame: &mut Frame, + state: &AppState, + tracks: &[TrackItem], + cursor: usize, + selected: usize, +) { let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else { return; }; @@ -1025,8 +1127,8 @@ fn draw_track_artists(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, se let block = Block::bordered() .title(" Open artist ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -1059,7 +1161,9 @@ fn draw_track_artists(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, se }; frame.render_widget(Paragraph::new(line), row); if index == selected { - frame.buffer_mut().set_style(row, theme::tab_active()); + frame + .buffer_mut() + .set_style(row, theme::tab_active_for(state)); } } @@ -1212,8 +1316,8 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u let block = Block::bordered() .title(" Add to playlist ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -1230,15 +1334,15 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u lines.push(Line::styled("loading playlists…", theme::dim())); } else if matches!(&state.playlists.list, Some(Loadable::Failed(_))) { lines.push(Line::styled("playlist list unavailable", theme::dim())); - lines.push(Line::styled("+ New playlist…", theme::accent())); + lines.push(Line::styled("+ New playlist…", theme::accent_for(state))); } else if options.is_empty() { lines.push(Line::styled("no playlists yet", theme::dim())); - lines.push(Line::styled("+ New playlist…", theme::accent())); + lines.push(Line::styled("+ New playlist…", theme::accent_for(state))); } else { for (_, title) in &options { lines.push(Line::raw(title.clone())); } - lines.push(Line::styled("+ New playlist…", theme::accent())); + lines.push(Line::styled("+ New playlist…", theme::accent_for(state))); } let selected_line = if loading { 0 @@ -1260,7 +1364,9 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u }; frame.render_widget(Paragraph::new(line), row); if index == selected_line { - frame.buffer_mut().set_style(row, theme::tab_active()); + frame + .buffer_mut() + .set_style(row, theme::tab_active_for(state)); } } @@ -1275,12 +1381,17 @@ fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: u ); } -fn draw_name_entry(frame: &mut Frame, input: &crate::app::input::LineEdit, busy: bool) { +fn draw_name_entry( + frame: &mut Frame, + state: &AppState, + input: &crate::app::input::LineEdit, + busy: bool, +) { let area = centered(frame.area(), 44, 7); let block = Block::bordered() .title(" New playlist ") - .title_style(theme::header()) - .border_style(theme::accent()); + .title_style(theme::header_for(state)) + .border_style(theme::strong_border_for(state)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); @@ -1294,14 +1405,14 @@ fn draw_name_entry(frame: &mut Frame, input: &crate::app::input::LineEdit, busy: let name_block = Block::bordered() .title("Name") - .border_style(theme::accent()); + .border_style(theme::strong_border_for(state)); let name_inner = name_block.inner(field); frame.render_widget(name_block, field); let spans = super::line_edit_spans(input, usize::from(name_inner.width)); frame.render_widget(Paragraph::new(Line::from(spans)), name_inner); let hint = if busy { - Line::styled("creating…", theme::accent()) + Line::styled("creating…", theme::accent_for(state)) } else { Line::styled("enter create · esc back", theme::dim()) }; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 71b5c0f..f870ab9 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,12 +1,19 @@ use ratatui::style::{Color, Modifier, Style}; +use crate::app::state::{AppState, DevicePlaybackRole}; + pub const ACCENT: Color = Color::Cyan; +pub const CONTROL_ACCENT: Color = Color::Yellow; pub const DIM: Color = Color::DarkGray; pub fn accent() -> Style { Style::new().fg(ACCENT) } +pub fn accent_for(state: &AppState) -> Style { + Style::new().fg(accent_color_for(state)) +} + pub fn dim() -> Style { Style::new().fg(DIM) } @@ -18,6 +25,13 @@ pub fn tab_active() -> Style { .add_modifier(Modifier::BOLD) } +pub fn tab_active_for(state: &AppState) -> Style { + Style::new() + .fg(Color::Black) + .bg(accent_color_for(state)) + .add_modifier(Modifier::BOLD) +} + pub fn danger_button() -> Style { Style::new() .fg(Color::White) @@ -29,6 +43,45 @@ pub fn selection() -> Style { Style::new().fg(Color::White).bg(Color::Rgb(24, 68, 72)) } -pub fn header() -> Style { - Style::new().fg(ACCENT).add_modifier(Modifier::BOLD) +pub fn selection_for(state: &AppState) -> Style { + if state.device_playback.is_control() { + Style::new().fg(Color::White).bg(Color::Rgb(92, 72, 0)) + } else { + selection() + } +} + +pub fn header_for(state: &AppState) -> Style { + accent_for(state).add_modifier(Modifier::BOLD) +} + +pub fn border_for(state: &AppState) -> Style { + if state.device_playback.is_control() { + Style::new().fg(CONTROL_ACCENT) + } else { + dim() + } +} + +pub fn strong_border_for(state: &AppState) -> Style { + accent_for(state) +} + +pub fn role_pill(role: DevicePlaybackRole) -> Style { + let bg = match role { + DevicePlaybackRole::Active => Color::Green, + DevicePlaybackRole::Control => CONTROL_ACCENT, + }; + Style::new() + .fg(Color::Black) + .bg(bg) + .add_modifier(Modifier::BOLD) +} + +fn accent_color_for(state: &AppState) -> Color { + if state.device_playback.is_control() { + CONTROL_ACCENT + } else { + ACCENT + } }