use crate::app::{App, InputMode, ListItem, TreeItem, SortMode}; use crate::config::parse_color; use crate::zfs::format_size; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem as RatatuiListItem, Paragraph}, Frame, }; // Helper to get colors from app config fn get_colors(app: &App) -> (Color, Color, Color, Color, Color, Color, Color, Color, Color, Color) { let c = &app.config.colors; ( parse_color(&c.background), parse_color(&c.foreground), parse_color(&c.header), parse_color(&c.selected_bg), parse_color(&c.selected_fg), parse_color(&c.marked), parse_color(&c.snapshot), parse_color(&c.size), parse_color(&c.tree), parse_color(&c.search_match), ) } pub fn draw(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title Constraint::Min(10), // Main content Constraint::Length(1), // Status bar Constraint::Length(1), // Function keys / Input ]) .split(f.area()); draw_title(f, chunks[0], app); draw_main(f, chunks[1], app); draw_status(f, chunks[2], app); if app.input_mode != InputMode::Normal { draw_input(f, chunks[3], app); } else { draw_function_keys(f, chunks[3], app); } if app.show_help { draw_help(f, app); } } const VERSION: &str = env!("CARGO_PKG_VERSION"); fn draw_title(f: &mut Frame, area: Rect, app: &App) { let (bg, _fg, header, _, _, _, _, _, _, _) = get_colors(app); let mut title = format!(" zfsdu v{} ", VERSION); // Show sort mode title.push_str(&format!("[Sort: {}] ", app.sort_mode.label())); if !app.filter_query.is_empty() { title.push_str(&format!("[Filter: {}] ", app.filter_query)); } if app.dry_run_mode { title.push_str("[DRY-RUN] "); } if app.auto_refresh { title.push_str("[AUTO] "); } let title_bar = Paragraph::new(title).style( Style::default() .bg(bg) .fg(header) .add_modifier(Modifier::BOLD), ); f.render_widget(title_bar, area); } fn draw_main(f: &mut Frame, area: Rect, app: &mut App) { let (bg, fg, header_color, _, _, _, _, _, _, _) = get_colors(app); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(fg).bg(bg)) .style(Style::default().bg(bg)); let inner = block.inner(area); f.render_widget(block, area); // Header - dynamic width with date column let header_area = Rect { x: inner.x, y: inner.y, width: inner.width, height: 1, }; // Layout: name + date(12) + size(10) + bar(8) + spacing let header_name_width = (inner.width as usize).saturating_sub(34); let header = Line::from(vec![ Span::styled( format!(" {:12}", "Date"), Style::default() .fg(header_color) .add_modifier(Modifier::BOLD), ), Span::styled( format!("{:>10}", "Size"), Style::default() .fg(header_color) .add_modifier(Modifier::BOLD), ), ]); f.render_widget( Paragraph::new(header).style(Style::default().bg(bg)), header_area, ); // Separator let sep_area = Rect { x: inner.x, y: inner.y + 1, width: inner.width, height: 1, }; let separator = "─".repeat(inner.width as usize); f.render_widget( Paragraph::new(separator).style(Style::default().fg(fg).bg(bg)), sep_area, ); // List area let list_area = Rect { x: inner.x, y: inner.y + 2, width: inner.width, height: inner.height.saturating_sub(2), }; // Update page_size based on visible area app.page_size = list_area.height as usize; // Get visible items with scrolling let visible_items: Vec<(usize, &TreeItem)> = app.visible_items(); let total_visible = visible_items.len(); // Apply scroll offset let display_items: Vec<(usize, usize, &TreeItem)> = visible_items .into_iter() .enumerate() .skip(app.scroll_offset) .take(list_area.height as usize) .map(|(visible_idx, (real_idx, ti))| (visible_idx, real_idx, ti)) .collect(); let available_width = list_area.width as usize; let items: Vec = display_items .iter() .map(|(visible_idx, real_idx, tree_item)| { render_tree_item(app, *visible_idx, *real_idx, tree_item, available_width) }) .collect(); let list = List::new(items).style(Style::default().bg(bg)); f.render_widget(list, list_area); // Draw scrollbar indicator if needed if total_visible > list_area.height as usize { draw_scrollbar(f, list_area, app.scroll_offset, total_visible, list_area.height as usize, bg); } } fn render_tree_item(app: &App, visible_index: usize, real_index: usize, tree_item: &TreeItem, available_width: usize) -> RatatuiListItem<'static> { let (bg, fg, _, selected_bg, selected_fg, marked_color, snapshot_color, size_color, tree_color, search_match_color) = get_colors(app); let is_selected = visible_index == app.selected; let is_marked = app.is_marked(tree_item.item.full_name()); let is_search_match = app.is_search_match(real_index); // Build tree prefix (│, ├──, └──) let mut tree_prefix = String::new(); for &parent_last in &tree_item.parent_is_last { if parent_last { tree_prefix.push_str(" "); } else { tree_prefix.push_str(" │ "); } } if tree_item.depth > 0 { if tree_item.is_last { tree_prefix.push_str(" └──"); } else { tree_prefix.push_str(" ├──"); } } // Item prefix (icon) let (icon, name_color) = match &tree_item.item { ListItem::Dataset(d) => { let has_children = d.has_children(); let is_expanded = app.is_expanded(&d.full_name); let icon = if has_children { if is_expanded { "▾ " } else { "▸ " } } else { " " }; (icon, fg) } ListItem::Snapshot(_) => { let icon = if is_marked { "● " } else { "○ " }; (icon, snapshot_color) } }; // Calculate available width for name dynamically // Layout: prefix + icon(2) + name + date(12) + size(10) + space(1) + bar(8) + scrollbar(2) let prefix_len = tree_prefix.chars().count(); let fixed_width = prefix_len + 2 + 12 + 10 + 1 + 8 + 2; // prefix + icon + date + size + space + bar + scrollbar let max_name_len = available_width.saturating_sub(fixed_width).max(20); let name = truncate_str(tree_item.item.name(), max_name_len); let name_padded = format!("{:10}", format_size(tree_item.item.used())); let bar = create_size_bar(tree_item.item.used(), app.max_size()); // Determine styles let tree_style = Style::default().fg(tree_color).bg(bg); let mut item_style = if is_marked { Style::default() .fg(marked_color) .bg(bg) .add_modifier(Modifier::BOLD) } else if is_search_match { Style::default() .fg(search_match_color) .bg(bg) } else { Style::default().fg(name_color).bg(bg) }; let mut size_style = Style::default().fg(size_color).bg(bg); let mut date_style = Style::default().fg(Color::Gray).bg(bg); if is_selected { item_style = item_style.bg(selected_bg).fg(selected_fg); size_style = size_style.bg(selected_bg).fg(selected_fg); date_style = date_style.bg(selected_bg).fg(selected_fg); } let line = Line::from(vec![ Span::styled(tree_prefix, if is_selected { item_style } else { tree_style }), Span::styled(icon.to_string(), item_style), Span::styled(name_padded, item_style), Span::styled(format!("{:>12}", date_str), date_style), Span::styled(size, size_style), Span::raw(" "), Span::styled(bar, size_style), ]); RatatuiListItem::new(line).style(Style::default().bg(bg)) } fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize, bg: Color) { if total == 0 || visible >= total { return; } let scrollbar_height = area.height as usize; let thumb_height = ((visible as f64 / total as f64) * scrollbar_height as f64).max(1.0) as usize; let thumb_pos = ((offset as f64 / (total - visible) as f64) * (scrollbar_height - thumb_height) as f64) as usize; for i in 0..scrollbar_height { let char = if i >= thumb_pos && i < thumb_pos + thumb_height { "█" } else { "░" }; let scroll_area = Rect { x: area.x + area.width - 1, y: area.y + i as u16, width: 1, height: 1, }; f.render_widget( Paragraph::new(char).style(Style::default().fg(Color::DarkGray).bg(bg)), scroll_area, ); } } fn draw_status(f: &mut Frame, area: Rect, app: &App) { let marked_info = if app.marked.is_empty() { String::new() } else { format!( "{} marked ({})", app.marked.len(), format_size(app.total_marked_size()) ) }; let msg = app.message.clone().unwrap_or_default(); let status = if !msg.is_empty() { if marked_info.is_empty() { format!(" {}", msg) } else { format!(" {} │ {}", marked_info, msg) } } else if !marked_info.is_empty() { format!(" {}", marked_info) } else { format!(" {} items", app.visible_count()) }; let status_bar = Paragraph::new(status).style( Style::default().bg(Color::Cyan).fg(Color::Black), ); f.render_widget(status_bar, area); } fn draw_input(f: &mut Frame, area: Rect, app: &App) { let (label, query) = match app.input_mode { InputMode::Search => ("Search: ", &app.search_query), InputMode::Filter => ("Filter: ", &app.filter_query), InputMode::Normal => return, }; let input_line = format!("{}{}_", label, query); let input = Paragraph::new(input_line).style( Style::default().bg(Color::Black).fg(Color::White), ); f.render_widget(input, area); } fn draw_function_keys(f: &mut Frame, area: Rect, app: &App) { let keys = vec![ ("?", "Help"), ("s", "Sort"), ("D", if app.dry_run_mode { "DRY!" } else { "Dry" }), ("a", if app.auto_refresh { "Auto!" } else { "Auto" }), ("/", "Find"), ("f", "Filt"), ("d", "Del"), ("q", "Quit"), ]; let spans: Vec = keys .iter() .flat_map(|(key, desc)| { vec![ Span::styled( format!("{}", key), Style::default().bg(Color::Black).fg(Color::White), ), Span::styled( format!("{} ", desc), Style::default().bg(Color::Cyan).fg(Color::Black), ), ] }) .collect(); let line = Line::from(spans); let keys_bar = Paragraph::new(line).style(Style::default().bg(Color::Cyan)); f.render_widget(keys_bar, area); } fn draw_help(f: &mut Frame, app: &App) { let (bg, fg, header, _, _, _, _, _, _, _) = get_colors(app); let area = centered_rect(70, 90, f.area()); f.render_widget(Clear, area); let help_text = vec![ "", " Navigation:", " ↑/k, ↓/j Move up/down", " Enter/→/l Expand/collapse dataset", " Home/End First/last item", " PgUp/PgDn Page up/down", " Mouse scroll Scroll list", " Left-click Select item", " Right-click Toggle mark", "", " Tree:", " e Expand all", " c Collapse all", "", " Selection (current scope):", " Space Mark/unmark snapshot", " + Select all in scope", " - Unselect all in scope", " * Invert selection in scope", "", " Search & Filter:", " / Search (n=next, N=prev)", " f Filter visible items", " Esc Clear filter", "", " Sort & View:", " s Toggle sort: Size/Date/Name", " D Toggle dry-run mode", " a Toggle auto-refresh (30s)", "", " Actions:", " d / F8 Delete marked snapshots", " r / F5 Refresh", " ? / F1 This help", " q / F10 Quit", "", " Config: ~/.config/zfsdu/config.toml", "", " (c) Florian Schermer - DATAZONE GmbH", " https://datazone.de", "", " Press any key to close", ]; let help = Paragraph::new(help_text.join("\n")) .block( Block::default() .title(format!(" Help - zfsdu v{} ", VERSION)) .borders(Borders::ALL) .border_style(Style::default().fg(header)), ) .style(Style::default().bg(bg).fg(fg)); f.render_widget(help, area); } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(popup_layout[1])[1] } fn truncate_str(s: &str, max_len: usize) -> String { if s.chars().count() <= max_len { s.to_string() } else { let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); format!("{}…", truncated) } } fn create_size_bar(size: u64, max_size: u64) -> String { let bar_width = 8; if max_size == 0 { return " ".repeat(bar_width); } let filled = ((size as f64 / max_size as f64) * bar_width as f64).round() as usize; let filled = filled.min(bar_width); format!("{}{}", "█".repeat(filled), " ".repeat(bar_width - filled)) } fn format_date(timestamp: &str) -> String { // ZFS returns timestamp as Unix epoch seconds if let Ok(ts) = timestamp.parse::() { use chrono::{DateTime, Utc}; if let Some(dt) = DateTime::::from_timestamp(ts, 0) { return dt.format("%Y-%m-%d").to_string(); } } // Return original if parsing fails if timestamp.len() > 10 { timestamp[..10].to_string() } else { timestamp.to_string() } }