use ratatui::layout::{Alignment, Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use crate::scan::fmt_bytes;
use crate::tui::app::App;
pub fn render(f: &mut Frame, app: &App) {
let theme = &app.theme;
let area = f.area();
let n_daps = app.scan.identified.len();
let dap_summary = format!(
" scan: {} DAP{} ",
n_daps,
if n_daps == 1 { "" } else { "s" }
);
let outer = Block::bordered()
.title(
Line::from(" dapctl ")
.style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
)
.title(
Line::from(dap_summary)
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Right),
)
.border_style(Style::default().fg(theme.muted))
.style(Style::default().bg(theme.bg));
let inner = outer.inner(area);
f.render_widget(outer, area);
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner);
let footer_line = if let Some(ref msg) = app.flash {
Line::from(Span::styled(
format!(" {msg}"),
Style::default().fg(theme.warn),
))
} else {
Line::from(vec![
kb("j/k", app),
Span::raw(" move "),
kb("enter", app),
Span::raw(" diff "),
kb("n", app),
Span::raw(" new "),
kb("c", app),
Span::raw(" clone "),
kb("D", app),
Span::raw(" delete "),
kb("r", app),
Span::raw(" refresh "),
kb("l", app),
Span::raw(" log "),
kb("m", app),
Span::raw(" player "),
kb("q", app),
Span::raw(" quit"),
])
};
f.render_widget(
Paragraph::new(footer_line).style(Style::default().fg(theme.muted)),
footer_area,
);
let profiles_height = ((app.profiles.len() as u16).saturating_add(2))
.max(3)
.min(content_area.height.saturating_sub(4));
let [profiles_area, daps_area] =
Layout::vertical([Constraint::Length(profiles_height), Constraint::Fill(1)])
.areas(content_area);
render_profiles(f, app, profiles_area);
render_daps(f, app, daps_area);
}
fn render_profiles(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let theme = &app.theme;
let [header_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" SYNC PROFILES",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
header_area,
);
if app.profiles.is_empty() {
let dir = crate::config::profiles_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "~/.config/dapctl/profiles/".to_owned());
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" (no profiles found — add .toml files to {dir})"),
Style::default().fg(theme.muted),
))),
list_area,
);
return;
}
let items: Vec<ListItem> = app
.profiles
.iter()
.map(|(_, p)| {
let mode_str = format!("{:?}", p.profile.mode).to_lowercase();
let is_mirror = matches!(p.profile.mode, crate::config::Mode::Mirror);
let mode_style = Style::default().fg(if is_mirror { theme.warn } else { theme.muted });
let src = truncate(&p.profile.source, 24);
let dst = truncate(&p.profile.destination, 24);
let sync_str = app
.last_sync
.get(&p.profile.name)
.map(|&ts| format!(" ✓ {}", fmt_since(ts)))
.unwrap_or_default();
ListItem::new(Line::from(vec![
Span::raw(format!(
" {:<20} {:<24} → {:<24} ",
p.profile.name, src, dst
)),
Span::styled(format!("[{mode_str}]"), mode_style),
Span::styled(sync_str, Style::default().fg(theme.muted)),
]))
})
.collect();
let mut state = ListState::default();
if !app.profiles.is_empty() {
state.select(Some(app.profile_idx));
}
let list = List::new(items)
.style(Style::default().fg(theme.fg).bg(theme.bg))
.highlight_style(
Style::default()
.fg(theme.sel_fg)
.bg(theme.sel_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶");
f.render_stateful_widget(list, list_area, &mut state);
}
fn render_daps(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let theme = &app.theme;
if area.height == 0 {
return;
}
let [header_area, body_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" CONNECTED DAPs",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
header_area,
);
let mut lines: Vec<Line> = Vec::new();
if app.scan.identified.is_empty() && app.scan.unidentified.is_empty() {
lines.push(Line::from(Span::styled(
" (no removable drives detected — press r to refresh)",
Style::default().fg(theme.muted),
)));
} else {
for id in &app.scan.identified {
let total = id.mount.total_bytes.map(fmt_bytes).unwrap_or_default();
let free = id.mount.free_bytes.map(fmt_bytes).unwrap_or_default();
let fs = id.mount.filesystem.as_deref().unwrap_or("?");
let label = id.mount.label.as_deref().unwrap_or(&id.dap_id);
lines.push(Line::from(vec![
Span::raw(format!(" {:<20} ", id.dap_id)),
Span::styled(
format!("{:<28}", id.mount.mount_point),
Style::default().fg(theme.fg),
),
Span::raw(format!(" {:<8}", fs)),
Span::styled(
format!(" free {} / {}", free, total),
Style::default().fg(theme.muted),
),
Span::styled(format!(" ({})", label), Style::default().fg(theme.muted)),
]));
}
for m in &app.scan.unidentified {
let total = m.total_bytes.map(fmt_bytes).unwrap_or_default();
let label = m.label.as_deref().unwrap_or("(no label)");
let fs = m.filesystem.as_deref().unwrap_or("?");
lines.push(Line::from(Span::styled(
format!(
" {:<20} {:<28} {:<8} {}",
label, m.mount_point, fs, total
),
Style::default().fg(theme.muted),
)));
}
}
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(theme.bg)),
body_area,
);
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max {
s
} else {
&s[..max]
}
}
fn kb<'a>(key: &'a str, app: &'a App) -> Span<'a> {
Span::styled(
key,
Style::default()
.fg(app.theme.fg)
.add_modifier(Modifier::BOLD),
)
}
#[allow(dead_code)]
fn dap_separator(theme: &crate::tui::theme::Theme) -> Block<'static> {
Block::new()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.muted))
}
fn fmt_since(epoch_secs: u64) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(epoch_secs);
let diff = now.saturating_sub(epoch_secs);
if diff < 90 {
"just now".to_owned()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86_400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86_400)
}
}