use crate::backend::utils;
use crate::tui::app::{App, CurrentScreen, DetailFocus, PendingAction, StatusKind};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.size());
draw_header(f, app, chunks[0]);
match app.current_screen {
CurrentScreen::Home => draw_home(f, app, chunks[1]),
CurrentScreen::Detail => draw_detail(f, app, chunks[1]),
}
draw_footer(f, app, chunks[2]);
if app.pending_action == Some(PendingAction::DeleteSelected) {
draw_delete_confirmation(f, app);
}
}
fn draw_home(f: &mut Frame, app: &App, area: Rect) {
if app.sessions.is_empty() {
let empty =
Paragraph::new("No backups found.\n\nPress n to capture the current Safari session.")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Backups "))
.wrap(Wrap { trim: true });
f.render_widget(empty, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(54), Constraint::Percentage(46)])
.split(area);
let header = Row::new(vec!["Captured", "Win", "Tabs", "File"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = app
.sessions
.iter()
.map(|summary| {
Row::new(vec![
Cell::from(utils::format_timestamp(&summary.captured_at)),
Cell::from(summary.window_count.to_string()),
Cell::from(summary.tab_count.to_string()),
Cell::from(summary.file_name.clone()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(16),
Constraint::Length(5),
Constraint::Length(6),
Constraint::Min(16),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Backups "))
.column_spacing(1)
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Yellow))
.highlight_symbol(">> ");
let mut state = TableState::default();
state.select(Some(app.selected_session_index));
f.render_stateful_widget(table, chunks[0], &mut state);
draw_preview(f, app, chunks[1]);
}
fn draw_detail(f: &mut Frame, app: &App, area: Rect) {
if let Some(session) = app.detail_session() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let summary = Paragraph::new(vec![
Line::from(format!(
"Captured {} | {} windows | {} tabs",
utils::format_timestamp(&session.captured_at),
session.window_count(),
session.tab_count()
)),
Line::from(format!(
"Focus: {} | Marked windows: {}",
match app.detail_focus {
DetailFocus::Windows => "windows",
DetailFocus::Tabs => "tabs",
},
app.marked_window_count()
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Detail Summary "),
)
.wrap(Wrap { trim: true });
f.render_widget(summary, chunks[0]);
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(34), Constraint::Percentage(66)])
.split(chunks[1]);
draw_detail_windows(f, app, panes[0]);
draw_detail_tabs(f, app, panes[1]);
} else {
let empty = Paragraph::new("No session loaded.")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Details "));
f.render_widget(empty, area);
}
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let (text, style) = if let Some(message) = &app.status {
let style = match message.kind {
StatusKind::Info => Style::default().fg(Color::Black).bg(Color::White),
StatusKind::Success => Style::default().fg(Color::Black).bg(Color::Green),
StatusKind::Error => Style::default().fg(Color::White).bg(Color::Red),
};
(message.text.clone(), style)
} else {
let help = match app.current_screen {
CurrentScreen::Home => {
"j/k move enter details n capture r restore d delete f refresh q quit"
}
CurrentScreen::Detail => {
"tab switch pane j/k move space mark window r restore window s restore marked o open url a all u clear esc back"
}
};
(
help.to_string(),
Style::default().fg(Color::Black).bg(Color::White),
)
};
let p = Paragraph::new(text)
.style(style)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let selected = app
.selected_summary()
.map(|summary| {
format!(
"{} | {} windows | {} tabs",
utils::format_timestamp(&summary.captured_at),
summary.window_count,
summary.tab_count
)
})
.unwrap_or_else(|| "No backup selected".to_string());
let header = Paragraph::new(vec![
Line::from(Span::styled(
"Safari Checkpoint",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(format!("{selected} | Data: {}", app.storage_dir.display())),
])
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}
fn draw_preview(f: &mut Frame, app: &App, area: Rect) {
let mut lines = Vec::new();
if let Some(summary) = app.selected_summary() {
lines.push(Line::from(Span::styled(
utils::format_timestamp(&summary.captured_at),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(format!(
"{} windows | {} tabs",
summary.window_count, summary.tab_count
)));
lines.push(Line::from(summary.file_name.clone()));
lines.push(Line::from(String::new()));
}
if let Some(session) = app.selected_session() {
for (window_index, window) in session.windows.iter().enumerate() {
lines.push(Line::from(Span::styled(
format!(
"{}. {} ({})",
window_index + 1,
window.title,
window.tabs.len()
),
Style::default().fg(Color::Yellow),
)));
for (tab_index, tab) in window.tabs.iter().take(3).enumerate() {
lines.push(Line::from(format!(" {}. {}", tab_index + 1, tab.title)));
lines.push(Line::from(Span::styled(
format!(" {}", truncate_text(&tab.url, 56)),
Style::default().fg(Color::DarkGray),
)));
}
if window.tabs.len() > 3 {
lines.push(Line::from(format!(
" ... {} more tab(s)",
window.tabs.len() - 3
)));
}
if window_index + 1 < session.windows.len() {
lines.push(Line::from(String::new()));
}
}
} else {
lines.push(Line::from("No preview available."));
}
let preview = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Preview "))
.wrap(Wrap { trim: true });
f.render_widget(preview, area);
}
fn draw_delete_confirmation(f: &mut Frame, app: &App) {
let area = centered_rect(60, 20, f.size());
let selected = app
.selected_summary()
.map(|summary| utils::format_timestamp(&summary.captured_at))
.unwrap_or_else(|| "the selected backup".to_string());
let dialog = Paragraph::new(vec![
Line::from("Delete this backup?"),
Line::from(String::new()),
Line::from(selected),
Line::from(String::new()),
Line::from("Press y or Enter to confirm."),
Line::from("Press n or Esc to cancel."),
])
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Confirm Delete "),
)
.wrap(Wrap { trim: true });
f.render_widget(Clear, area);
f.render_widget(dialog, area);
}
fn draw_detail_windows(f: &mut Frame, app: &App, area: Rect) {
let title = if app.detail_focus == DetailFocus::Windows {
" Windows * "
} else {
" Windows "
};
let header = Row::new(vec!["Sel", "Window", "Tabs"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = app
.detail_session()
.map(|session| {
session
.windows
.iter()
.enumerate()
.map(|(index, window)| {
let marker = if app.marked_window_indexes.contains(&index) {
"[x]"
} else {
"[ ]"
};
Row::new(vec![
Cell::from(marker),
Cell::from(truncate_text(&window.title, 28)),
Cell::from(window.tabs.len().to_string()),
])
})
.collect()
})
.unwrap_or_default();
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Min(14),
Constraint::Length(4),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style(app.detail_focus == DetailFocus::Windows)),
)
.column_spacing(1)
.highlight_style(highlight_style(app.detail_focus == DetailFocus::Windows))
.highlight_symbol(">> ");
let mut state = TableState::default();
state.select(Some(app.detail_selected_window_index));
f.render_stateful_widget(table, area, &mut state);
}
fn draw_detail_tabs(f: &mut Frame, app: &App, area: Rect) {
let title = if app.detail_focus == DetailFocus::Tabs {
" Tabs * "
} else {
" Tabs "
};
let Some(window) = app.selected_detail_window() else {
let empty = Paragraph::new("No window selected.")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(empty, area);
return;
};
let header = Row::new(vec!["#", "Title", "URL"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = window
.tabs
.iter()
.enumerate()
.map(|(index, tab)| {
Row::new(vec![
Cell::from((index + 1).to_string()),
Cell::from(truncate_text(&tab.title, 38)),
Cell::from(truncate_text(&tab.url, 56)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Percentage(42),
Constraint::Percentage(58),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(
"{}{} ({}) ",
title,
truncate_text(&window.title, 24),
window.tabs.len()
))
.border_style(border_style(app.detail_focus == DetailFocus::Tabs)),
)
.column_spacing(1)
.highlight_style(highlight_style(app.detail_focus == DetailFocus::Tabs))
.highlight_symbol(">> ");
let mut state = TableState::default();
state.select(Some(app.detail_selected_tab_index));
f.render_stateful_widget(table, area, &mut state);
}
fn truncate_text(value: &str, max_chars: usize) -> String {
let mut chars = value.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if value.chars().count() > max_chars {
format!("{truncated}...")
} else {
value.to_string()
}
}
fn border_style(active: bool) -> Style {
if active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}
}
fn highlight_style(active: bool) -> Style {
if active {
Style::default().bg(Color::DarkGray).fg(Color::Yellow)
} else {
Style::default().bg(Color::Black).fg(Color::White)
}
}
#[cfg(test)]
mod tests {
use super::truncate_text;
#[test]
fn truncates_long_text_for_detail_tables() {
assert_eq!(truncate_text("abcdef", 4), "abcd...");
assert_eq!(truncate_text("abc", 4), "abc");
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}