Reworked statistics

This commit is contained in:
Ultradesu
2026-07-26 01:41:16 +03:00
parent 17eb6a4fee
commit ada18a4583
14 changed files with 957 additions and 216 deletions
+2
View File
@@ -54,6 +54,8 @@ pub enum AppEvent {
LikesLoaded(Result<Vec<String>, String>),
/// Local-library content ids for availability markers.
LocalContentIdsLoaded(Result<Vec<String>, String>),
/// Counts and storage footprint of the local library/database.
LocalLibraryStatsLoaded(Result<crate::library::LocalLibraryStats, String>),
/// One content id became available locally while the UI is open.
LocalContentAvailable {
content_id: String,
+71 -1
View File
@@ -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);
+11
View File
@@ -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();
+5
View File
@@ -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<SettingsRow> {
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<String>,
pub likes_loaded: bool,
pub local_content_ids_loaded: bool,
pub local_library_stats: Option<Loadable<crate::library::LocalLibraryStats>>,
pub logs: LogsTab,
pub queue_tab: QueueTab,
pub federation: FederationTab,
+9
View File
@@ -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<Effect> {
}
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;
+146 -2
View File
@@ -957,6 +957,94 @@ impl DeviceSync {
Ok(())
}
pub fn record_leave_group_revoke(&self) -> Result<String> {
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<String> {
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::<i64>().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<SyncOpPayload>,
gc_after_record: bool,
) -> Result<SyncOpWire> {
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<SyncOpWire>) -> 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();
+107 -1
View File
@@ -227,10 +227,30 @@ pub struct NetworkArtistImageRequest {
pub struct Library {
conn: Mutex<Connection>,
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: `<data dir>/furumi/library.db`.
pub fn default_db_path() -> Result<PathBuf> {
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<LocalLibraryStats> {
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<String> {
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();
+203 -29
View File
@@ -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<Line<'static>> {
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<Line<'static>> {
fn draw_summary_card(
frame: &mut Frame,
area: Rect,
state: &AppState,
title: &'static str,
lines: Vec<Line<'static>>,
) {
@@ -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<Line<'static>> {
]
}
fn local_data_summary_lines(state: &AppState) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
let mut lines: Vec<Line> = vec![Line::styled("Status", theme::header())];
let mut lines: Vec<Line> = 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<Lin
short_id(&status.endpoint_id),
short_id(&status.dht_node_id)
),
state,
status_cursor == 0,
));
lines.push(status_line(
@@ -764,6 +931,7 @@ fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec<Lin
lines.push(status_action_line(
"Peer IDs",
peers.join(", "),
state,
status_cursor == 1,
));
}
@@ -795,19 +963,24 @@ fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec<Lin
if let Some(error) = &status.last_error {
lines.push(status_line("Error", first_line(error)));
}
push_transport_summary_status(&mut lines, status);
push_transport_summary_status(&mut lines, state, status);
}
}
lines
}
fn status_action_line(label: &str, value: String, selected: bool) -> 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<Line<'static>>,
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<Line<'static
}
fn status_detail_device_lines(state: &AppState) -> Vec<Line<'static>> {
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<Line<'static>> {
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(", ")),
+91 -42
View File
@@ -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<Availability>,
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<String>, selected: bool) {
fn draw_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
line: Line,
right: Option<String>,
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<String>, 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<usize>,
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<String>, Option<usize>)> = 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,
);
}
}
+9 -7
View File
@@ -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()),
}
+42 -39
View File
@@ -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<String>) -> 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);
+9 -7
View File
@@ -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(
+197 -86
View File
@@ -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<Line<'static>>,
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<Line<'static>>, focused: bool) {
fn draw_status_log_panel(
frame: &mut Frame,
area: Rect,
state: &AppState,
lines: Vec<Line<'static>>,
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<Line<'static>
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())
};
+55 -2
View File
@@ -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
}
}