Reworked statistics
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user