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]]
|
[[package]]
|
||||||
name = "dap-tui"
|
name = "dap-tui"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dap-tui"
|
name = "dap-tui"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Device sync for portable players — TUI edition (ratatui)"
|
description = "Device sync for portable players — TUI edition (ratatui)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ living dashboard instead of a batch CLI.
|
|||||||
last-sync age per device.
|
last-sync age per device.
|
||||||
- **3 tabs** — Dashboard (per-player overview + settings, storage usage when
|
- **3 tabs** — Dashboard (per-player overview + settings, storage usage when
|
||||||
mounted, library + sync pipeline), Sync (live step pipeline + progress
|
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
|
- **Live sync engine** — streams `rclone` and `rsync` output, parses progress
|
||||||
(byte-weighted via `--info=progress2`), NFC-aware diffing for
|
(byte-weighted via `--info=progress2`), NFC-aware diffing for
|
||||||
macOS→FAT32/Android devices, optional `fatsort` finalize for Mlove devices.
|
macOS→FAT32/Android devices, optional `fatsort` finalize for Mlove devices.
|
||||||
|
|||||||
@@ -108,15 +108,6 @@ pub struct Device {
|
|||||||
pub last_status: String,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub config_file: PathBuf,
|
pub config_file: PathBuf,
|
||||||
|
|||||||
+106
-35
@@ -1,4 +1,5 @@
|
|||||||
use crate::app::{relative_ago, App, Tab};
|
use crate::app::{relative_ago, App, Tab};
|
||||||
|
use crate::config::Firmware;
|
||||||
use crate::engine::{LogLevel, StepStatus};
|
use crate::engine::{LogLevel, StepStatus};
|
||||||
use crate::ui::theme;
|
use crate::ui::theme;
|
||||||
use crate::ui::truncate;
|
use crate::ui::truncate;
|
||||||
@@ -6,7 +7,63 @@ use ratatui::Frame;
|
|||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
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 ─────────────────────────────────────────────────────────────────
|
// ── title bar ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -20,30 +77,37 @@ pub fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("device sync", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"StorageBox → local → player",
|
"StorageBox",
|
||||||
Style::default().fg(theme::DIM),
|
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![
|
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(
|
Span::styled(
|
||||||
if app.is_syncing() {
|
if app.is_syncing() {
|
||||||
format!("{} syncing", app.spinner())
|
format!("{} syncing", app.spinner())
|
||||||
} else {
|
} else {
|
||||||
"● ready".to_string()
|
"● ready".to_string()
|
||||||
},
|
},
|
||||||
Style::default().fg(if app.is_syncing() { theme::CYAN } else { theme::GREEN }),
|
Style::default().fg(if app.is_syncing() {
|
||||||
),
|
theme::CYAN
|
||||||
Span::raw(" "),
|
} else {
|
||||||
Span::styled(
|
theme::GREEN
|
||||||
truncate(
|
}),
|
||||||
&app.config.config_file.display().to_string(),
|
|
||||||
(area.width.max(9) - 8) as usize,
|
|
||||||
),
|
|
||||||
Style::default().fg(theme::DIM),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -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) {
|
pub fn draw_sidebar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::RIGHT)
|
.borders(Borders::RIGHT)
|
||||||
.border_style(Style::default().fg(theme::DIM))
|
.border_style(Style::default().fg(theme::BORDER))
|
||||||
.style(Style::default().bg(theme::BG_ALT))
|
.style(Style::default().bg(theme::BG_ALT))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" DEVICES ",
|
" DEVICES ",
|
||||||
@@ -177,10 +241,10 @@ fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
|
|||||||
};
|
};
|
||||||
let selected = idx == app.selected;
|
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 {
|
let row_style = if selected {
|
||||||
Style::default()
|
Style::default().bg(theme::SELECTION_BG).fg(theme::FG)
|
||||||
.bg(theme::ACCENT)
|
|
||||||
.fg(theme::BG)
|
|
||||||
} else {
|
} else {
|
||||||
Style::default().bg(theme::BG_ALT).fg(theme::FG)
|
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
|
// status icon
|
||||||
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
|
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
|
||||||
if is_this {
|
if is_this {
|
||||||
// dark on the accent selection, bright cyan on the dark unselected row
|
spans.push(Span::styled(
|
||||||
let spinner_style = if selected {
|
app.spinner().to_string(),
|
||||||
Style::default().fg(theme::BG).add_modifier(Modifier::BOLD)
|
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
|
||||||
} else {
|
));
|
||||||
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD)
|
|
||||||
};
|
|
||||||
spans.push(Span::styled(app.spinner().to_string(), spinner_style));
|
|
||||||
} else if device.mounted {
|
} 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 {
|
} else {
|
||||||
spans.push(Span::styled("○", Style::default().fg(theme::DIM)));
|
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 marker = if selected { "▸ " } else { " " };
|
||||||
let name = truncate(&device.name, (row.width as usize).saturating_sub(8));
|
let name = truncate(&device.name, (row.width as usize).saturating_sub(8));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("{marker}{name}"),
|
marker,
|
||||||
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
|
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);
|
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 {
|
} else {
|
||||||
format!(" {} · {}", device.firmware.label(), device.content_type)
|
format!(" {} · {}", device.firmware.label(), device.content_type)
|
||||||
};
|
};
|
||||||
let sub_style = if selected {
|
let sub_style = Style::default()
|
||||||
Style::default().bg(theme::ACCENT).fg(theme::BG)
|
.bg(if selected { theme::SELECTION_BG } else { theme::BG_ALT })
|
||||||
} else {
|
.fg(theme::DIM);
|
||||||
Style::default().bg(theme::BG_ALT).fg(theme::DIM)
|
|
||||||
};
|
|
||||||
let row2 = Rect {
|
let row2 = Rect {
|
||||||
y: row.y + 1,
|
y: row.y + 1,
|
||||||
..*row
|
..*row
|
||||||
};
|
};
|
||||||
f.render_widget(
|
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(
|
||||||
.style(sub_style),
|
truncate(&sub, row.width.max(1) as usize),
|
||||||
|
sub_style,
|
||||||
|
)))
|
||||||
|
.style(sub_style),
|
||||||
row2,
|
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);
|
f.render_widget(Block::default().style(Style::default().bg(BG)), area);
|
||||||
|
|
||||||
let v = Layout::vertical([
|
let v = Layout::vertical([
|
||||||
Constraint::Length(2), // title
|
Constraint::Length(1), // title
|
||||||
Constraint::Length(1), // gradient rule
|
Constraint::Length(1), // gradient rule
|
||||||
Constraint::Min(0), // body
|
Constraint::Min(0), // body
|
||||||
Constraint::Length(1), // status
|
Constraint::Length(1), // status
|
||||||
@@ -37,7 +37,11 @@ pub fn draw(f: &mut Frame, app: &App) {
|
|||||||
components::draw_title(f, title, app);
|
components::draw_title(f, title, app);
|
||||||
components::draw_rule(f, rule, 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);
|
components::draw_sidebar(f, side, app);
|
||||||
draw_main(f, main, 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]
|
#[test]
|
||||||
fn renders_live_cloud_mirror_progress() {
|
fn renders_live_cloud_mirror_progress() {
|
||||||
use crate::engine::{StepState, StepStatus};
|
use crate::engine::{StepState, StepStatus};
|
||||||
|
|||||||
+239
-173
@@ -1,41 +1,51 @@
|
|||||||
use crate::app::{relative_ago, App};
|
use crate::app::{relative_ago, App};
|
||||||
use crate::config::Firmware;
|
use crate::config::Firmware;
|
||||||
use crate::disk::{disk_stats, fmt_bytes};
|
use crate::disk::{disk_stats, fmt_bytes};
|
||||||
|
use crate::ui::components::{arrow, chip, firmware_color, panel};
|
||||||
use crate::ui::theme;
|
use crate::ui::theme;
|
||||||
use crate::ui::truncate;
|
use crate::ui::truncate;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
|
use ratatui::widgets::Paragraph;
|
||||||
|
|
||||||
fn card(title: &str) -> Block<'static> {
|
fn kv_w<'a>(label: &str, value: String, value_style: Style, label_w: usize) -> Line<'a> {
|
||||||
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> {
|
|
||||||
Line::from(vec![
|
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),
|
Span::styled(value, value_style),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn firmware_color(fw: &Firmware) -> ratatui::style::Color {
|
/// `age` shortened for narrow cards (`42m ago` → `42m`).
|
||||||
match fw {
|
fn short_ago(dt: chrono::DateTime<chrono::Local>) -> String {
|
||||||
Firmware::Rockbox => theme::MAGENTA,
|
relative_ago(dt).trim_end_matches(" ago").to_string()
|
||||||
Firmware::Android => theme::CYAN,
|
}
|
||||||
Firmware::Mlove => theme::ORANGE,
|
|
||||||
Firmware::Sony => theme::GREEN,
|
/// A `kv` line whose value is a filesystem path, marking whether it exists.
|
||||||
Firmware::Apple => theme::YELLOW,
|
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) {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let [row1, row2, chain] = Layout::vertical([
|
// Three stacked rows normally; drop the decorative SYNC PATH card when the
|
||||||
Constraint::Min(9),
|
// terminal is too short for all cards to keep a content row.
|
||||||
Constraint::Min(8),
|
let (row1, row2, chain) = if area.height >= 19 {
|
||||||
Constraint::Length(4),
|
let [r1, r2, ch] = Layout::vertical([
|
||||||
])
|
Constraint::Min(8),
|
||||||
.areas(area);
|
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] =
|
let [left, right] =
|
||||||
Layout::horizontal([Constraint::Percentage(46), Constraint::Percentage(54)]).areas(row1);
|
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_library_card(f, lib, app);
|
||||||
draw_sync_status_card(f, status, 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) {
|
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);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, 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 name = truncate(&device.name, inner.width.saturating_sub(6) as usize);
|
||||||
let fw = device.firmware.label().to_string();
|
let fw = device.firmware.label().to_string();
|
||||||
let fw_color = firmware_color(&device.firmware);
|
let fw_color = firmware_color(&device.firmware);
|
||||||
|
|
||||||
let status = if device.mounted {
|
let (status, status_style) = if device.mounted {
|
||||||
Span::styled("● mounted", Style::default().fg(theme::GREEN))
|
(
|
||||||
|
if narrow { "● on" } else { "● mounted" },
|
||||||
|
Style::default().fg(theme::GREEN),
|
||||||
|
)
|
||||||
} else {
|
} 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 {
|
let (last, last_style) = match device.last_sync {
|
||||||
Some(dt) => {
|
Some(dt) if device.last_status == "ok" => (
|
||||||
if device.last_status == "ok" {
|
format!(
|
||||||
format!("{} (ok)", relative_ago(dt))
|
"{} · ok",
|
||||||
} else {
|
if narrow { short_ago(dt) } else { relative_ago(dt) }
|
||||||
format!("{} (error)", relative_ago(dt))
|
),
|
||||||
}
|
Style::default().fg(theme::GREEN),
|
||||||
}
|
),
|
||||||
None => "never".to_string(),
|
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![
|
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),
|
Style::default().fg(fw_color).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
Line::raw(""),
|
line("status", status.to_string(), status_style),
|
||||||
Line::from(vec![
|
line("last sync", last, last_style),
|
||||||
Span::styled(" status", Style::default().fg(theme::DIM)),
|
|
||||||
Span::raw(" "),
|
|
||||||
status,
|
|
||||||
]),
|
|
||||||
kv("last sync", last, Style::default().fg(theme::YELLOW)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 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 {
|
let storage = if device.mounted {
|
||||||
device.mount_point.as_ref().and_then(|p| disk_stats(p))
|
device.mount_point.as_ref().and_then(|p| disk_stats(p))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(stats) = storage {
|
let mut pct = 0u16;
|
||||||
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
|
|
||||||
if let Some(stats) = storage {
|
if let Some(stats) = storage {
|
||||||
let used = stats.total.saturating_sub(stats.available);
|
let used = stats.total.saturating_sub(stats.available);
|
||||||
let pct = if stats.total > 0 {
|
pct = if stats.total > 0 {
|
||||||
(used as f64 / stats.total as f64 * 100.0) as u16
|
(used as f64 / stats.total as f64 * 100.0).round() as u16
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let gauge_area = Rect {
|
let value = if narrow {
|
||||||
y: inner.y + inner.height.saturating_sub(1),
|
format!("{pct}% used")
|
||||||
x: inner.x,
|
} else {
|
||||||
width: inner.width,
|
format!("{} free · {}%", fmt_bytes(stats.available), pct)
|
||||||
height: 1,
|
|
||||||
};
|
};
|
||||||
f.render_widget(
|
lines.push(line("storage", value, Style::default().fg(theme::CYAN)));
|
||||||
ratatui::widgets::Gauge::default()
|
}
|
||||||
.gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT))
|
|
||||||
.ratio((pct as f64) / 100.0)
|
let _ = app;
|
||||||
.label(Span::styled(
|
let content_h = lines.len() as u16;
|
||||||
format!(" {pct}% used "),
|
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
|
||||||
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
|
|
||||||
)),
|
// Usage bar on its own row, only when the card is tall enough that it
|
||||||
gauge_area,
|
// 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) {
|
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);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
let mut lines: Vec<Line> = vec![Line::raw("")];
|
let narrow = inner.width < 34;
|
||||||
lines.push(kv(
|
let lw = if narrow { 8 } else { 14 };
|
||||||
"content type",
|
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(),
|
device.content_type.clone(),
|
||||||
Style::default().fg(theme::FG),
|
Style::default().fg(theme::FG),
|
||||||
));
|
));
|
||||||
lines.push(kv(
|
match device.mount_point.as_deref() {
|
||||||
"mount point",
|
Some(p) => lines.push(path_kv(l_mount, p, value_w, lw)),
|
||||||
device.mount_str(),
|
None => lines.push(line(l_mount, "—".to_string(), Style::default().fg(theme::DIM))),
|
||||||
Style::default().fg(theme::CYAN),
|
}
|
||||||
));
|
lines.push(line(
|
||||||
lines.push(kv(
|
l_folder,
|
||||||
"music folder",
|
|
||||||
device.music_folder.clone(),
|
device.music_folder.clone(),
|
||||||
Style::default().fg(theme::CYAN),
|
Style::default().fg(theme::CYAN),
|
||||||
));
|
));
|
||||||
lines.push(kv(
|
lines.push(line(
|
||||||
"fat order",
|
l_fat,
|
||||||
if device.preserve_fat_order { "yes".into() } else { "no".into() },
|
if device.preserve_fat_order { "yes".into() } else { "no".into() },
|
||||||
Style::default().fg(if device.preserve_fat_order { theme::YELLOW } else { theme::DIM }),
|
Style::default().fg(if device.preserve_fat_order { theme::YELLOW } else { theme::DIM }),
|
||||||
));
|
));
|
||||||
if device.firmware == Firmware::Apple {
|
if device.firmware == Firmware::Apple {
|
||||||
lines.push(kv(
|
lines.push(line(
|
||||||
"sync command",
|
if narrow { "cmd" } else { "sync command" },
|
||||||
truncate(&device.sync_command, inner.width.saturating_sub(18) as usize),
|
device.sync_command.clone(),
|
||||||
Style::default().fg(theme::MAGENTA),
|
Style::default().fg(theme::MAGENTA),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
lines.push(Line::raw(""));
|
lines.push(Line::raw(""));
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
" edit ~/.config/dap-sync/config.toml",
|
"r",
|
||||||
Style::default().fg(theme::DIM),
|
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);
|
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) {
|
fn draw_library_card(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let block = card("LIBRARY");
|
let block = panel("LIBRARY");
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
let excludes = app.config.storagebox_excludes.len();
|
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(""),
|
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<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),
|
||||||
|
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);
|
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) {
|
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);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, 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 ok = 0usize;
|
||||||
let mut err = 0usize;
|
let mut err = 0usize;
|
||||||
let mut never = 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();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
for device in &app.config.devices {
|
for device in &app.config.devices {
|
||||||
let is_syncing = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
|
let is_syncing = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
|
||||||
let status_span: Span = if is_syncing {
|
let status_span: Span = if is_syncing {
|
||||||
Span::styled(
|
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),
|
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
} else if let Some(dt) = device.last_sync {
|
} 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" {
|
if device.last_status == "ok" {
|
||||||
ok += 1;
|
ok += 1;
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("✓ ok · {ago}"),
|
if compact { format!("✓ {ago}") } else { format!("✓ ok · {ago}") },
|
||||||
Style::default().fg(theme::GREEN),
|
Style::default().fg(theme::GREEN),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
err += 1;
|
err += 1;
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("✗ error · {ago}"),
|
if compact { format!("✗ {ago}") } else { format!("✗ error · {ago}") },
|
||||||
Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
|
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))
|
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 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 used: usize = spans.iter().map(|s| s.width()).sum();
|
||||||
let left = (inner.width as usize).saturating_sub(2 + status_span.width());
|
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));
|
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(""));
|
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(
|
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),
|
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) {
|
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);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, 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) {
|
fn draw_empty(f: &mut Frame, area: Rect) {
|
||||||
let block = card("DEVICE");
|
let block = panel("DEVICE");
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
|
|||||||
+33
-8
@@ -35,19 +35,44 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
} else {
|
} else {
|
||||||
"follow off".to_string()
|
"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(
|
Span::styled(
|
||||||
format!(" {} log entries", app.logs.len()),
|
format!(" {} log entries", app.logs.len()),
|
||||||
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw(" · "),
|
Span::raw(" · "),
|
||||||
Span::styled(follow_tag, Style::default().fg(theme::DIM)),
|
Span::styled(follow_tag, Style::default().fg(theme::DIM)),
|
||||||
Span::raw(" · "),
|
];
|
||||||
Span::styled(
|
if warns > 0 {
|
||||||
"f toggle · u/d scroll · x clear",
|
head_spans.push(Span::raw(" · "));
|
||||||
Style::default().fg(theme::DIM),
|
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(
|
f.render_widget(
|
||||||
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
|
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
|
||||||
head,
|
head,
|
||||||
@@ -56,7 +81,7 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.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));
|
.style(Style::default().bg(theme::PANEL_BG));
|
||||||
let inner = block.inner(body);
|
let inner = block.inner(body);
|
||||||
f.render_widget(block, body);
|
f.render_widget(block, body);
|
||||||
|
|||||||
+174
-80
@@ -1,25 +1,13 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::engine::{StepState, StepStatus, SyncState};
|
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::theme;
|
||||||
use crate::ui::truncate;
|
use crate::ui::truncate;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, BorderType, Gauge, Paragraph};
|
use ratatui::widgets::{Block, Borders, 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),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let state = app.engine.state.lock().unwrap().clone();
|
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 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,71 +50,157 @@ pub fn draw(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
draw_progress(f, progress, &state);
|
draw_progress(f, progress, &state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_idle(f: &mut Frame, area: Rect, state: &SyncState, _app: &App) {
|
/// Horizontally centered, vertically centered block of lines.
|
||||||
let [info, hint] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(area);
|
fn center_lines(f: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
|
||||||
|
let pad = area.height.saturating_sub(lines.len() as u16) / 2;
|
||||||
let block = panel("LAST SYNC");
|
let mut content: Vec<Line> = vec![Line::raw(""); pad as usize];
|
||||||
let inner = block.inner(info);
|
content.extend(lines);
|
||||||
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);
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(vec![
|
Paragraph::new(content)
|
||||||
Line::from(vec![
|
.alignment(Alignment::Center)
|
||||||
Span::raw(" "),
|
.style(Style::default().bg(theme::PANEL_BG)),
|
||||||
Span::styled("1 ", Style::default().fg(theme::ACCENT)),
|
area,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) {
|
||||||
let block = panel("STEPS");
|
let block = panel("STEPS");
|
||||||
let inner = block.inner(area);
|
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();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
lines.push(Line::raw(""));
|
lines.push(Line::raw(""));
|
||||||
for step in &state.steps {
|
for (i, step) in state.steps.iter().enumerate() {
|
||||||
lines.push(step_line(step, app));
|
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::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);
|
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 [gauge_row, stats] = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
|
||||||
|
|
||||||
let g = Gauge::default()
|
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))
|
.gauge_style(Style::default().fg(theme::ACCENT).bg(theme::BG_ALT))
|
||||||
.ratio((pct as f64) / 100.0)
|
.ratio((pct as f64) / 100.0)
|
||||||
.label(Span::styled(
|
.label(Span::styled(
|
||||||
format!(" {label} · {pct}% "),
|
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);
|
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 FG: Color = Color::Rgb(0xc0, 0xca, 0xf5);
|
||||||
pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
pub const DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
||||||
pub const ACCENT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
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 CYAN: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||||
pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
pub const GREEN: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||||
pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
pub const YELLOW: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
||||||
|
|||||||
Reference in New Issue
Block a user