dap-tui: ratatui device-sync dashboard

Full rewrite of dap-sync as a Rust TUI on ratatui:
- Tokyo Night theme, gradient title rule, rounded panels
- Devices sidebar with live mount status + last-sync age
- Dashboard / Sync / Logs / Config tabs
- Live sync engine: rclone mirror, NFC-aware diff, rsync
  progress parsing, podkit + mlove (fatsort) finalize
- Abort-safe subprocess handling, persisted sync state
This commit is contained in:
Sebastian
2026-08-22 09:26:15 +02:00
commit 16a5a4327e
18 changed files with 4972 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
use crate::app::{relative_ago, App, Tab};
use crate::engine::{LogLevel, StepStatus};
use crate::ui::theme;
use crate::ui::truncate;
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};
// ── title bar ─────────────────────────────────────────────────────────────────
pub fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![
Span::styled(
" dap-tui ",
Style::default()
.fg(theme::BG)
.bg(theme::ACCENT)
.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),
),
];
let mut right_spans = vec![
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),
),
];
// pad to full width
let left_w: usize = spans.iter().map(|s| s.width()).sum();
let right_w: usize = right_spans.iter().map(|s| s.width()).sum();
let total = area.width as usize;
let pad = total.saturating_sub(left_w + right_w);
spans.push(Span::raw(" ".repeat(pad)));
spans.append(&mut right_spans);
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_ALT)),
area,
);
}
// ── gradient rule ─────────────────────────────────────────────────────────────
pub fn draw_rule(f: &mut Frame, area: Rect, _app: &App) {
let mut spans = Vec::with_capacity(area.width as usize);
let colors = [theme::ACCENT, theme::CYAN, theme::MAGENTA, theme::DIM];
for i in 0..area.width {
let t = i as f32 / area.width.max(1) as f32;
let idx = if t < 0.25 {
0
} else if t < 0.5 {
1
} else if t < 0.75 {
2
} else {
3
};
spans.push(Span::styled("─", Style::default().fg(colors[idx])));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
area,
);
}
// ── sidebar ───────────────────────────────────────────────────────────────────
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))
.style(Style::default().bg(theme::BG_ALT))
.title(Span::styled(
" DEVICES ",
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
f.render_widget(block, area);
let [head, list, foot] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
.areas(inner);
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
let syncing = app.engine.state.lock().map(|s| s.running).unwrap_or(false);
let head_line = Line::from(vec![
Span::styled(
format!("{} devices", app.config.devices.len()),
Style::default().fg(theme::FG),
),
Span::raw(" · "),
Span::styled(
format!("{mounted} mounted"),
Style::default().fg(if mounted > 0 { theme::GREEN } else { theme::DIM }),
),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG_ALT)),
head,
);
draw_device_list(f, list, app, syncing);
let foot_line = Line::from(vec![
Span::styled("s sync", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
Span::styled(" · ", Style::default().fg(theme::DIM)),
Span::styled("r refresh", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
if app.skip_mirror {
Span::styled(" · mirror off", Style::default().fg(theme::YELLOW))
} else {
Span::styled("", Style::default())
},
]);
f.render_widget(
Paragraph::new(foot_line).style(Style::default().bg(theme::BG_ALT)),
foot,
);
}
fn draw_device_list(f: &mut Frame, area: Rect, app: &App, syncing: bool) {
if app.config.devices.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" no devices configured",
Style::default().fg(theme::DIM),
)))
.style(Style::default().bg(theme::BG_ALT)),
area,
);
return;
}
let item_h: u16 = 2;
let visible = (area.height / item_h) as usize;
let start = app.sidebar_scroll.min(app.config.devices.len());
let rows = Layout::vertical(
(0..visible)
.map(|_| Constraint::Length(item_h))
.collect::<Vec<_>>(),
)
.split(area);
let sync_device = if syncing {
app.engine.state.lock().unwrap().device.clone()
} else {
None
};
for (i, row) in rows.iter().enumerate() {
let idx = start + i;
let Some(device) = app.config.devices.get(idx) else {
continue;
};
let selected = idx == app.selected;
let row_style = if selected {
Style::default()
.bg(theme::ACCENT)
.fg(theme::BG)
} else {
Style::default().bg(theme::BG_ALT).fg(theme::FG)
};
let mut spans: Vec<Span> = Vec::new();
// status icon
let is_this = sync_device.as_ref().map(|d| d == &device.name).unwrap_or(false);
if is_this {
spans.push(Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::BG),
));
} else if device.mounted {
spans.push(Span::styled("●", Style::default().fg(if selected { theme::BG } else { theme::GREEN })));
} else {
spans.push(Span::styled("○", Style::default().fg(theme::DIM)));
}
spans.push(Span::raw(" "));
// selection marker + name
let marker = if selected { "▸ " } else { " " };
let name = truncate(&device.name, row.width.max(1) as usize - 8);
spans.push(Span::styled(
format!("{marker}{name}"),
Style::default().add_modifier(if selected { Modifier::BOLD } else { Modifier::default() }),
));
f.render_widget(Paragraph::new(Line::from(spans)).style(row_style), *row);
// second line
let sub = if let Some(last) = device.last_sync {
format!(
" {} · {} · {}",
device.firmware.label(),
device.content_type,
relative_ago(last)
)
} 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 row2 = Rect {
y: row.y + 1,
..*row
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(truncate(&sub, row.width.max(1) as usize), sub_style)))
.style(sub_style),
row2,
);
}
}
// ── tab bar ───────────────────────────────────────────────────────────────────
pub fn draw_tabbar(f: &mut Frame, area: Rect, app: &App) {
let mut spans: Vec<Span> = Vec::new();
for (i, tab) in Tab::ALL.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let active = app.tab == *tab;
let syncing_badge = *tab == Tab::Sync && app.is_syncing();
let text = format!(" {} {} ", tab.number(), tab.label());
if active {
spans.push(Span::styled(
text,
Style::default()
.bg(theme::ACCENT)
.fg(theme::BG)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
text,
Style::default().bg(theme::BG_ALT).fg(theme::DIM),
));
}
if syncing_badge {
spans.push(Span::raw(" "));
spans.push(Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::CYAN),
));
}
}
let rest: u16 = area
.width
.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
if rest > 0 {
spans.push(Span::raw(" ".repeat(rest as usize)));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG)),
area,
);
}
// ── status bar ────────────────────────────────────────────────────────────────
pub fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let (msg, style) = status_text(app);
let left_spans: Vec<Span> = vec![
Span::styled(" ", Style::default()),
Span::styled(msg, style),
];
let mounted = app.config.devices.iter().filter(|d| d.mounted).count();
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let right_spans = vec![
Span::styled(
format!("{} devices · {mounted} mounted", app.config.devices.len()),
Style::default().fg(theme::DIM),
),
Span::raw(" "),
Span::styled(
format!("v{} · {now}", crate::app::VERSION),
Style::default().fg(theme::DIM),
),
];
let total = area.width as usize;
let lw: usize = left_spans.iter().map(|s| s.width()).sum();
let rw: usize = right_spans.iter().map(|s| s.width()).sum();
let pad = total.saturating_sub(lw + rw);
let mut all = left_spans;
all.push(Span::raw(" ".repeat(pad)));
all.extend(right_spans);
f.render_widget(
Paragraph::new(Line::from(all)).style(Style::default().bg(theme::BG_ALT)),
area,
);
}
fn status_text(app: &App) -> (String, Style) {
if let Some((msg, level)) = &app.status_msg {
let style = match level {
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
LogLevel::Warn => Style::default().fg(theme::YELLOW),
_ => Style::default().fg(theme::GREEN),
};
return (format!(" {msg}"), style);
}
if app.is_syncing() {
let dev = app
.engine
.state
.lock()
.unwrap()
.device
.clone()
.unwrap_or_default();
return (
format!(" {} Syncing {dev}…", app.spinner()),
Style::default().fg(theme::CYAN),
);
}
(" Ready".to_string(), Style::default().fg(theme::DIM))
}
// ── key bar ───────────────────────────────────────────────────────────────────
pub fn draw_keys(f: &mut Frame, area: Rect, _app: &App) {
let keys: &[(&str, &str)] = &[
("↑↓/jk", "nav"),
("s", "sync"),
("a", "abort"),
("Tab/1-4", "tab"),
("r", "refresh"),
("f", "follow"),
("?", "help"),
("q", "quit"),
];
let mut spans: Vec<Span> = Vec::new();
let mut first = true;
for (key, label) in keys {
if !first {
spans.push(Span::styled(" ", Style::default().fg(theme::DIM)));
}
first = false;
spans.push(Span::styled(
format!(" {key} "),
Style::default().fg(theme::ACCENT).bg(theme::BG_ALT).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(*label, Style::default().fg(theme::DIM)));
}
let rest = area.width.saturating_sub(spans.iter().map(|s| s.width() as u16).sum());
if rest > 0 {
spans.push(Span::raw(" ".repeat(rest as usize)));
}
f.render_widget(
Paragraph::new(Line::from(spans))
.style(Style::default().bg(theme::BG))
.alignment(Alignment::Left),
area,
);
}
// ── shared helpers ────────────────────────────────────────────────────────────
pub fn step_icon(status: StepStatus, app: &App) -> Span<'static> {
match status {
StepStatus::Done => Span::styled("●", Style::default().fg(theme::GREEN)),
StepStatus::Active => Span::styled(
app.spinner().to_string(),
Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD),
),
StepStatus::Pending => Span::styled("○", Style::default().fg(theme::DIM)),
StepStatus::Error => Span::styled("✗", Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)),
}
}
+108
View File
@@ -0,0 +1,108 @@
pub mod components;
pub mod tabs;
pub mod theme;
use crate::app::App;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::widgets::{Block, Borders, Clear};
use theme::{BG, DIM};
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
f.render_widget(Block::default().style(Style::default().bg(BG)), area);
let v = Layout::vertical([
Constraint::Length(2), // title
Constraint::Length(1), // gradient rule
Constraint::Min(0), // body
Constraint::Length(1), // status
Constraint::Length(1), // keys
]);
let [title, rule, body, status, keys] = v.areas(area);
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);
components::draw_sidebar(f, side, app);
draw_main(f, main, app);
components::draw_status(f, status, app);
components::draw_keys(f, keys, app);
if app.show_help {
draw_help(f);
}
}
fn draw_main(f: &mut Frame, area: Rect, app: &App) {
let [tabbar, content] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
components::draw_tabbar(f, tabbar, app);
match app.tab {
crate::app::Tab::Dashboard => tabs::dashboard::draw(f, content, app),
crate::app::Tab::Sync => tabs::sync::draw(f, content, app),
crate::app::Tab::Logs => tabs::logs::draw(f, content, app),
crate::app::Tab::Config => tabs::config_view::draw(f, content, app),
}
}
fn draw_help(f: &mut Frame) {
let area = f.area();
let box_w = 58;
let box_h = 16;
let x = (area.width.saturating_sub(box_w)) / 2;
let y = (area.height.saturating_sub(box_h)) / 2;
let rect = Rect::new(
x.max(0),
y.max(0),
box_w.min(area.width),
box_h.min(area.height),
);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::ui::theme::ACCENT))
.style(Style::default().bg(crate::ui::theme::PANEL_BG))
.title(" KEYBINDINGS ".cyan().bold());
let lines = [
" ↑↓ / j k move selection",
" s / Enter sync selected device",
" a abort running sync",
" Tab / 1-4 switch tab",
" r refresh devices",
" f toggle log follow",
" u / d scroll logs",
" x clear logs",
" ? / h this help",
" q / Esc quit",
"",
" ← → (todo: device nav)",
];
let mut spans = Vec::new();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
spans.push(ratatui::text::Line::raw(""));
}
let styled = if line.contains('/') {
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(crate::ui::theme::FG))
} else {
ratatui::text::Span::styled(*line, ratatui::style::Style::default().fg(DIM))
};
spans.push(ratatui::text::Line::from(styled));
}
f.render_widget(ratatui::widgets::Paragraph::new(ratatui::text::Text::from(spans)).block(block), rect);
}
+84
View File
@@ -0,0 +1,84 @@
use crate::app::App;
use crate::ui::theme;
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};
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let path = app.config.config_file.display().to_string();
let head_line = Line::from(vec![
Span::styled(" config", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(path.clone(), Style::default().fg(theme::DIM)),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
head,
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG));
let inner = block.inner(body);
f.render_widget(block, body);
let lines: Vec<Line> = app
.config
.raw_text
.lines()
.map(syntax_line)
.collect();
let content_h = inner.height.saturating_sub(1) as usize;
let offset = lines.len().saturating_sub(content_h);
f.render_widget(
Paragraph::new(lines)
.scroll((offset as u16, 0))
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
fn syntax_line(raw: &str) -> Line<'static> {
let line = raw;
let trimmed = line.trim_start();
if trimmed.is_empty() {
return Line::raw("");
}
if trimmed.starts_with('#') {
return Line::from(Span::styled(line.to_string(), Style::default().fg(theme::DIM)));
}
if trimmed.starts_with('[') {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme::MAGENTA).add_modifier(Modifier::BOLD),
));
}
if let Some(idx) = line.find('=') {
let key = line[..idx].trim_end();
let value = line[idx + 1..].trim_start();
let value_style = if value.starts_with('"') {
Style::default().fg(theme::GREEN)
} else if value.starts_with('[') {
Style::default().fg(theme::CYAN)
} else if value.starts_with("true") || value.starts_with("false") {
Style::default().fg(theme::YELLOW)
} else {
Style::default().fg(theme::ORANGE)
};
return Line::from(vec![
Span::styled(format!("{key}"), Style::default().fg(theme::ACCENT)),
Span::styled(" = ", Style::default().fg(theme::DIM)),
Span::styled(value.to_string(), value_style),
]);
}
Line::from(Span::styled(line.to_string(), Style::default().fg(theme::FG)))
}
+213
View File
@@ -0,0 +1,213 @@
use crate::app::{relative_ago, App};
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};
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> {
Line::from(vec![
Span::styled(
format!(" {label:<12}"),
Style::default().fg(theme::DIM),
),
Span::styled(value, value_style),
])
}
fn firmware_badge(fw: &str) -> Span<'static> {
let color = match fw {
"rockbox" => theme::MAGENTA,
"android" => theme::CYAN,
"mlove" => theme::ORANGE,
_ => theme::GREEN,
};
Span::styled(
format!(" [{fw}] "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let Some(device) = app.selected_device() else {
draw_empty(f, area);
return;
};
let [top, chain] = Layout::vertical([Constraint::Min(8), Constraint::Length(4)]).areas(area);
let [left, right] =
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(top);
// ── device card ──
let dev_block = card("DEVICE");
let inner = dev_block.inner(left);
f.render_widget(dev_block, left);
let status = if device.mounted {
Span::styled("● mounted", Style::default().fg(theme::GREEN))
} else {
Span::styled("○ not mounted", Style::default().fg(theme::RED))
};
let fw = device.firmware.label().to_string();
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 lines = vec![
Line::from(vec![
Span::raw(" "),
Span::styled(
truncate(&device.name, inner.width.saturating_sub(4) as usize),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
),
firmware_badge(&fw),
]),
Line::raw(""),
Line::from(vec![
Span::styled(" status", Style::default().fg(theme::DIM)),
Span::raw(" "),
status.clone(),
]),
kv("firmware", fw.clone(), Style::default().fg(theme::FG)),
kv("content", device.content_type.clone(), Style::default().fg(theme::FG)),
kv("mount", device.mount_str(), Style::default().fg(theme::CYAN)),
kv("folder", device.music_folder.clone(), Style::default().fg(theme::CYAN)),
kv("last sync", last, Style::default().fg(theme::YELLOW)),
];
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
// ── library card ──
let lib_block = card("LIBRARY");
let inner = lib_block.inner(right);
f.render_widget(lib_block, right);
let excludes = app.config.storagebox_excludes.len();
let lib_lines = vec![
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),
),
];
f.render_widget(Paragraph::new(lib_lines).style(Style::default().bg(theme::PANEL_BG)), inner);
// ── source chain ──
draw_chain(f, chain, app, device.name.clone());
}
fn draw_chain(f: &mut Frame, area: Rect, app: &App, device_name: String) {
let block = card("SYNC PATH");
let inner = block.inner(area);
f.render_widget(block, area);
let w = inner.width.saturating_sub(2) as usize;
// reserve: indent(2) + 2×arrow(20) + 3×chip brackets(9)
let avail = w.saturating_sub(31);
let seg1 = avail * 40 / 100;
let seg2 = avail * 30 / 100;
let seg3 = avail.saturating_sub(seg1 + seg2);
let src = truncate(&app.config.storagebox_source, seg1.max(3));
let local = truncate(&app.config.local_music_dir.display().to_string(), seg2.max(3));
let device = truncate(&device_name, seg3.max(3));
let mut line = vec![
Span::raw(" "),
chip(&src),
arrow("rclone"),
chip(&local),
arrow("rsync"),
chip(&device),
];
let used: usize = line.iter().map(|s| s.width()).sum();
if used < inner.width as usize {
line.push(Span::raw(" ".repeat(inner.width as usize - used)));
}
f.render_widget(
Paragraph::new(Line::from(line)).style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
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 inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::raw(""),
Line::from(Span::styled(
" No devices configured.",
Style::default().fg(theme::YELLOW),
)),
Line::from(Span::styled(
" Add [[devices]] entries to your config.toml",
Style::default().fg(theme::DIM),
)),
])
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
+91
View File
@@ -0,0 +1,91 @@
use crate::app::App;
use crate::engine::LogLevel;
use crate::ui::theme;
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};
fn level_style(level: LogLevel) -> Style {
match level {
LogLevel::Info => Style::default().fg(theme::FG),
LogLevel::Transfer => Style::default().fg(theme::CYAN),
LogLevel::Warn => Style::default().fg(theme::YELLOW),
LogLevel::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
LogLevel::Success => Style::default().fg(theme::GREEN),
}
}
fn level_tag(level: LogLevel) -> &'static str {
match level {
LogLevel::Info => "INF",
LogLevel::Transfer => "XFER",
LogLevel::Warn => "WRN",
LogLevel::Error => "ERR",
LogLevel::Success => "OK",
}
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let follow_tag = if app.log_follow {
"follow on".to_string()
} else {
"follow off".to_string()
};
let head_line = Line::from(vec![
Span::styled(
format!(" {} log entries", app.logs.len()),
Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD),
),
Span::raw(" · "),
Span::styled(follow_tag, Style::default().fg(theme::DIM)),
Span::raw(" · "),
Span::styled(
"f toggle · u/d scroll · x clear",
Style::default().fg(theme::DIM),
),
]);
f.render_widget(
Paragraph::new(head_line).style(Style::default().bg(theme::BG)),
head,
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::DIM))
.style(Style::default().bg(theme::PANEL_BG));
let inner = block.inner(body);
f.render_widget(block, body);
let lines: Vec<Line> = app
.logs
.iter()
.map(|e| {
let ts = e.ts.format("%H:%M:%S").to_string();
Line::from(vec![
Span::styled(format!(" {ts} "), Style::default().fg(theme::DIM)),
Span::styled(format!("{:>4}", level_tag(e.level)), level_style(e.level)),
Span::raw(" "),
Span::styled(e.msg.clone(), level_style(e.level)),
])
})
.collect();
let content_h = inner.height.saturating_sub(1) as usize;
let offset = if app.log_follow {
lines.len().saturating_sub(content_h)
} else {
app.log_scroll.min(lines.len().saturating_sub(content_h))
};
f.render_widget(
Paragraph::new(lines)
.scroll((offset as u16, 0))
.style(Style::default().bg(theme::PANEL_BG)),
inner,
);
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config_view;
pub mod dashboard;
pub mod logs;
pub mod sync;
+246
View File
@@ -0,0 +1,246 @@
use crate::app::App;
use crate::engine::{StepState, StepStatus, SyncState};
use crate::ui::components::step_icon;
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, 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) {
let state = app.engine.state.lock().unwrap().clone();
let header = match (&state.device, state.running) {
(Some(name), true) => {
if state.aborting {
format!(" Aborting sync → {name}")
} else {
format!(" Syncing → {name}")
}
}
(Some(name), false) => format!(" Ready to sync → {name}"),
_ => " Sync".to_string(),
};
let [head, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
header,
Style::default()
.fg(theme::FG)
.add_modifier(Modifier::BOLD),
)))
.style(Style::default().bg(theme::BG)),
head,
);
if !state.running {
draw_idle(f, body, &state, app);
return;
}
let [steps, progress] =
Layout::vertical([Constraint::Min(6), Constraint::Length(6)]).areas(body);
draw_steps(f, steps, &state, 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);
f.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::raw(" "),
Span::styled("1 ", Style::default().fg(theme::ACCENT)),
Span::styled("rclone sync", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("2 ", Style::default().fg(theme::ACCENT)),
Span::styled("NFC diff", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("3 ", Style::default().fg(theme::ACCENT)),
Span::styled("rsync", Style::default().fg(theme::FG)),
Span::styled(" → ", Style::default().fg(theme::DIM)),
Span::styled("4 ", Style::default().fg(theme::ACCENT)),
Span::styled("eject / fatsort", Style::default().fg(theme::FG)),
]),
])
.style(Style::default().bg(theme::PANEL_BG)),
hint_inner,
);
}
fn draw_steps(f: &mut Frame, area: Rect, state: &SyncState, app: &App) {
let block = panel("STEPS");
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::raw(""));
for step in &state.steps {
lines.push(step_line(step, app));
lines.push(Line::raw(""));
}
f.render_widget(Paragraph::new(lines).style(Style::default().bg(theme::PANEL_BG)), inner);
}
fn step_line(step: &StepState, app: &App) -> Line<'static> {
let icon = step_icon(step.status, app);
let title_style = match step.status {
StepStatus::Active => Style::default().fg(theme::FG).add_modifier(Modifier::BOLD),
StepStatus::Done => Style::default().fg(theme::GREEN),
StepStatus::Error => Style::default().fg(theme::RED),
StepStatus::Pending => Style::default().fg(theme::DIM),
};
Line::from(vec![
Span::raw(" "),
icon,
Span::raw(" "),
Span::styled(step.title.clone(), title_style),
Span::raw(" "),
Span::styled(step.detail.clone(), Style::default().fg(theme::DIM)),
])
}
fn draw_progress(f: &mut Frame, area: Rect, state: &SyncState) {
let block = panel("PROGRESS");
let inner = block.inner(area);
f.render_widget(block, area);
let active = state
.steps
.iter()
.position(|s| s.status == StepStatus::Active)
.unwrap_or(usize::MAX);
let (pct, done, total, speed, eta, current, label) = if active == 0 {
(
state.mirror.pct,
state.mirror.done.clone(),
state.mirror.total.clone(),
state.mirror.speed.clone(),
state.mirror.eta.clone(),
state.mirror.current.clone(),
"Mirroring local library",
)
} else if active == 1 {
(
state.device_prog.pct,
state.device_prog.done.clone(),
state.device_prog.total.clone(),
state.device_prog.speed.clone(),
state.device_prog.eta.clone(),
state.device_prog.current.clone(),
"Syncing device",
)
} else {
(0, String::new(), String::new(), String::new(), String::new(), String::new(), "Finalizing")
};
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)))
.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),
));
f.render_widget(g, gauge_row);
let mut lines: Vec<Line> = Vec::new();
if !done.is_empty() || !total.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
if done.is_empty() { "…".to_string() } else { done.clone() },
Style::default().fg(theme::CYAN),
),
Span::styled(" / ", Style::default().fg(theme::DIM)),
Span::styled(
if total.is_empty() { "…".to_string() } else { total },
Style::default().fg(theme::CYAN),
),
]));
}
let mut right: Vec<Span> = Vec::new();
if !speed.is_empty() {
right.push(Span::styled(speed, Style::default().fg(theme::YELLOW)));
}
if !eta.is_empty() {
right.push(Span::styled(" ETA ", Style::default().fg(theme::DIM)));
right.push(Span::styled(eta, Style::default().fg(theme::YELLOW)));
}
if !current.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
truncate(&current, inner.width.saturating_sub(2) as usize),
Style::default().fg(theme::DIM),
),
]));
} else {
lines.push(Line::raw(""));
}
let mut stats_line = lines;
stats_line.push(Line::from(
std::iter::once(Span::raw(" "))
.chain(right.into_iter())
.collect::<Vec<_>>(),
));
f.render_widget(Paragraph::new(stats_line).style(Style::default().bg(theme::PANEL_BG)), stats);
}
+15
View File
@@ -0,0 +1,15 @@
use ratatui::style::Color;
// Tokyo Night palette
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
pub const BG_ALT: Color = Color::Rgb(0x16, 0x16, 0x1e);
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 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);
pub const RED: Color = Color::Rgb(0xf7, 0x76, 0x8e);
pub const MAGENTA: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
pub const ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);