use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap};
use rebecca::core::cleanup_advice::CleanupAdviceStatus;
use rebecca::core::disk_session::{DiskMapDistributionRow, DiskMapVisibleRow};
use rebecca::core::plan::CleanupPlan;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::output::format_bytes;
use crate::text::format_count;
use crate::tui::app::TuiApp;
use crate::tui::basket;
use crate::tui::layout;
use crate::tui::model::TuiScreen;
use crate::tui::progress::TuiTaskStatus;
use crate::tui::treemap::TreemapTile;
const BAR_WIDTH: usize = 12;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ViewOptions {
pub(crate) width: usize,
pub(crate) visual_bars: bool,
pub(crate) color: bool,
}
pub(crate) fn render(frame: &mut Frame<'_>, app: &TuiApp, options: ViewOptions) {
let layout = layout::frame(frame.area());
render_header(frame, app, layout.header, options.color);
match app.screen {
TuiScreen::RootPicker => render_root_picker(frame, app, layout.body, options.color),
TuiScreen::Map => render_map(frame, app, layout.body, options),
TuiScreen::Treemap => render_treemap(frame, app, layout.body, options),
TuiScreen::Types | TuiScreen::Extensions => {
render_distribution(frame, app, layout.body, options);
}
TuiScreen::Busy => render_busy(frame, app, layout.body),
TuiScreen::Preview => {
render_plan(frame, app.preview.as_ref(), "Cleanup preview", layout.body)
}
TuiScreen::Confirm => render_confirm(frame, app, layout.body),
TuiScreen::Executed => {
render_plan(frame, app.executed.as_ref(), "Cleanup result", layout.body)
}
TuiScreen::History => render_history(frame, app, layout.body),
TuiScreen::Help => render_help(frame, layout.body),
TuiScreen::Error => render_error(frame, app, layout.body),
}
render_status(frame, app, layout.status);
}
fn render_header(frame: &mut Frame<'_>, app: &TuiApp, area: Rect, color: bool) {
let mut spans = vec![Span::styled("Rebecca ", header_style(color))];
for (label, screen) in layout::header_tab_specs() {
spans.push(Span::styled(
format!("[{label}]"),
selected_style(app.screen == screen, color),
));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(format!(
" basket:{} sort:{}{}",
app.basket.len(),
app.sort.label(),
group_filter_suffix(app)
)));
let title = Line::from(spans);
frame.render_widget(Paragraph::new(title), area);
}
fn render_root_picker(frame: &mut Frame<'_>, app: &TuiApp, area: Rect, color: bool) {
let rows = app.root_choices.iter().enumerate().map(|(index, choice)| {
let marker = if index == app.selected { ">" } else { " " };
Row::new(vec![
Cell::from(marker),
Cell::from(choice.label.clone()),
Cell::from(choice.path.display().to_string()),
])
.style(selected_style(index == app.selected, color))
});
let table = Table::new(
rows,
[
Constraint::Length(2),
Constraint::Length(18),
Constraint::Min(20),
],
)
.block(Block::default().borders(Borders::ALL).title("Roots"))
.column_spacing(1);
frame.render_widget(table, area);
}
fn render_map(frame: &mut Frame<'_>, app: &TuiApp, area: Rect, options: ViewOptions) {
let chunks = layout::workbench_body(area);
let rows = app.visible_rows();
let table_rows = rows.iter().enumerate().map(|(index, row)| {
let marker = if index == app.selected { ">" } else { " " };
let staged = row
.cleanup_advice
.as_ref()
.and_then(|advice| advice.rule_id.as_ref())
.is_some_and(|rule_id| app.basket.contains_key(rule_id));
Row::new(vec![
Cell::from(marker),
Cell::from(if staged { "*" } else { " " }),
Cell::from(if row.has_children {
"dir"
} else {
row.kind.label()
}),
Cell::from(format_bytes(row.metrics.logical_bytes)),
Cell::from(if options.visual_bars {
byte_bar(row.metrics.logical_bytes, max_logical(&rows), BAR_WIDTH)
} else {
String::new()
}),
Cell::from(row.name.clone()),
Cell::from(advice_label(row)),
])
.style(selected_style(index == app.selected, options.color))
});
let table = Table::new(
table_rows,
[
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(11),
Constraint::Length(BAR_WIDTH as u16),
Constraint::Min(16),
Constraint::Length(18),
],
)
.block(
Block::default()
.borders(Borders::ALL)
.title(map_title(app, "Map")),
)
.column_spacing(1);
frame.render_widget(table, chunks.primary);
render_details(frame, app, chunks.details);
}
fn render_treemap(frame: &mut Frame<'_>, app: &TuiApp, area: Rect, options: ViewOptions) {
let chunks = layout::workbench_body(area);
let block = Block::default()
.borders(Borders::ALL)
.title(map_title(app, "Treemap"));
let tile_area = layout::bordered_inner(chunks.primary);
frame.render_widget(block, chunks.primary);
let rows = app.visible_rows();
let tiles = layout::treemap_tiles(&rows, tile_area);
if tiles.is_empty() {
frame.render_widget(Paragraph::new(treemap_empty_message(app)), tile_area);
} else {
for (index, tile) in tiles.iter().enumerate() {
render_treemap_tile(
frame,
tile,
index,
tile.row_index == Some(app.selected),
options,
);
}
}
render_details(frame, app, chunks.details);
}
fn render_distribution(frame: &mut Frame<'_>, app: &TuiApp, area: Rect, options: ViewOptions) {
let chunks = layout::workbench_body(area);
let rows = app.distribution_rows();
let max = max_distribution_logical(&rows);
let selected = app.selected_distribution_index();
let table_rows = rows.iter().enumerate().map(|(index, row)| {
let marker = if index == selected { ">" } else { " " };
Row::new(vec![
Cell::from(marker),
Cell::from(row.label.clone()),
Cell::from(format_bytes(row.metrics.logical_bytes)),
Cell::from(
row.metrics
.allocated_bytes
.map(format_bytes)
.unwrap_or_else(|| "-".to_string()),
),
Cell::from(distribution_count_label(row)),
Cell::from(distribution_share_label(row)),
Cell::from(if options.visual_bars {
byte_bar(row.metrics.logical_bytes, max, BAR_WIDTH)
} else {
String::new()
}),
])
.style(selected_style(index == selected, options.color))
});
let table = Table::new(
table_rows,
[
Constraint::Length(2),
Constraint::Min(14),
Constraint::Length(11),
Constraint::Length(11),
Constraint::Length(18),
Constraint::Length(8),
Constraint::Length(BAR_WIDTH as u16),
],
)
.block(
Block::default()
.borders(Borders::ALL)
.title(distribution_title(app.screen)),
)
.column_spacing(1);
frame.render_widget(table, chunks.primary);
render_distribution_details(frame, rows.get(selected), chunks.details);
}
fn render_treemap_tile(
frame: &mut Frame<'_>,
tile: &TreemapTile,
index: usize,
selected: bool,
options: ViewOptions,
) {
if tile.rect.width == 0 || tile.rect.height == 0 {
return;
}
let style = treemap_tile_style(index, selected, options.color);
let label_width = usize::from(tile.rect.width.saturating_sub(2)).max(1);
let mut lines = Vec::new();
if tile.rect.width >= 6 {
lines.push(Line::from(trim_to_width(tile.label.clone(), label_width)));
}
if tile.rect.height >= 2 && tile.rect.width >= 8 {
lines.push(Line::from(trim_to_width(
format_bytes(tile.logical_bytes),
label_width,
)));
}
let paragraph = Paragraph::new(lines).style(style);
if tile.rect.width >= 8 && tile.rect.height >= 3 {
frame.render_widget(
paragraph.block(Block::default().borders(Borders::ALL)),
tile.rect,
);
} else {
frame.render_widget(paragraph, tile.rect);
}
}
fn render_details(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let mut lines = Vec::new();
if app.screen == TuiScreen::Treemap {
lines.extend(treemap_context_lines(app));
lines.push(Line::from(""));
}
if let Some(row) = app.selected_row() {
lines.push(Line::from(row.path.display().to_string()));
lines.push(Line::from(format!(
"{} logical, {}",
format_bytes(row.metrics.logical_bytes),
format_count(row.metrics.files, "file", "files")
)));
if let Some(advice) = row.cleanup_advice {
lines.push(Line::from(format!("Advice: {}", advice.status.label())));
lines.push(Line::from(format!("Reason: {}", advice.reason)));
if let Some(rule_id) = advice.rule_id {
lines.push(Line::from(format!("Rule: {rule_id}")));
}
if !advice.required_flags.is_empty() {
lines.push(Line::from(format!(
"Flags: {}",
advice.required_flags.join(", ")
)));
}
if !advice.required_warnings.is_empty() {
lines.push(Line::from(format!(
"Warnings: {}",
advice.required_warnings.join(", ")
)));
}
} else {
lines.push(Line::from("Advice: none"));
}
} else {
lines.push(Line::from("No entries"));
}
lines.push(Line::from(""));
lines.push(Line::from("Rule basket"));
if app.basket.is_empty() {
lines.push(Line::from(" empty"));
} else {
lines.push(Line::from(" preview includes all matching rule targets"));
for item in app.basket.values() {
lines.push(Line::from(format!(" {}", basket::label(item))));
}
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(Block::default().borders(Borders::ALL).title("Details")),
area,
);
}
fn render_distribution_details(
frame: &mut Frame<'_>,
selected_row: Option<&DiskMapDistributionRow>,
area: Rect,
) {
let mut lines = Vec::new();
if let Some(row) = selected_row {
lines.push(Line::from(row.label.clone()));
lines.push(Line::from(format!("Group: {}", row.kind.label())));
lines.push(Line::from(format!(
"Logical: {}",
format_bytes(row.metrics.logical_bytes)
)));
lines.push(Line::from(format!(
"Allocated: {}",
row.metrics
.allocated_bytes
.map(format_bytes)
.unwrap_or_else(|| "unknown".to_string())
)));
lines.push(Line::from(format!(
"Unique: {}",
row.metrics
.unique_logical_bytes
.map(format_bytes)
.unwrap_or_else(|| "unknown".to_string())
)));
lines.push(Line::from(format!(
"Count: {}",
distribution_count_label(row)
)));
lines.push(Line::from(format!(
"Share: {}",
distribution_share_label(row)
)));
lines.push(Line::from(""));
lines.push(Line::from("Distribution rows are read-only."));
lines.push(Line::from("Stage cleanup from map entries."));
} else {
lines.push(Line::from("No distribution row selected."));
}
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: true }).block(
Block::default()
.borders(Borders::ALL)
.title("Distribution details"),
),
area,
);
}
fn render_plan(frame: &mut Frame<'_>, plan: Option<&CleanupPlan>, title: &'static str, area: Rect) {
let lines = plan_lines(title, plan);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(Block::default().borders(Borders::ALL).title(title)),
area,
);
}
fn render_busy(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let mut lines = vec![Line::from(app.message.clone())];
lines.extend(task_status_lines(app.task_status.as_ref()));
lines.push(Line::from(
"Esc cancels the current task when possible. q quits.",
));
frame.render_widget(
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Working")),
area,
);
}
fn render_confirm(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let lines = vec![
Line::from("Rebecca will move allowed targets to recoverable trash."),
Line::from(format!("Required phrase: {}", app.confirmation_phrase())),
Line::from(format!("Input: {}", app.message)),
];
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Confirm cleanup"),
),
area,
);
}
fn render_history(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let lines = history_lines(app);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(Block::default().borders(Borders::ALL).title("History")),
area,
);
}
fn render_help(frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(
Paragraph::new(help_lines()).block(Block::default().borders(Borders::ALL).title("Help")),
area,
);
}
fn render_error(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
frame.render_widget(
Paragraph::new(app.message.clone())
.block(Block::default().borders(Borders::ALL).title("Error")),
area,
);
}
fn render_status(frame: &mut Frame<'_>, app: &TuiApp, area: Rect) {
let filter_text = app.active_filter_text();
let filter_label = app.active_filter_label();
let search = if app.is_search_editing() {
format!(" | {filter_label} search: {filter_text}")
} else if filter_text.is_empty() {
String::new()
} else {
format!(" | {filter_label} filter: {filter_text}")
};
frame.render_widget(
Paragraph::new(format!(
"{}{}{} | 1 map 4/w treemap 2/t types 3/x extensions Tab cycle s sort / filter Backspace clear-filter r refresh R root Space stage c preview g history ? help q quit | mouse click/wheel",
app.message,
search,
active_group_filter_status(app)
)),
area,
);
}
fn plan_lines(_title: &'static str, plan: Option<&CleanupPlan>) -> Vec<Line<'static>> {
let Some(plan) = plan else {
return vec![Line::from("No plan available.")];
};
vec![
Line::from(format!(
"Targets: {} total, {} allowed, {} blocked, {} skipped, {} failed",
plan.summary.total_targets,
plan.summary.allowed_targets,
plan.summary.blocked_targets,
plan.summary.skipped_targets,
plan.summary.failed_targets
)),
Line::from(format!(
"Estimated: {} ({})",
plan.summary.estimated_bytes,
format_bytes(plan.summary.estimated_bytes)
)),
Line::from(format!(
"Freed: {} ({})",
plan.summary.freed_bytes,
format_bytes(plan.summary.freed_bytes)
)),
Line::from("e execute Esc return q quit"),
]
}
fn help_lines() -> Vec<Line<'static>> {
vec![
Line::from("j/k or arrows move"),
Line::from("Enter/l opens a directory or treemap tile, h/Esc moves back"),
Line::from("1 map, 4/w treemap, 2/t types, 3/x extensions, Tab cycles views"),
Line::from("Enter filters by selected type/extension; Backspace clears group filter"),
Line::from("/ filters the active view, s cycles sort"),
Line::from("r patches the active directory, R refreshes the root"),
Line::from(
"Mouse: click tabs, map rows, treemap tiles, or distribution rows; click selects only",
),
Line::from("Space stages the cleanup rule; preview includes all matching targets"),
Line::from("e executes only after typed confirmation"),
Line::from("g shows recent cleanup history"),
Line::from("q quits"),
]
}
fn history_lines(app: &TuiApp) -> Vec<Line<'static>> {
if app.history.is_empty() {
return vec![Line::from("No cleanup history entries yet.")];
}
let mut lines = Vec::with_capacity(app.history.len() + 2);
lines.push(Line::from("Recent cleanup history"));
for (index, entry) in app.history.iter().rev().enumerate() {
let bytes = entry
.summary
.freed_bytes
.saturating_add(entry.summary.pending_reclaim_bytes);
lines.push(Line::from(format!(
"#{:>2} {} | targets {} done, {} blocked, {} skipped | {}",
index + 1,
entry.recorded_at_unix_seconds,
entry.summary.completed_targets,
entry.summary.blocked_targets,
entry.summary.skipped_targets,
format_bytes(bytes),
)));
}
lines.push(Line::from("Esc returns to map, q quits."));
lines
}
fn task_status_lines(status: Option<&TuiTaskStatus>) -> Vec<Line<'static>> {
task_status_plain_lines(status)
.into_iter()
.map(Line::from)
.collect()
}
fn task_status_plain_lines(status: Option<&TuiTaskStatus>) -> Vec<String> {
let Some(status) = status else {
return Vec::new();
};
let mut lines = Vec::new();
lines.push(format!("Task: {} | {}", status.label, status.phase));
if status.cancel_requested {
lines.push(status.cancel_wait_message().to_string());
}
if let Some(backend) = &status.backend {
lines.push(format!("Backend: {backend}"));
}
if status.root_count > 0 {
lines.push(format!(
"Roots: {}/{}",
status.roots_finished, status.root_count
));
}
if status.files > 0 || status.directories > 0 || status.logical_bytes > 0 {
lines.push(format!(
"Scanned: {} files, {} directories, {}",
status.files,
status.directories,
format_bytes(status.logical_bytes)
));
}
if status.targets_started > 0 || status.targets_finished > 0 {
lines.push(format!(
"Targets: {} started, {} finished, {} estimated",
status.targets_started,
status.targets_finished,
format_bytes(status.estimated_bytes)
));
}
if status.cache_hits > 0 || status.cache_misses > 0 || status.cache_write_skipped > 0 {
lines.push(format!(
"Scan cache: {} hits, {} misses, {} skipped writes, {} pruned",
status.cache_hits, status.cache_misses, status.cache_write_skipped, status.cache_pruned
));
}
if let Some(rule_id) = &status.current_rule_id {
lines.push(format!("Rule: {rule_id}"));
}
if let Some(path) = &status.current_path {
lines.push(format!("Current: {}", path.display()));
}
if !status.last_event.is_empty() {
lines.push(format!("Last: {}", status.last_event));
}
lines
}
fn group_filter_suffix(app: &TuiApp) -> String {
app.active_group_filter_label()
.map(|label| format!(" | filter {label}"))
.unwrap_or_default()
}
fn active_group_filter_status(app: &TuiApp) -> String {
app.active_group_filter_label()
.map(|label| format!(" | group filter: {label}"))
.unwrap_or_default()
}
fn map_title(app: &TuiApp, prefix: &str) -> String {
match app.active_group_filter_label() {
Some(label) => format!("{prefix}: {} [{label}]", app.current_node_name()),
None => format!("{prefix}: {}", app.current_node_name()),
}
}
fn treemap_context_lines(app: &TuiApp) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(Line::from(format!("Scope: {}", app.current_node_name())));
lines.push(Line::from(format!(
"Breadcrumb: {}",
app.current_scope_breadcrumb()
)));
lines.push(Line::from(format!("Zoom depth: {}", app.zoom_depth())));
lines.push(Line::from(format!(
"Filter: {}",
app.active_scope_filter_summary()
.unwrap_or_else(|| "none".to_string())
)));
if let Some(summary) = app.treemap_selection_summary() {
lines.push(Line::from(format!("Selected tile: {}", summary.name)));
lines.push(Line::from(format!("Kind: {}", summary.kind)));
lines.push(Line::from(format!(
"Drillable: {}",
if summary.drillable { "yes" } else { "no" }
)));
if let Some(reason) = summary.non_drillable_reason {
lines.push(Line::from(format!("Reason: {reason}")));
}
lines.push(Line::from(format!("Action: {}", summary.primary_action)));
} else {
lines.push(Line::from("Selected tile: none"));
lines.push(Line::from("Drillable: no"));
lines.push(Line::from("Action: select a directory tile"));
}
lines
}
fn treemap_empty_message(app: &TuiApp) -> String {
if app.active_scope_filter_summary().is_some() {
"No entries match the active filters in this scope.".to_string()
} else {
"No non-empty entries.".to_string()
}
}
fn header_style(color: bool) -> Style {
if color {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
}
}
fn selected_style(selected: bool, color: bool) -> Style {
if selected {
let style = Style::default().add_modifier(Modifier::BOLD);
if color {
style.fg(Color::Black).bg(Color::Cyan)
} else {
style
}
} else {
Style::default()
}
}
fn treemap_tile_style(index: usize, selected: bool, color: bool) -> Style {
if selected {
return selected_style(true, color);
}
if !color {
return Style::default();
}
match index % 4 {
0 => Style::default().fg(Color::White).bg(Color::DarkGray),
1 => Style::default().fg(Color::White).bg(Color::Blue),
2 => Style::default().fg(Color::Black).bg(Color::Green),
_ => Style::default().fg(Color::Black).bg(Color::Yellow),
}
}
fn advice_label(row: &DiskMapVisibleRow) -> String {
row.cleanup_advice
.as_ref()
.map(|advice| advice.status.label().to_string())
.unwrap_or_else(|| CleanupAdviceStatus::Unknown.label().to_string())
}
fn max_logical(rows: &[DiskMapVisibleRow]) -> u64 {
rows.iter()
.map(|row| row.metrics.logical_bytes)
.max()
.unwrap_or(0)
}
fn max_distribution_logical(rows: &[DiskMapDistributionRow]) -> u64 {
rows.iter()
.map(|row| row.metrics.logical_bytes)
.max()
.unwrap_or(0)
}
fn distribution_title(screen: TuiScreen) -> &'static str {
match screen {
TuiScreen::Types => "Types: file kind distribution",
TuiScreen::Extensions => "Extensions: suffix distribution",
_ => "Distribution",
}
}
fn distribution_count_label(row: &DiskMapDistributionRow) -> String {
match (row.metrics.files, row.metrics.directories) {
(files, 0) => format_count(files, "file", "files"),
(0, directories) => format_count(directories, "directory", "directories"),
(files, directories) => format!(
"{}, {}",
format_count(files, "file", "files"),
format_count(directories, "directory", "directories")
),
}
}
fn distribution_share_label(row: &DiskMapDistributionRow) -> String {
if row.scope_logical_bytes == 0 {
return "0.0%".to_string();
}
format!(
"{:.1}%",
(row.metrics.logical_bytes as f64 / row.scope_logical_bytes as f64) * 100.0
)
}
fn byte_bar(value: u64, max: u64, width: usize) -> String {
if width == 0 {
return String::new();
}
let filled = if max == 0 {
0
} else {
((value as f64 / max as f64) * width as f64).round() as usize
}
.min(width);
format!("{}{}", "#".repeat(filled), ".".repeat(width - filled))
}
fn trim_to_width(value: String, width: usize) -> String {
if width == 0 {
return String::new();
}
if UnicodeWidthStr::width(value.as_str()) <= width {
return value;
}
let suffix = if width > 3 { "..." } else { "" };
let target_width = width.saturating_sub(UnicodeWidthStr::width(suffix));
let mut rendered_width = 0;
let mut trimmed = String::new();
for ch in value.chars() {
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if rendered_width + char_width > target_width {
break;
}
trimmed.push(ch);
rendered_width += char_width;
}
trimmed.push_str(suffix);
trimmed
}
#[cfg(test)]
mod tests {
use unicode_width::UnicodeWidthStr;
use super::*;
#[test]
fn trim_to_width_respects_display_width_for_cjk_text() {
let line = trim_to_width("缓存目录-with-a-very-long-name".to_string(), 12);
assert!(UnicodeWidthStr::width(line.as_str()) <= 12, "{line}");
assert!(line.ends_with("..."));
}
}