design pass: responsive layout, clearer cards, better sync/log states

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.
This commit is contained in:
Sebastian
2026-09-10 19:55:11 +02:00
parent ed492e7047
commit 42a3f0c35a
10 changed files with 699 additions and 310 deletions
Generated
+1 -1
View File
@@ -253,7 +253,7 @@ dependencies = [
[[package]]
name = "dap-tui"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+5 -1
View File
@@ -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.
-9
View File
@@ -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,
+105 -34
View File
@@ -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,17 +294,18 @@ 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)))
Paragraph::new(Line::from(Span::styled(
truncate(&sub, row.width.max(1) as usize),
sub_style,
)))
.style(sub_style),
row2,
);
+138 -2
View File
@@ -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};
+216 -150
View File
@@ -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:<label_w$}"), Style::default().fg(theme::DIM)),
Span::styled(value, value_style),
])
}
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,
}
/// `age` shortened for narrow cards (`42m ago` → `42m`).
fn short_ago(dt: chrono::DateTime<chrono::Local>) -> 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:<label_w$}"), Style::default().fg(theme::DIM)),
Span::styled(format!("{mark} "), mark_style),
Span::styled(
// the mark + space take two columns of the value budget
truncate(
&path.display().to_string(),
max_value.saturating_sub(2).max(4),
),
Style::default().fg(theme::FG),
),
])
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
@@ -44,12 +54,24 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
return;
};
let [row1, row2, chain] = Layout::vertical([
Constraint::Min(9),
// Three stacked rows normally; drop the decorative SYNC PATH card when the
// terminal is too short for all cards to keep a content row.
let (row1, row2, chain) = if area.height >= 19 {
let [r1, r2, ch] = Layout::vertical([
Constraint::Min(8),
Constraint::Length(4),
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);
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<Line> = 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 {
let value = if narrow {
format!("{pct}% used")
} else {
format!("{} free · {}%", fmt_bytes(stats.available), pct)
};
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,
width: inner.width,
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((pct as f64) / 100.0)
.ratio(ratio.clamp(0.0, 1.0))
.label(Span::styled(
format!(" {pct}% used "),
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),
)),
gauge_area,
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<Line> = 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<Line> = 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(),
];
let source_kv = |label: &str, source: &str, lines: &mut Vec<Line>| {
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),
),
kv(
lw,
));
}
};
source_kv("storagebox", &app.config.storagebox_source, &mut lines);
source_kv(
"source hsp",
app.config.storagebox_hoerspiele_source.clone(),
Style::default().fg(theme::FG),
),
kv(
&app.config.storagebox_hoerspiele_source,
&mut lines,
);
lines.push(kv_w(
"excludes",
format!("{excludes} patterns"),
Style::default().fg(theme::DIM),
),
Line::raw(""),
kv(
lw,
));
lines.push(Line::raw(""));
lines.push(kv_w(
"output",
app.config.output_dir.display().to_string(),
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<Line> = 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<Span> = 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
lines.push(Line::from(Span::styled(
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| {
let s = now.signed_duration_since(*dt).num_seconds();
s.max(0)
})
.map(|dt| relative_ago(dt))
.unwrap_or_else(|| "—".to_string()),
);
.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(
truncate(&summary, inner.width as usize),
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(
+31 -6
View File
@@ -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(
];
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);
+162 -68
View File
@@ -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,54 +50,103 @@ 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<Line> = 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<Line<'static>>) {
let pad = area.height.saturating_sub(lines.len() as u16) / 2;
let mut content: Vec<Line> = vec![Line::raw(""); pad as usize];
content.extend(lines);
f.render_widget(
Paragraph::new(vec![
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::raw(" "),
Span::styled("1 ", Style::default().fg(theme::ACCENT)),
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)),
@@ -116,13 +157,50 @@ fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) {
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)),
hint_inner,
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<Line> = 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);
+2
View File
@@ -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);