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:
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod config_view;
|
||||
pub mod dashboard;
|
||||
pub mod logs;
|
||||
pub mod sync;
|
||||
@@ -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(¤t, 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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user