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:
Generated
+1
-1
@@ -253,7 +253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dap-tui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user