zfsdu/src/ui.rs
kidev 194b81bbcf Add scope-based selection and credits
- Selection keys (+/-/*) now work on current scope (parent dataset)
- Add author credits: Florian Schermer, DATAZONE GmbH
- Update help text and README with scope documentation
2026-01-31 00:15:26 +01:00

508 lines
16 KiB
Rust

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!(" {:<width$}", "Name", width = header_name_width),
Style::default()
.fg(header_color)
.add_modifier(Modifier::BOLD),
),
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<RatatuiListItem> = 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!("{:<width$}", name, width = max_name_len);
// Format date (show only date part, not time)
let creation = tree_item.item.creation();
let date_str = format_date(creation);
let size = 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<Span> = 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::<i64>() {
use chrono::{DateTime, Utc};
if let Some(dt) = DateTime::<Utc>::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()
}
}