From 42a3f0c35a489182f5b54bcbe7961d1a3cd1804d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 10 Sep 2026 19:55:11 +0200 Subject: [PATCH] design pass: responsive layout, clearer cards, better sync/log states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - DEVICE card: storage is now a compact "276.2 GiB free · 40%" value plus a usage bar on its own row (only when it cannot collide with the text) — the old "total · free" value was truncated mid-number; gauge labels no longer set an explicit fg so ratatui's colour swap keeps them readable over both filled and empty bar cells - LIBRARY card: paths are marked ✓/✗ (missing dirs are a common failure) and long values are ellipsized instead of hard-clipped; absolute local storagebox sources get the same check - SYNC STATUS: device names adapt to the card width, statuses go time-only on narrow cards, and the summary footer is two compact lines (no trailing "…") - responsive: sidebar 28/34 cols, compact labels for narrow cards, short terminal drops the decorative SYNC PATH card (it previously lost its only content row and rendered empty) Sync tab: - hero "READY TO SYNC" panel for the selected device (name, firmware, mount state, key hint, last sync) instead of a mostly empty LAST SYNC card - after a run finishes, keep the step timeline visible with final statuses and a LAST RUN result banner, plus an "s run again" hint (previously the steps were discarded and only a summary line remained) - steps connected as a timeline Chrome: - shared panel/firmware/chip/arrow/key-hint helpers in components; softer panel borders (theme::BORDER) and a softer sidebar selection highlight - title bar reduced to one row and now shows mirror on/off + ready/syncing instead of the truncated config path - logs header shows warning/error counts; cards use one consistent style Bump to 0.3.0. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- src/config.rs | 9 - src/ui/components.rs | 141 ++++++++++---- src/ui/mod.rs | 140 ++++++++++++- src/ui/tabs/dashboard.rs | 412 +++++++++++++++++++++++---------------- src/ui/tabs/logs.rs | 41 +++- src/ui/tabs/sync.rs | 254 ++++++++++++++++-------- src/ui/theme.rs | 2 + 10 files changed, 699 insertions(+), 310 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 949b744..21eaecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,7 +253,7 @@ dependencies = [ [[package]] name = "dap-tui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a122b8f..4e1a5bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dap-tui" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Device sync for portable players — TUI edition (ratatui)" license = "MIT" diff --git a/README.md b/README.md index 6c37193..1c7efc0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ living dashboard instead of a batch CLI. last-sync age per device. - **3 tabs** — Dashboard (per-player overview + settings, storage usage when mounted, library + sync pipeline), Sync (live step pipeline + progress - gauge), Logs (color-coded, follow mode). + gauge with speed/ETA, timeline of the last run), Logs (color-coded, follow + mode, warning/error counters). +- **Responsive layout** — short terminals drop the decorative sync-path card, + narrow cards switch to compact labels/values and ellipsize long paths + instead of clipping them mid-word. - **Live sync engine** — streams `rclone` and `rsync` output, parses progress (byte-weighted via `--info=progress2`), NFC-aware diffing for macOS→FAT32/Android devices, optional `fatsort` finalize for Mlove devices. diff --git a/src/config.rs b/src/config.rs index a6886c5..950a941 100644 --- a/src/config.rs +++ b/src/config.rs @@ -108,15 +108,6 @@ pub struct Device { pub last_status: String, } -impl Device { - pub fn mount_str(&self) -> String { - match &self.mount_point { - Some(p) => p.display().to_string(), - None => "—".to_string(), - } - } -} - #[derive(Debug, Clone)] pub struct Config { pub config_file: PathBuf, diff --git a/src/ui/components.rs b/src/ui/components.rs index 3d89627..9349737 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -1,4 +1,5 @@ use crate::app::{relative_ago, App, Tab}; +use crate::config::Firmware; use crate::engine::{LogLevel, StepStatus}; use crate::ui::theme; use crate::ui::truncate; @@ -6,7 +7,63 @@ use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; + +// ── shared panel chrome ─────────────────────────────────────────────────────── + +/// Rounded card with the accent title used by every tab, so all panels share +/// exactly one look. +pub fn panel(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)) + .title(Span::styled( + format!(" {title} "), + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )) +} + +/// Colour per firmware family, used for the firmware tag/chip. +pub fn firmware_color(fw: &Firmware) -> ratatui::style::Color { + match fw { + Firmware::Rockbox => theme::MAGENTA, + Firmware::Android => theme::CYAN, + Firmware::Mlove => theme::ORANGE, + Firmware::Sony => theme::GREEN, + Firmware::Apple => theme::YELLOW, + } +} + +/// Highlighted inline chip like `[storagebox:backup/masters]`. +pub fn chip(text: &str) -> Span<'static> { + Span::styled( + format!(" [{text}]"), + Style::default() + .fg(theme::BG) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) +} + +/// Arrow between two chips: ` ─rsync▶ `. +pub fn arrow(label: &str) -> Span<'static> { + Span::styled(format!(" ─{label}▶ "), Style::default().fg(theme::CYAN)) +} + +/// A small key hint, e.g. ` s `, for inline "press …" prompts. +pub fn key_hint(key: &str) -> Span<'static> { + Span::styled( + format!(" {key} "), + Style::default() + .fg(theme::BG) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) +} // ── title bar ───────────────────────────────────────────────────────────────── @@ -20,30 +77,37 @@ pub fn draw_title(f: &mut Frame, area: Rect, app: &App) { .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled("device sync", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)), - Span::raw(" "), Span::styled( - "StorageBox → local → player", - Style::default().fg(theme::DIM), + "StorageBox", + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), ), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("local", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("player", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)), ]; let mut right_spans = vec![ + Span::styled( + if app.skip_mirror { "mirror off" } else { "mirror on" }, + Style::default().fg(if app.skip_mirror { + theme::YELLOW + } else { + theme::DIM + }), + ), + Span::raw(" "), Span::styled( if app.is_syncing() { format!("{} syncing", app.spinner()) } else { "● ready".to_string() }, - Style::default().fg(if app.is_syncing() { theme::CYAN } else { theme::GREEN }), - ), - Span::raw(" "), - Span::styled( - truncate( - &app.config.config_file.display().to_string(), - (area.width.max(9) - 8) as usize, - ), - Style::default().fg(theme::DIM), + Style::default().fg(if app.is_syncing() { + theme::CYAN + } else { + theme::GREEN + }), ), ]; @@ -90,7 +154,7 @@ pub fn draw_rule(f: &mut Frame, area: Rect, _app: &App) { pub fn draw_sidebar(f: &mut Frame, area: Rect, app: &App) { let block = Block::default() .borders(Borders::RIGHT) - .border_style(Style::default().fg(theme::DIM)) + .border_style(Style::default().fg(theme::BORDER)) .style(Style::default().bg(theme::BG_ALT)) .title(Span::styled( " DEVICES ", @@ -177,10 +241,10 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) { }; let selected = idx == app.selected; + // Soft selection: highlight background + accent marker instead of + // flooding both rows with the full accent colour. let row_style = if selected { - Style::default() - .bg(theme::ACCENT) - .fg(theme::BG) + Style::default().bg(theme::SELECTION_BG).fg(theme::FG) } else { Style::default().bg(theme::BG_ALT).fg(theme::FG) }; @@ -189,15 +253,12 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) { // status icon let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false); if is_this { - // dark on the accent selection, bright cyan on the dark unselected row - let spinner_style = if selected { - Style::default().fg(theme::BG).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD) - }; - spans.push(Span::styled(app.spinner().to_string(), spinner_style)); + spans.push(Span::styled( + app.spinner().to_string(), + Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD), + )); } else if device.mounted { - spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN }))); + spans.push(Span::styled("●", Style::default().fg(theme::GREEN))); } else { spans.push(Span::styled("○", Style::default().fg(theme::DIM))); } @@ -207,8 +268,17 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) { let marker = if selected { "▸ " } else { " " }; let name = truncate(&device.name, (row.width as usize).saturating_sub(8)); spans.push(Span::styled( - format!("{marker}{name}"), - Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }), + marker, + Style::default() + .fg(theme::ACCENT) + .bg(if selected { theme::SELECTION_BG } else { theme::BG_ALT }) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + name, + Style::default() + .fg(theme::FG) + .add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }), )); f.render_widget(Paragraph::new(Line::from(spans)).style(row_style), *row); @@ -224,18 +294,19 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) { } else { format!(" {} · {}", device.firmware.label(), device.content_type) }; - let sub_style = if selected { - Style::default().bg(theme::ACCENT).fg(theme::BG) - } else { - Style::default().bg(theme::BG_ALT).fg(theme::DIM) - }; + let sub_style = Style::default() + .bg(if selected { theme::SELECTION_BG } else { theme::BG_ALT }) + .fg(theme::DIM); let row2 = Rect { y: row.y + 1, ..*row }; f.render_widget( - Paragraph::new(Line::from(Span::styled(truncate(&sub, row.width.max(1) as usize), sub_style))) - .style(sub_style), + Paragraph::new(Line::from(Span::styled( + truncate(&sub, row.width.max(1) as usize), + sub_style, + ))) + .style(sub_style), row2, ); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d5ba78f..879b1dd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -26,7 +26,7 @@ pub fn draw(f: &mut Frame, app: &App) { f.render_widget(Block::default().style(Style::default().bg(BG)), area); let v = Layout::vertical([ - Constraint::Length(2), // title + Constraint::Length(1), // title Constraint::Length(1), // gradient rule Constraint::Min(0), // body Constraint::Length(1), // status @@ -37,7 +37,11 @@ pub fn draw(f: &mut Frame, app: &App) { components::draw_title(f, title, app); components::draw_rule(f, rule, app); - let [side, main] = Layout::horizontal([Constraint::Length(34), Constraint::Min(0)]).areas(body); + let [side, main] = Layout::horizontal([ + Constraint::Length(if area.width < 100 { 28 } else { 34 }), + Constraint::Min(0), + ]) + .areas(body); components::draw_sidebar(f, side, app); draw_main(f, main, app); @@ -184,6 +188,138 @@ mod tests { } } + #[allow(dead_code)] + fn ui_sample_app() -> App { + use chrono::Duration as CDuration; + let now = chrono::Local::now(); + let dev = |name: &str, fw: Firmware, mounted: bool, mount: &str, ago: i64, status: &str| Device { + name: name.into(), + firmware: fw, + content_type: if name.contains("Mlove") { "hoerspiele" } else { "music" }.into(), + sync_command: "podkit sync -c main".into(), + mount_point: Some(PathBuf::from(mount)), + music_folder: "Music".into(), + preserve_fat_order: false, + mounted, + last_sync: if ago >= 0 { Some(now - CDuration::minutes(ago)) } else { None }, + last_status: status.into(), + }; + let config = Config { + config_file: PathBuf::from("~/.config/dap-sync/config.toml"), + local_music_dir: PathBuf::from("/home/sebastian/music"), + local_hoerspiele_dir: PathBuf::from("/home/sebastian/hoerspiele"), + output_dir: PathBuf::from("/home/sebastian/.local/share/dap-sync"), + storagebox_source: "storagebox:backup/masters".into(), + storagebox_hoerspiele_source: "/home/sebastian/storagebox/hoerspiele".into(), + storagebox_excludes: vec!["/Music/**".into(), ".DS_Store".into(), ".localized".into()], + devices: vec![ + dev("iPod Rockbox", Firmware::Rockbox, true, "/", 42, "ok"), + dev("Hifi Walker H2", Firmware::Rockbox, false, "/media/sebastian/ROCKBOX", 1440, "ok"), + dev("ffiom21", Firmware::Android, false, "/media/sebastian/FIIOM21", -1, ""), + dev("Sony NW-A55", Firmware::Sony, false, "/media/sebastian/SD_CARD", 2880, "error"), + dev("Mlove Player", Firmware::Mlove, false, "/media/sebastian/MLOVE", 10080, "ok"), + ], + }; + App::new(config, false).unwrap() + } + + fn dump_frame(app: &App, name: &str) { + dump_frame_sized(app, name, 120, 34); + } + + fn dump_frame_sized(app: &App, name: &str, w: u16, h: u16) { + println!("\n===== {name} ====="); + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal.draw(|f| draw(f, app)).unwrap(); + let buf = terminal.backend().buffer().clone(); + for y in 0..h { + let mut line = String::new(); + for x in 0..w { + line.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")); + } + println!("{}", line.trim_end()); + } + } + + #[test] + #[ignore = "dev helper: cargo test dump_ui -- --ignored --nocapture"] + fn dump_ui() { + use crate::engine::{LogEntry, LogLevel, StepState, StepStatus}; + + let mut app = ui_sample_app(); + app.logs.push(LogEntry { + level: LogLevel::Info, + msg: "── Starting sync for iPod Rockbox".into(), + ts: chrono::Local::now(), + }); + app.logs.push(LogEntry { + level: LogLevel::Transfer, + msg: "Artist/Album/track01.m4a".into(), + ts: chrono::Local::now(), + }); + app.logs.push(LogEntry { + level: LogLevel::Error, + msg: "rsync failed (exit 23)".into(), + ts: chrono::Local::now(), + }); + + app.tab = crate::app::Tab::Dashboard; + dump_frame(&app, "DASHBOARD"); + + app.tab = crate::app::Tab::Sync; + dump_frame(&app, "SYNC / READY"); + + { + let mut s = app.engine.state.lock().unwrap(); + s.running = true; + s.device = Some("iPod Rockbox".into()); + s.steps = vec![ + StepState { + title: "Mirror StorageBox".into(), + detail: "local library refreshed".into(), + status: StepStatus::Done, + }, + StepState { + title: "Sync iPod Rockbox".into(), + detail: "copying 12 file(s)…".into(), + status: StepStatus::Active, + }, + StepState { + title: "Finalize FAT".into(), + detail: "unmount + fatsort".into(), + status: StepStatus::Pending, + }, + ]; + s.device_prog.pct = 42; + s.device_prog.done = "1.2 GiB".into(); + s.device_prog.total = "2.9 GiB".into(); + s.device_prog.speed = "18.4 MiB/s".into(); + s.device_prog.eta = "1m 30s".into(); + s.device_prog.current = "Artist/Album/track07.flac".into(); + } + dump_frame(&app, "SYNC / RUNNING"); + + { + let mut s = app.engine.state.lock().unwrap(); + s.running = false; + s.result = Some(true); + s.summary = "Sync complete".into(); + s.steps[1].status = StepStatus::Done; + s.steps[1].detail = "device synced".into(); + s.steps[2].status = StepStatus::Done; + s.steps[2].detail = "device ejected & sorted".into(); + } + dump_frame(&app, "SYNC / FINISHED"); + + app.tab = crate::app::Tab::Logs; + dump_frame(&app, "LOGS"); + + app.tab = crate::app::Tab::Sync; + dump_frame_sized(&app, "SYNC / FINISHED 80x24", 80, 24); + app.tab = crate::app::Tab::Dashboard; + dump_frame_sized(&app, "DASHBOARD 80x24", 80, 24); + } + #[test] fn renders_live_cloud_mirror_progress() { use crate::engine::{StepState, StepStatus}; diff --git a/src/ui/tabs/dashboard.rs b/src/ui/tabs/dashboard.rs index beb1b28..69775b3 100644 --- a/src/ui/tabs/dashboard.rs +++ b/src/ui/tabs/dashboard.rs @@ -1,41 +1,51 @@ use crate::app::{relative_ago, App}; use crate::config::Firmware; use crate::disk::{disk_stats, fmt_bytes}; +use crate::ui::components::{arrow, chip, firmware_color, panel}; use crate::ui::theme; use crate::ui::truncate; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, BorderType, Paragraph}; +use ratatui::widgets::Paragraph; -fn card(title: &str) -> Block<'static> { - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme::DIM)) - .style(Style::default().bg(theme::PANEL_BG)) - .title(Span::styled( - format!(" {title} "), - Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), - )) -} - -fn kv<'a>(label: &str, value: String, value_style: Style) -> Line<'a> { +fn kv_w<'a>(label: &str, value: String, value_style: Style, label_w: usize) -> Line<'a> { Line::from(vec![ - Span::styled(format!(" {label:<14}"), Style::default().fg(theme::DIM)), + Span::styled(format!(" {label: ratatui::style::Color { - match fw { - Firmware::Rockbox => theme::MAGENTA, - Firmware::Android => theme::CYAN, - Firmware::Mlove => theme::ORANGE, - Firmware::Sony => theme::GREEN, - Firmware::Apple => theme::YELLOW, - } +/// `age` shortened for narrow cards (`42m ago` → `42m`). +fn short_ago(dt: chrono::DateTime) -> String { + relative_ago(dt).trim_end_matches(" ago").to_string() +} + +/// A `kv` line whose value is a filesystem path, marking whether it exists. +fn path_kv<'a>( + label: &str, + path: &std::path::Path, + max_value: usize, + label_w: usize, +) -> Line<'a> { + let (mark, mark_style) = if path.exists() { + ("✓", Style::default().fg(theme::GREEN)) + } else { + ("✗", Style::default().fg(theme::RED)) + }; + Line::from(vec![ + Span::styled(format!(" {label:= 19 { + let [r1, r2, ch] = Layout::vertical([ + Constraint::Min(8), + Constraint::Min(8), + Constraint::Length(3), + ]) + .areas(area); + (r1, r2, Some(ch)) + } else { + let [r1, r2] = Layout::vertical([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .areas(area); + (r1, r2, None) + }; let [left, right] = Layout::horizontal([Constraint::Percentage(46), Constraint::Percentage(54)]).areas(row1); @@ -61,33 +83,55 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { draw_library_card(f, lib, app); draw_sync_status_card(f, status, app); - draw_chain(f, chain, app, device); + if let Some(chain) = chain { + draw_chain(f, chain, app, device); + } } fn draw_device_card(f: &mut Frame, area: Rect, app: &App, device: &crate::config::Device) { - let block = card("DEVICE"); + let block = panel("DEVICE"); let inner = block.inner(area); f.render_widget(block, area); + let narrow = inner.width < 34; + let lw = if narrow { 10 } else { 14 }; + let value_w = (inner.width as usize).saturating_sub(2 + lw).max(4); + let line = |label: &str, value: String, style: Style| { + kv_w(label, truncate(&value, value_w), style, lw) + }; + let name = truncate(&device.name, inner.width.saturating_sub(6) as usize); let fw = device.firmware.label().to_string(); let fw_color = firmware_color(&device.firmware); - let status = if device.mounted { - Span::styled("● mounted", Style::default().fg(theme::GREEN)) + let (status, status_style) = if device.mounted { + ( + if narrow { "● on" } else { "● mounted" }, + Style::default().fg(theme::GREEN), + ) } else { - Span::styled("○ not mounted", Style::default().fg(theme::RED)) + ( + if narrow { "○ off" } else { "○ not mounted" }, + Style::default().fg(theme::RED), + ) }; - let last = match device.last_sync { - Some(dt) => { - if device.last_status == "ok" { - format!("{} (ok)", relative_ago(dt)) - } else { - format!("{} (error)", relative_ago(dt)) - } - } - None => "never".to_string(), + let (last, last_style) = match device.last_sync { + Some(dt) if device.last_status == "ok" => ( + format!( + "{} · ok", + if narrow { short_ago(dt) } else { relative_ago(dt) } + ), + Style::default().fg(theme::GREEN), + ), + Some(dt) => ( + format!( + "{} · error", + if narrow { short_ago(dt) } else { relative_ago(dt) } + ), + Style::default().fg(theme::RED), + ), + None => ("never".to_string(), Style::default().fg(theme::DIM)), }; let mut lines: Vec = vec![ @@ -99,156 +143,181 @@ fn draw_device_card(f: &mut Frame, area: Rect, app: &App, device: &crate::config Style::default().fg(fw_color).add_modifier(Modifier::BOLD), ), ]), - Line::raw(""), - Line::from(vec![ - Span::styled(" status", Style::default().fg(theme::DIM)), - Span::raw(" "), - status, - ]), - kv("last sync", last, Style::default().fg(theme::YELLOW)), + line("status", status.to_string(), status_style), + line("last sync", last, last_style), ]; - // mounted storage usage (total / available) + // Mounted storage usage. The compact value ("276.8 GiB free · 39%") fits + // the card width, unlike the old "459.5 GiB total · 276.8 GiB free" which + // was truncated mid-number. let storage = if device.mounted { device.mount_point.as_ref().and_then(|p| disk_stats(p)) } else { None }; - if let Some(stats) = storage { - lines.push(Line::raw("")); - lines.push(Line::from(vec![ - Span::styled(" storage", Style::default().fg(theme::DIM)), - Span::raw(" "), - Span::styled( - format!( - "{} total · {} free", - fmt_bytes(stats.total), - fmt_bytes(stats.available) - ), - Style::default().fg(theme::CYAN), - ), - ])); - } - let _ = app; - f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); - - // storage usage gauge underneath + let mut pct = 0u16; if let Some(stats) = storage { let used = stats.total.saturating_sub(stats.available); - let pct = if stats.total > 0 { - (used as f64 / stats.total as f64 * 100.0) as u16 + pct = if stats.total > 0 { + (used as f64 / stats.total as f64 * 100.0).round() as u16 } else { 0 }; - let gauge_area = Rect { - y: inner.y + inner.height.saturating_sub(1), - x: inner.x, - width: inner.width, - height: 1, + let value = if narrow { + format!("{pct}% used") + } else { + format!("{} free · {}%", fmt_bytes(stats.available), pct) }; - f.render_widget( - ratatui::widgets::Gauge::default() - .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) - .ratio((pct as f64) / 100.0) - .label(Span::styled( - format!(" {pct}% used "), - Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), - )), - gauge_area, - ); + lines.push(line("storage", value, Style::default().fg(theme::CYAN))); + } + + let _ = app; + let content_h = lines.len() as u16; + f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); + + // Usage bar on its own row, only when the card is tall enough that it + // cannot collide with the text above. + if let Some(stats) = storage { + if inner.height >= content_h + 2 { + let used = stats.total.saturating_sub(stats.available); + let ratio = if stats.total > 0 { + used as f64 / stats.total as f64 + } else { + 0.0 + }; + let bar_area = Rect { + y: inner.y + inner.height.saturating_sub(1), + x: inner.x + 1, + width: inner.width.saturating_sub(2), + height: 1, + }; + f.render_widget( + ratatui::widgets::Gauge::default() + .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) + .ratio(ratio.clamp(0.0, 1.0)) + .label(Span::styled( + format!(" {pct}% used "), + // No explicit colour: Gauge swaps fg/bg under the label so + // text stays readable over both the filled and empty bar. + Style::default().add_modifier(Modifier::BOLD), + )), + bar_area, + ); + } } } fn draw_settings_card(f: &mut Frame, area: Rect, device: &crate::config::Device) { - let block = card("PLAYER SETTINGS"); + let block = panel("PLAYER SETTINGS"); let inner = block.inner(area); f.render_widget(block, area); - let mut lines: Vec = vec![Line::raw("")]; - lines.push(kv( - "content type", + let narrow = inner.width < 34; + let lw = if narrow { 8 } else { 14 }; + let value_w = (inner.width as usize).saturating_sub(2 + lw).max(4); + let line = |label: &str, value: String, style: Style| { + kv_w(label, truncate(&value, value_w), style, lw) + }; + let (l_content, l_mount, l_folder, l_fat) = if narrow { + ("content", "mount", "folder", "fat") + } else { + ("content type", "mount point", "music folder", "fat order") + }; + + let mut lines: Vec = Vec::new(); + lines.push(line( + l_content, device.content_type.clone(), Style::default().fg(theme::FG), )); - lines.push(kv( - "mount point", - device.mount_str(), - Style::default().fg(theme::CYAN), - )); - lines.push(kv( - "music folder", + match device.mount_point.as_deref() { + Some(p) => lines.push(path_kv(l_mount, p, value_w, lw)), + None => lines.push(line(l_mount, "—".to_string(), Style::default().fg(theme::DIM))), + } + lines.push(line( + l_folder, device.music_folder.clone(), Style::default().fg(theme::CYAN), )); - lines.push(kv( - "fat order", + lines.push(line( + l_fat, if device.preserve_fat_order { "yes".into() } else { "no".into() }, Style::default().fg(if device.preserve_fat_order { theme::YELLOW } else { theme::DIM }), )); if device.firmware == Firmware::Apple { - lines.push(kv( - "sync command", - truncate(&device.sync_command, inner.width.saturating_sub(18) as usize), + lines.push(line( + if narrow { "cmd" } else { "sync command" }, + device.sync_command.clone(), Style::default().fg(theme::MAGENTA), )); } lines.push(Line::raw("")); lines.push(Line::from(vec![ + Span::raw(" "), Span::styled( - " edit ~/.config/dap-sync/config.toml", - Style::default().fg(theme::DIM), + "r", + Style::default() + .fg(theme::BG) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), ), + Span::styled(" reloads config.toml", Style::default().fg(theme::DIM)), ])); f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); } fn draw_library_card(f: &mut Frame, area: Rect, app: &App) { - let block = card("LIBRARY"); + let block = panel("LIBRARY"); let inner = block.inner(area); f.render_widget(block, area); let excludes = app.config.storagebox_excludes.len(); - let lines = vec![ + let narrow = inner.width < 34; + let lw = if narrow { 12 } else { 14 }; + let value_w = (inner.width as usize).saturating_sub(2 + lw).max(4); + let mut lines = vec![ + path_kv("music", &app.config.local_music_dir, value_w, lw), + path_kv("hoerspiele", &app.config.local_hoerspiele_dir, value_w, lw), Line::raw(""), - kv( - "music", - app.config.local_music_dir.display().to_string(), - Style::default().fg(theme::CYAN), - ), - kv( - "hoerspiele", - app.config.local_hoerspiele_dir.display().to_string(), - Style::default().fg(theme::CYAN), - ), - Line::raw(""), - kv( - "storagebox", - app.config.storagebox_source.clone(), - Style::default().fg(theme::FG), - ), - kv( - "source hsp", - app.config.storagebox_hoerspiele_source.clone(), - Style::default().fg(theme::FG), - ), - kv( - "excludes", - format!("{excludes} patterns"), - Style::default().fg(theme::DIM), - ), - Line::raw(""), - kv( - "output", - app.config.output_dir.display().to_string(), - Style::default().fg(theme::DIM), - ), ]; + let source_kv = |label: &str, source: &str, lines: &mut Vec| { + let path = std::path::Path::new(source); + if path.is_absolute() { + lines.push(path_kv(label, path, value_w, lw)); + } else { + lines.push(kv_w( + label, + truncate(source, value_w), + Style::default().fg(theme::FG), + lw, + )); + } + }; + source_kv("storagebox", &app.config.storagebox_source, &mut lines); + source_kv( + "source hsp", + &app.config.storagebox_hoerspiele_source, + &mut lines, + ); + lines.push(kv_w( + "excludes", + format!("{excludes} patterns"), + Style::default().fg(theme::DIM), + lw, + )); + lines.push(Line::raw("")); + lines.push(kv_w( + "output", + truncate(&app.config.output_dir.display().to_string(), value_w), + Style::default().fg(theme::DIM), + lw, + )); f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); } fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { - let block = card("SYNC STATUS"); + let block = panel("SYNC STATUS"); let inner = block.inner(area); f.render_widget(block, area); @@ -263,13 +332,19 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { let mut ok = 0usize; let mut err = 0usize; let mut never = 0usize; + // Narrow cards get time-only statuses so the device name keeps its space. + let compact = inner.width < 34; let mut lines: Vec = Vec::new(); for device in &app.config.devices { let is_syncing = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false); let status_span: Span = if is_syncing { Span::styled( - format!("{} syncing…", app.spinner()), + if compact { + format!("{} sync", app.spinner()) + } else { + format!("{} syncing…", app.spinner()) + }, Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD), ) } else if let Some(dt) = device.last_sync { @@ -277,13 +352,13 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { if device.last_status == "ok" { ok += 1; Span::styled( - format!("✓ ok · {ago}"), + if compact { format!("✓ {ago}") } else { format!("✓ ok · {ago}") }, Style::default().fg(theme::GREEN), ) } else { err += 1; Span::styled( - format!("✗ error · {ago}"), + if compact { format!("✗ {ago}") } else { format!("✗ error · {ago}") }, Style::default().fg(theme::RED).add_modifier(Modifier::BOLD), ) } @@ -292,7 +367,10 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { Span::styled("— never", Style::default().fg(theme::DIM)) }; - let name = truncate(&device.name, inner.width.saturating_sub(26).max(4) as usize); + let name_w = (inner.width as usize) + .saturating_sub(5 + status_span.width()) + .max(4); + let name = truncate(&device.name, name_w); let mut spans: Vec = vec![Span::raw(" "), Span::styled(name, Style::default().fg(theme::FG))]; let used: usize = spans.iter().map(|s| s.width()).sum(); let left = (inner.width as usize).saturating_sub(2 + status_span.width()); @@ -303,24 +381,23 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { lines.push(Line::from(spans)); } - // summary footer + // summary footer (two compact lines — the one-line variant used to end in + // an ugly "…" because the card is narrow) lines.push(Line::raw("")); - let summary = format!( - " {ok} ok · {err} error · {never} never · last {last}", - last = app - .config - .devices - .iter() - .filter_map(|d| d.last_sync) - .min_by_key(|dt| { - let s = now.signed_duration_since(*dt).num_seconds(); - s.max(0) - }) - .map(|dt| relative_ago(dt)) - .unwrap_or_else(|| "—".to_string()), - ); lines.push(Line::from(Span::styled( - truncate(&summary, inner.width as usize), + format!(" {ok} ok · {err} err · {never} never"), + Style::default().fg(theme::DIM), + ))); + let last = app + .config + .devices + .iter() + .filter_map(|d| d.last_sync) + .min_by_key(|dt| now.signed_duration_since(*dt).num_seconds().max(0)) + .map(relative_ago) + .unwrap_or_else(|| "—".to_string()); + lines.push(Line::from(Span::styled( + format!(" last sync {last}"), Style::default().fg(theme::DIM), ))); @@ -328,7 +405,7 @@ fn draw_sync_status_card(f: &mut Frame, area: Rect, app: &App) { } fn draw_chain(f: &mut Frame, area: Rect, app: &App, device: &crate::config::Device) { - let block = card("SYNC PATH"); + let block = panel("SYNC PATH"); let inner = block.inner(area); f.render_widget(block, area); @@ -361,19 +438,8 @@ fn draw_chain(f: &mut Frame, area: Rect, app: &App, device: &crate::config::Devi ); } -fn chip(text: &str) -> Span<'static> { - Span::styled( - format!(" [{text}]"), - Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD), - ) -} - -fn arrow(label: &str) -> Span<'static> { - Span::styled(format!(" ─{label}▶ "), Style::default().fg(theme::CYAN)) -} - fn draw_empty(f: &mut Frame, area: Rect) { - let block = card("DEVICE"); + let block = panel("DEVICE"); let inner = block.inner(area); f.render_widget(block, area); f.render_widget( diff --git a/src/ui/tabs/logs.rs b/src/ui/tabs/logs.rs index 9118cff..2f44f34 100644 --- a/src/ui/tabs/logs.rs +++ b/src/ui/tabs/logs.rs @@ -35,19 +35,44 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { } else { "follow off".to_string() }; - let head_line = Line::from(vec![ + let warns = app + .logs + .iter() + .filter(|e| matches!(e.level, LogLevel::Warn)) + .count(); + let errors = app + .logs + .iter() + .filter(|e| matches!(e.level, LogLevel::Error)) + .count(); + let mut head_spans = vec![ Span::styled( format!(" {} log entries", app.logs.len()), Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), ), Span::raw(" · "), Span::styled(follow_tag, Style::default().fg(theme::DIM)), - Span::raw(" · "), - Span::styled( - "f toggle · u/d scroll · x clear", - Style::default().fg(theme::DIM), - ), - ]); + ]; + if warns > 0 { + head_spans.push(Span::raw(" · ")); + head_spans.push(Span::styled( + format!("{warns} warning{}", if warns == 1 { "" } else { "s" }), + Style::default().fg(theme::YELLOW), + )); + } + if errors > 0 { + head_spans.push(Span::raw(" · ")); + head_spans.push(Span::styled( + format!("{errors} error{}", if errors == 1 { "" } else { "s" }), + Style::default().fg(theme::RED).add_modifier(Modifier::BOLD), + )); + } + head_spans.push(Span::raw(" · ")); + head_spans.push(Span::styled( + "f toggle · u/d scroll · x clear", + Style::default().fg(theme::DIM), + )); + let head_line = Line::from(head_spans); f.render_widget( Paragraph::new(head_line).style(Style::default().bg(theme::BG)), head, @@ -56,7 +81,7 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme::DIM)) + .border_style(Style::default().fg(theme::BORDER)) .style(Style::default().bg(theme::PANEL_BG)); let inner = block.inner(body); f.render_widget(block, body); diff --git a/src/ui/tabs/sync.rs b/src/ui/tabs/sync.rs index a9cd09a..7429b2f 100644 --- a/src/ui/tabs/sync.rs +++ b/src/ui/tabs/sync.rs @@ -1,25 +1,13 @@ use crate::app::App; use crate::engine::{StepState, StepStatus, SyncState}; -use crate::ui::components::step_icon; +use crate::ui::components::{firmware_color, key_hint, panel, step_icon}; use crate::ui::theme; use crate::ui::truncate; use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, BorderType, Gauge, Paragraph}; - -fn panel(title: &str) -> Block<'static> { - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme::DIM)) - .style(Style::default().bg(theme::PANEL_BG)) - .title(Span::styled( - format!(" {title} "), - Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD), - )) -} +use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; pub fn draw(f: &mut Frame, area: Rect, app: &App) { let state = app.engine.state.lock().unwrap().clone(); @@ -48,7 +36,11 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { ); if !state.running { - draw_idle(f, body, &state, app); + if state.steps.is_empty() { + draw_ready(f, body, app); + } else { + draw_finished(f, body, &state, app); + } return; } @@ -58,71 +50,157 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) { draw_progress(f, progress, &state); } -fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) { - let [info, hint] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(area); - - let block = panel("LAST SYNC"); - let inner = block.inner(info); - f.render_widget(block, info); - - let mut lines: Vec = Vec::new(); - if let Some(ok) = state.result { - lines.push(Line::raw("")); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - if ok { "✓" } else { "✗" }, - Style::default().fg(if ok { theme::GREEN } else { theme::RED }).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(state.summary.clone(), Style::default().fg(theme::FG)), - ])); - } else { - lines.push(Line::raw("")); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - "No sync has been run yet.", - Style::default().fg(theme::DIM), - ), - ])); - } - if let Some(name) = &state.device { - lines.push(Line::raw("")); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("Press ", Style::default().fg(theme::DIM)), - Span::styled(" s ", Style::default().fg(theme::BG).bg(theme::ACCENT).add_modifier(Modifier::BOLD)), - Span::styled(format!(" to sync {name}"), Style::default().fg(theme::DIM)), - ])); - } - f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); - - let hint_block = panel("PIPELINE"); - let hint_inner = hint_block.inner(hint); - f.render_widget(hint_block, hint); +/// Horizontally centered, vertically centered block of lines. +fn center_lines(f: &mut Frame, area: Rect, lines: Vec>) { + let pad = area.height.saturating_sub(lines.len() as u16) / 2; + let mut content: Vec = vec![Line::raw(""); pad as usize]; + content.extend(lines); f.render_widget( - Paragraph::new(vec![ - Line::from(vec![ - Span::raw(" "), - Span::styled("1 ", Style::default().fg(theme::ACCENT)), - Span::styled("rclone sync", Style::default().fg(theme::FG)), - Span::styled(" → ", Style::default().fg(theme::DIM)), - Span::styled("2 ", Style::default().fg(theme::ACCENT)), - Span::styled("NFC diff", Style::default().fg(theme::FG)), - Span::styled(" → ", Style::default().fg(theme::DIM)), - Span::styled("3 ", Style::default().fg(theme::ACCENT)), - Span::styled("rsync", Style::default().fg(theme::FG)), - Span::styled(" → ", Style::default().fg(theme::DIM)), - Span::styled("4 ", Style::default().fg(theme::ACCENT)), - Span::styled("eject / fatsort", Style::default().fg(theme::FG)), - ]), - ]) - .style(Style::default().bg(theme::PANEL_BG)), - hint_inner, + Paragraph::new(content) + .alignment(Alignment::Center) + .style(Style::default().bg(theme::PANEL_BG)), + area, ); } +/// Idle state before the first sync of this session: a hero panel for the +/// selected device plus the pipeline description. +fn draw_ready(f: &mut Frame, area: Rect, app: &App) { + let [hero, pipeline] = + Layout::vertical([Constraint::Min(0), Constraint::Length(4)]).areas(area); + + let block = panel("READY TO SYNC"); + let inner = block.inner(hero); + f.render_widget(block, hero); + + let Some(device) = app.selected_device() else { + center_lines( + f, + inner, + vec![ + Line::from(Span::styled( + "No device selected", + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Add [[devices]] entries to config.toml, then press r", + Style::default().fg(theme::DIM), + )), + ], + ); + draw_pipeline(f, pipeline); + return; + }; + + let (mount, mount_style) = if device.mounted { + ("● mounted", Style::default().fg(theme::GREEN)) + } else { + ("○ not mounted", Style::default().fg(theme::RED)) + }; + let last = match device.last_sync { + Some(dt) if device.last_status == "ok" => { + format!("{} · ok", crate::app::relative_ago(dt)) + } + Some(dt) => format!("{} · error", crate::app::relative_ago(dt)), + None => "never synced".to_string(), + }; + + let lines = vec![ + Line::from(Span::styled( + truncate(&device.name, inner.width.saturating_sub(4) as usize), + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(vec![ + Span::styled( + device.firmware.label().to_string(), + Style::default() + .fg(firmware_color(&device.firmware)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(theme::DIM)), + Span::styled(device.content_type.clone(), Style::default().fg(theme::FG)), + Span::styled(" · ", Style::default().fg(theme::DIM)), + Span::styled(mount, mount_style), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled("press", Style::default().fg(theme::DIM)), + key_hint("s"), + Span::styled("to sync this device", Style::default().fg(theme::DIM)), + ]), + Line::raw(""), + Line::from(Span::styled( + format!("last sync {last}"), + Style::default().fg(theme::DIM), + )), + ]; + center_lines(f, inner, lines); + draw_pipeline(f, pipeline); +} + +fn draw_pipeline(f: &mut Frame, area: Rect) { + let block = panel("PIPELINE"); + let inner = block.inner(area); + f.render_widget(block, area); + let line = Line::from(vec![ + Span::styled(" 1 ", Style::default().fg(theme::ACCENT)), + Span::styled("rclone sync", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("2 ", Style::default().fg(theme::ACCENT)), + Span::styled("NFC diff", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("3 ", Style::default().fg(theme::ACCENT)), + Span::styled("rsync", Style::default().fg(theme::FG)), + Span::styled(" → ", Style::default().fg(theme::DIM)), + Span::styled("4 ", Style::default().fg(theme::ACCENT)), + Span::styled("eject / fatsort", Style::default().fg(theme::FG)), + ]); + f.render_widget( + Paragraph::new(vec![Line::raw(""), line]) + .alignment(Alignment::Center) + .style(Style::default().bg(theme::PANEL_BG)), + inner, + ); +} + +/// After a sync finished: keep the final step timeline visible instead of +/// discarding it and showing only an empty "last sync" card. +fn draw_finished(f: &mut Frame, area: Rect, state: &SyncState, app: &App) { + let [result, steps] = + Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(area); + + let ok = state.result.unwrap_or(false); + let block = panel("LAST RUN"); + let inner = block.inner(result); + f.render_widget(block, result); + + let (icon, color) = if ok { + ("✓", theme::GREEN) + } else { + ("✗", theme::RED) + }; + let device = state.device.clone().unwrap_or_default(); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled( + state.summary.clone(), + Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {device}"), Style::default().fg(theme::DIM)), + ])) + .style(Style::default().bg(theme::PANEL_BG)), + inner, + ); + + draw_steps(f, steps, state, app); +} + fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) { let block = panel("STEPS"); let inner = block.inner(area); @@ -130,9 +208,23 @@ fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) { let mut lines: Vec = Vec::new(); lines.push(Line::raw("")); - for step in &state.steps { + for (i, step) in state.steps.iter().enumerate() { lines.push(step_line(step, app)); + if i + 1 < state.steps.len() { + // timeline connector between the step icons + lines.push(Line::from(Span::styled( + " │", + Style::default().fg(theme::BORDER), + ))); + } + } + if !state.running { lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::raw(" "), + key_hint("s"), + Span::styled(" run again", Style::default().fg(theme::DIM)), + ])); } f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner); } @@ -193,12 +285,14 @@ fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) { let [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner); let g = Gauge::default() - .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::DIM))) + .block(Block::default().borders(Borders::BOTTOM).border_style(Style::default().fg(theme::BORDER))) .gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT)) .ratio((pct as f64) / 100.0) .label(Span::styled( format!(" {label} · {pct}% "), - Style::default().fg(theme::FG).add_modifier(Modifier::BOLD), + // No explicit colour: Gauge swaps fg/bg under the label so text + // stays readable over both the filled and empty bar. + Style::default().add_modifier(Modifier::BOLD), )); f.render_widget(g, gauge_row); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 23a2e8a..f98bd9b 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -7,6 +7,8 @@ pub const PANEL_BG: Color = Color::Rgb(0x1f, 0x23, 0x33); pub const FG: Color = Color::Rgb(0xc0, 0xca, 0xf5); pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89); pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); +pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61); +pub const SELECTION_BG: Color = Color::Rgb(0x29, 0x2e, 0x42); pub const CYAN: Color = Color::Rgb(0x7d, 0xcf, 0xff); pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a); pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68);