use std::path::Path;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row as TableRow, Table, Wrap};
use crate::rules::Kind;
use super::keymap::help;
use super::state::{Answer, Mark, Pending, Roll, View, plural};
use super::treemap;
use super::treemap::tiles;
use crate::size::human;
use crate::tree::{NodeId, Order, Sort};
use crate::walk::WalkError;
const SIZE: u16 = 12;
const AGE: u16 = 9;
const LABEL: u16 = 31;
const NARROW: u16 = 94;
const SPACING: u16 = 2;
const SHIMMER: usize = 5;
const BLOCKS: [&str; 7] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇"];
const LISTING: u16 = 92;
const MARK: usize = 2;
const KIND: usize = 18;
const FLAG: usize = 8;
const TAIL: usize = 11;
const BOX: usize = 3;
const MARKER: usize = BOX + 1;
const INDENT: usize = 2;
pub fn draw(frame: &mut Frame, view: &mut View, errors: &[WalkError]) -> Placed {
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(frame.area());
frame.render_widget(headline(view, errors), header);
let (body, map) = split_off_map(frame, view, body);
let (head, rows) = if body.height > 1 {
let [head, rows] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body);
(Some(head), rows)
} else {
(None, body)
};
let columns = columns(rows);
view.viewport(rows.height as usize);
if let Some(head) = head {
heading(frame, head, columns, view.sort());
}
frame.render_widget(tree(view, columns, rows.height as usize), rows);
frame.render_widget(status(view), footer);
let mut placed = Placed {
columns: Some(columns),
heading: head,
rows,
map,
notice: view.notice().is_some().then_some(footer),
scroll: view.scroll(),
overlay: None,
answers: None,
};
let mut prompt_at = None;
let mut confirm_at = None;
let mut help_at = None;
if view.prompt().is_some() {
prompt_at = Some(prompting(frame, view, footer));
}
if view.pending().is_some() {
confirm_at = Some(confirming(frame, view));
}
if view.help().is_some() {
help_at = Some(helping(frame, view));
}
placed.overlay = prompt_at.or(help_at).or(confirm_at.map(|(area, _)| area));
placed.answers = confirm_at.map(|(_, answers)| answers);
placed
}
fn split_off_map(frame: &mut Frame, view: &View, body: Rect) -> (Rect, Option<Rect>) {
if !view.maps() || body.height < treemap::MIN_HEIGHT {
return (body, None);
}
let Some(width) = treemap::Pane::width_in(body.width) else {
return (body, None);
};
let [tree, gap, map] = Layout::horizontal([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(width),
])
.areas(body);
let _ = gap;
let [caption, picture] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(map);
let said = tiles::focus(view).map_or_else(String::new, |root| tiles::caption(view, root));
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
said,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)))
.style(Style::default().bg(Color::Rgb(24, 24, 30))),
caption,
);
(tree, Some(picture))
}
fn prompting(frame: &mut Frame, view: &View, footer: Rect) -> Rect {
let Some(prompt) = view.prompt() else {
return footer;
};
let line = Line::from(vec![
Span::styled("/", Style::default().fg(Color::Yellow)),
Span::raw(prompt.text()),
match prompt.error() {
Some(err) => Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
None => Span::raw(""),
},
]);
frame.render_widget(Paragraph::new(line), footer);
frame.set_cursor_position((
footer.x + 1 + u16::try_from(prompt.caret()).unwrap_or(u16::MAX),
footer.y,
));
footer
}
fn confirming(frame: &mut Frame, view: &mut View) -> (Rect, [Rect; 2]) {
let Some(pending) = view.pending() else {
return (Rect::default(), [Rect::default(); 2]);
};
let mut lines = vec![Line::from(format!(
"Delete {}, giving back {}?",
plural(pending.targets.len(), "directory", "directories"),
human(pending.bytes)
))];
if pending.unpriced > 0 {
lines.push(Line::styled(
format!(
"{} of them carry no price yet, so the figure is a floor.",
pending.unpriced
),
Style::default().fg(Color::DarkGray),
));
}
let hidden = pending.hidden();
if hidden > 0 {
lines.push(Line::styled(
format!(
"{} out of sight under {} — deleting takes {} anyway.",
plural(hidden, "directory is", "directories are"),
pending.view,
if hidden == 1 { "it" } else { "them" }
),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
if pending.kept() > 0 {
lines.push(Line::styled(
format!(
"{} will be left alone by the safety model, marked below.",
pending.kept()
),
Style::default().fg(Color::Cyan),
));
}
let unrecoverable = pending.unrecoverable();
if unrecoverable > 0 {
lines.push(Line::styled(
format!(
"{} of them {} — {}. Listed first, and `space` takes one out.",
unrecoverable,
if unrecoverable == 1 { "is" } else { "are" },
Kind::Unrecoverable.cost_said()
),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
let inner_width = usize::from(LISTING.min(frame.area().width).saturating_sub(2));
let drawn_lines: usize = lines
.iter()
.map(|line| wrapped_rows(line, inner_width))
.sum();
let said = u16::try_from(drawn_lines).unwrap_or(4);
let wanted = u16::try_from(pending.entries().len()).unwrap_or(u16::MAX);
let area = centred(
frame.area(),
LISTING,
said.saturating_add(wanted).saturating_add(5),
);
let block = Block::default()
.borders(Borders::ALL)
.title(" this cannot be undone ")
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [top, batch, hint, asked] = Layout::vertical([
Constraint::Length(said.min(inner.height)),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), top);
view.listing(batch.height as usize);
let Some(pending) = view.pending() else {
return (area, [Rect::default(); 2]);
};
frame.render_widget(Paragraph::new(entries(pending, batch.width)), batch);
frame.render_widget(
Paragraph::new(Line::styled(
"↑↓ move · space take one out · ←→ choose · Enter answer",
Style::default().fg(Color::DarkGray),
)),
hint,
);
(area, buttons(frame, asked, pending.answer))
}
fn entries(pending: &Pending, width: u16) -> Vec<Line<'static>> {
let tail = usize::from(width).saturating_sub(MARK + KIND + FLAG + TAIL);
let mut drawn = Vec::new();
let mut group = None;
for (at, entry) in pending.entries().iter().enumerate() {
let heads = group != Some(entry.kind);
group = Some(entry.kind);
if at < pending.scroll() {
continue;
}
if drawn.len() >= pending.page() {
break;
}
let here = at == pending.at();
let kind = if heads {
entry.kind.map_or_else(
|| crate::walk::UNLABELLED.to_owned(),
|kind| kind.to_string(),
)
} else {
String::new()
};
let mut line = vec![
Span::styled(
if here { "› " } else { " " },
Style::default().fg(Color::White),
),
Span::styled(format!("{:<tail$}", shorten(&entry.path, tail)), {
let style = if entry.kept.is_none() && entry.kind == Some(Kind::Unrecoverable) {
Style::default().fg(Color::Red)
} else {
Style::default()
};
if here {
style.add_modifier(Modifier::BOLD)
} else {
style
}
}),
];
match &entry.kept {
Some(reason) => line.push(Span::styled(
format!("kept — {reason}"),
Style::default().fg(Color::Cyan),
)),
None => line.extend([
Span::styled(
format!("{kind:<KIND$}"),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{:<FLAG$}", if entry.hidden { "hidden" } else { "" }),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>TAIL$}", entry.size.label()),
Style::default().fg(Color::DarkGray),
),
]),
}
drawn.push(Line::from(line));
}
drawn
}
fn shorten(path: &Path, width: usize) -> String {
let said = path.display().to_string();
let held = said.chars().count();
if held <= width || width <= 1 {
return said;
}
let cut = held + 1 - width;
format!("…{}", said.chars().skip(cut).collect::<String>())
}
fn helping(frame: &mut Frame, view: &mut View) -> Rect {
let area = centred(frame.area(), 74, frame.area().height.saturating_sub(4));
let page = help_page();
let height = area.height.saturating_sub(2) as usize;
view.clamp_help(page.lines.len().saturating_sub(height));
let at = view.help().unwrap_or(0);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(page)
.scroll((u16::try_from(at).unwrap_or(u16::MAX), 0))
.block(
Block::default()
.borders(Borders::ALL)
.title(" keys — Esc or ? to close "),
),
area,
);
area
}
fn buttons(frame: &mut Frame, area: Rect, chosen: Answer) -> [Rect; 2] {
const GAP: u16 = 3;
let mut at = area.x;
Answer::ALL.map(|answer| {
let label = format!(" {} ", answer.label());
let width = u16::try_from(label.chars().count()).unwrap_or(u16::MAX);
let rect = Rect {
x: at,
y: area.y,
width: width.min(area.right().saturating_sub(at)),
height: 1,
};
at = at.saturating_add(width + GAP);
frame.render_widget(
Paragraph::new(Span::styled(label, answered(answer == chosen))),
rect,
);
rect
})
}
fn headline(view: &View, errors: &[WalkError]) -> Paragraph<'static> {
let total = view.drawn_total();
let mut spans = vec![
Span::styled(
format!(" {} ", view.tree().root_path().display()),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" {} reclaimable in {} ",
total.label(),
plural(total.claims, "directory", "directories")
)),
];
if total.unpriced > 0 {
spans.push(Span::styled(
if view.is_scanning() {
format!("· {} still being priced ", total.unpriced)
} else {
format!("· {} unpriced ", total.unpriced)
},
Style::default().fg(Color::DarkGray),
));
}
if view.is_scanning() {
spans.push(Span::styled(
"· scanning ",
Style::default().fg(Color::Cyan),
));
}
let out_of_view = view.out_of_view();
if out_of_view > 0 {
spans.push(Span::styled(
format!("· {out_of_view} out of view ({}) ", view.view_label()),
Style::default().fg(Color::Yellow),
));
}
if let Some(pattern) = view.filter() {
spans.push(Span::styled(
format!("· /{pattern} "),
Style::default().fg(Color::Yellow),
));
}
if !errors.is_empty() {
spans.push(Span::styled(
format!(
"· {} unread, so this is a floor ",
plural(errors.len(), "path", "paths")
),
Style::default().fg(Color::Red),
));
}
Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(32, 32, 40)))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Columns {
pub name: Rect,
pub size: Rect,
pub age: Rect,
pub label: Option<Rect>,
}
fn columns(area: Rect) -> Columns {
let wide = area.width >= NARROW;
let mut widths = vec![
Constraint::Min(20),
Constraint::Length(SIZE),
Constraint::Length(AGE),
];
if wide {
widths.push(Constraint::Length(LABEL));
}
let split = Layout::horizontal(widths).spacing(SPACING).split(area);
Columns {
name: split[0],
size: split[1],
age: split[2],
label: split.get(3).copied(),
}
}
fn heading(frame: &mut Frame, area: Rect, at: Columns, sort: Sort) {
frame.render_widget(
Block::default().style(Style::default().bg(Color::Rgb(24, 24, 30))),
area,
);
let named = |order: Order| {
let mut name = order.column().to_owned();
if sort.by == order {
name.push_str(if sort.reverse { " ↑" } else { " ↓" });
}
let style = if sort.by == order {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
Span::styled(name, style)
};
for (rect, order, align) in [
(at.name, Order::Path, Alignment::Left),
(at.size, Order::Size, Alignment::Right),
(at.age, Order::Age, Alignment::Right),
] {
frame.render_widget(
Paragraph::new(Line::from(named(order))).alignment(align),
Rect {
y: area.y,
height: 1,
..rect
},
);
}
if let Some(rect) = at.label {
frame.render_widget(
Paragraph::new(Span::styled(
"what it is",
Style::default().fg(Color::Rgb(70, 70, 84)),
)),
Rect {
y: area.y,
height: 1,
..rect
},
);
}
}
fn tree(view: &View, at: Columns, height: usize) -> Table<'static> {
let wide = at.label.is_some();
let rows: Vec<TableRow> = view
.rows()
.iter()
.enumerate()
.skip(view.scroll())
.take(height)
.map(|(index, row)| {
let node = view.tree().node(row.id);
let selected = view.cursor() == Some(index);
let mut cells = vec![
Cell::from(Line::from(name(view, row.id, row.depth))),
Cell::from(size_of(view, row.id)),
Cell::from(Text::from(age(node.modified)).alignment(Alignment::Right)),
];
if wide {
cells.push(aside(view, row.id));
}
let style = if selected {
Style::default()
.bg(Color::Rgb(48, 48, 64))
.add_modifier(Modifier::BOLD)
} else if view.is_spent(row.id) {
Style::default().fg(Color::DarkGray)
} else {
arrival(view, row.id)
};
TableRow::new(cells).style(style)
})
.collect();
let mut widths = vec![
Constraint::Length(at.name.width),
Constraint::Length(at.size.width),
Constraint::Length(at.age.width),
];
if let Some(label) = at.label {
widths.push(Constraint::Length(label.width));
}
Table::new(rows, widths).column_spacing(SPACING)
}
fn name(view: &View, id: NodeId, depth: usize) -> Vec<Span<'static>> {
let node = view.tree().node(id);
let name = if node.parent.is_none() {
node.path.display().to_string()
} else {
node.name.to_string_lossy().into_owned()
};
vec![
marker(view, id),
Span::raw(" ".repeat(INDENT * depth)),
Span::raw(if view.tree().children(id).is_empty() {
" "
} else if view.is_expanded(id) {
"▾ "
} else {
"▸ "
}),
Span::styled(
name,
if view.kept_reason(id).is_some() {
Style::default().fg(Color::Cyan)
} else if node.hit.is_some() {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Rgb(150, 160, 180))
},
),
]
}
fn marker(view: &View, id: NodeId) -> Span<'static> {
let (glyph, colour) = match view.mark_of(id) {
Mark::None => ("[ ] ".to_owned(), Color::DarkGray),
Mark::Partial => {
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a share in 0..1 scaled to one of seven glyphs, clamped either side"
)]
let step = (view.share(id) * BLOCKS.len() as f64)
.round()
.clamp(1.0, 7.0) as usize;
(format!("[{}] ", BLOCKS[step - 1]), Color::Yellow)
}
Mark::All => ("[x] ".to_owned(), Color::Green),
};
let style = if view.is_cascading(id) {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colour)
};
Span::styled(glyph, style)
}
fn size_of(view: &View, id: NodeId) -> Text<'static> {
if view.is_pricing(id) {
let lit = view.shimmer(SHIMMER);
return Text::from(Line::from(
(0..SHIMMER)
.map(|cell| {
if cell == lit {
Span::styled(
"━",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled("─", Style::default().fg(Color::Rgb(70, 78, 92)))
}
})
.collect::<Vec<_>>(),
))
.alignment(Alignment::Right);
}
let roll = view.drawn(id);
let style = if roll.unpriced > 0 && roll.bytes > 0 {
Style::default().fg(Color::Rgb(150, 160, 180))
} else {
Style::default()
};
Text::from(Line::styled(roll.label(), style)).alignment(Alignment::Right)
}
fn aside(view: &View, id: NodeId) -> Cell<'static> {
match view.kept_reason(id) {
Some(reason) => Cell::from(Span::styled(
format!("kept — {reason}"),
Style::default().fg(Color::Cyan),
)),
None => Cell::from(Span::styled(
label(view, id),
Style::default().fg(Color::DarkGray),
)),
}
}
fn arrival(view: &View, id: NodeId) -> Style {
let lit = view.freshness(id);
if lit <= 0.0 {
return Style::default();
}
let lit = lit.sqrt();
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a channel scaled by a factor between zero and one, clamped by construction"
)]
let channel = |peak: f64| (peak * lit).round() as u8;
Style::default().bg(Color::Rgb(channel(26.0), channel(62.0), channel(44.0)))
}
fn label(view: &View, id: NodeId) -> String {
match &view.tree().node(id).hit {
Some(hit) => hit.label().into_owned(),
None => String::new(),
}
}
fn age(modified: Option<std::time::SystemTime>) -> String {
let Some(modified) = modified else {
return crate::size::UNPRICED.to_owned();
};
let Ok(since) = std::time::SystemTime::now().duration_since(modified) else {
return "now".to_owned();
};
let days = since.as_secs() / 86_400;
match days {
0 => format!("{}h", since.as_secs() / 3_600),
1..=30 => format!("{days}d"),
31..=364 => format!("{}mo", days / 30),
_ => format!("{}y", days / 365),
}
}
fn status(view: &View) -> Paragraph<'static> {
let freed = view.has_freed().then(|| {
Span::styled(
format!("· freed {} ", human(view.drawn_freed())),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
});
let said = Style::default().fg(Color::Black).bg(Color::Yellow);
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(removing) = view.removing() {
spans.push(Span::styled(format!(" {} ", removing.label()), said));
if let Some(busiest) = removing.busiest() {
let shown = busiest
.strip_prefix(view.tree().root_path())
.unwrap_or(busiest);
spans.push(Span::styled(
format!(" {} ", shown.display()),
Style::default().fg(Color::DarkGray),
));
}
}
if let Some(notice) = view.notice() {
spans.push(Span::styled(format!(" {notice} "), said));
spans.push(Span::styled(
" Esc to dismiss",
Style::default().fg(Color::DarkGray),
));
}
if !spans.is_empty() {
spans.extend(freed);
return Paragraph::new(Line::from(spans));
}
let marked = view.marked();
let mut spans = vec![Span::styled(
format!(" {} ", counter(marked)),
if marked.claims == 0 {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
},
)];
let hidden = view.hidden();
if hidden > 0 {
spans.push(Span::styled(
format!("· {hidden} out of sight "),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
spans.extend(freed);
if view.is_deleting() {
spans.push(Span::styled(
"· deleting ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(
format!(
"· space mark · x delete · f view ({}) · / filter · s sort ({}{}) · ? help",
view.view_label(),
view.sort().by.label(),
if view.sort().reverse { " ↑" } else { "" }
),
Style::default().fg(Color::DarkGray),
));
Paragraph::new(Line::from(spans))
}
fn counter(marked: Roll) -> String {
if marked.claims == 0 {
return "nothing marked".to_owned();
}
let said = format!(
"marked {} in {}",
human(marked.bytes),
plural(marked.claims, "directory", "directories")
);
if marked.unpriced > 0 {
return format!("{said} (+{} unpriced)", marked.unpriced);
}
said
}
fn answered(highlighted: bool) -> Style {
if highlighted {
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
}
fn help_page() -> Text<'static> {
let mut lines = Vec::new();
for (title, rows) in help() {
if !lines.is_empty() {
lines.push(Line::raw(""));
}
lines.push(Line::styled(
title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
for (keys, what) in rows {
lines.push(Line::from(vec![
Span::styled(format!(" {keys:<18}"), Style::default().fg(Color::Yellow)),
Span::raw(what),
]));
}
}
Text::from(lines)
}
fn wrapped_rows(line: &Line<'_>, width: usize) -> usize {
if width == 0 {
return 1;
}
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let mut rows = 1;
let mut used = 0;
for word in text.split_whitespace() {
let len = word.chars().count();
if used == 0 {
used = len;
} else if used + 1 + len <= width {
used += 1 + len;
} else {
rows += 1;
used = len;
}
while used > width {
rows += 1;
used -= width;
}
}
rows
}
fn centred(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Placed {
pub columns: Option<Columns>,
pub heading: Option<Rect>,
pub rows: Rect,
pub map: Option<Rect>,
pub notice: Option<Rect>,
pub scroll: usize,
pub overlay: Option<Rect>,
pub answers: Option<[Rect; 2]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Zone {
Mark,
Open,
Name,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Spot {
Heading(Order),
Row {
id: NodeId,
zone: Zone,
},
Tree,
Help,
Answer(Answer),
Confirm,
Prompt,
Outside,
Notice,
Nowhere,
}
#[must_use]
pub fn hit(view: &View, placed: &Placed, at: Position) -> Spot {
if let Some(over) = placed.overlay {
if !over.contains(at) {
return Spot::Outside;
}
if view.prompt().is_some() {
return Spot::Prompt;
}
if view.help().is_some() {
return Spot::Help;
}
return answer_at(placed.answers, at).map_or(Spot::Confirm, Spot::Answer);
}
if placed.notice.is_some_and(|footer| footer.contains(at)) {
return Spot::Notice;
}
let Some(columns) = placed.columns else {
return Spot::Nowhere;
};
if placed.heading.is_some_and(|head| head.contains(at)) {
return heading_at(columns, at.x);
}
if placed.rows.contains(at) {
let index = placed.scroll + usize::from(at.y - placed.rows.y);
return match view.rows().get(index) {
Some(row) => Spot::Row {
id: row.id,
zone: zone_at(columns, row.depth, at.x),
},
None => Spot::Tree,
};
}
Spot::Nowhere
}
fn answer_at(answers: Option<[Rect; 2]>, at: Position) -> Option<Answer> {
Answer::ALL
.into_iter()
.zip(answers?)
.find(|(_, rect)| rect.contains(at))
.map(|(answer, _)| answer)
}
fn heading_at(at: Columns, x: u16) -> Spot {
if x < at.size.x {
return Spot::Heading(Order::Path);
}
if x < at.age.x {
return Spot::Heading(Order::Size);
}
match at.label {
Some(label) if x >= label.x => Spot::Nowhere,
_ => Spot::Heading(Order::Age),
}
}
fn zone_at(at: Columns, depth: usize, x: u16) -> Zone {
let offset = usize::from(x.saturating_sub(at.name.x));
if offset < BOX {
return Zone::Mark;
}
if offset == MARKER + INDENT * depth {
return Zone::Open;
}
Zone::Name
}
#[cfg(test)]
mod tests {
use super::treemap::Maps;
use super::{INDENT, MARKER, Placed, Spot, Zone, draw, hit as press_on};
use crate::delete::{Refusal, Refused};
use crate::fixture::{hit, priced};
use crate::rules::Kind;
use crate::size::Size;
use crate::tree::{Order, Tree};
use crate::tui::keymap::{Action, Gesture, Motion, Turn, pointer};
use crate::tui::moving::COUNT_UP;
use crate::tui::state::{Answer, Notice, Planned, View};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Position;
use std::path::Path;
fn open_everything(view: &mut View) {
view.apply(Action::Cursor(Motion::Top));
view.apply(Action::ToggleSubtree);
view.apply(Action::ToggleSubtree);
}
fn row_with<'a>(frame: &'a [String], needle: &str) -> &'a str {
frame
.iter()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no row mentions {needle}: {frame:#?}"))
}
fn painted(view: &mut View, width: u16, height: u16) -> Vec<String> {
frame_of(view, width, height).0
}
fn frame_of(view: &mut View, width: u16, height: u16) -> (Vec<String>, Placed) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut placed = Placed::default();
terminal
.draw(|frame| placed = draw(frame, view, &[]))
.unwrap();
let buffer = terminal.backend().buffer().clone();
let lines = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect();
(lines, placed)
}
fn view() -> View {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/node_modules", 2 * 1024 * 1024));
tree.insert(hit("/scan/old/target", Size::Unmeasured, 0));
View::new(tree)
}
#[test]
fn a_row_carries_its_marker_its_rollup_and_what_it_is() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
view.apply(Action::Expand);
let frame = painted(&mut view, 100, 8);
assert!(frame[0].contains("/scan"), "{frame:#?}");
assert!(
frame[0].contains("2.0 MiB reclaimable in 2 directories"),
"{frame:#?}"
);
let marked = frame.iter().find(|line| line.contains("nx")).unwrap();
assert!(marked.contains("[x]"), "{marked}");
assert!(marked.contains("▾"), "{marked}");
assert!(marked.contains("2.0 MiB"), "{marked}");
let claim = frame
.iter()
.find(|line| line.contains("node_modules"))
.unwrap();
assert!(claim.contains("Node Dependencies"), "{claim}");
assert!(!marked.contains("Node Dependencies"), "{marked}");
let unpriced = frame.iter().find(|line| line.contains("old")).unwrap();
assert!(unpriced.contains("—"), "{unpriced}");
assert!(!unpriced.contains("0 B"), "{unpriced}");
}
#[test]
fn the_footer_counts_what_is_marked() {
let mut view = view();
let frame = painted(&mut view, 100, 8);
assert!(frame[7].contains("nothing marked"), "{frame:#?}");
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
let frame = painted(&mut view, 100, 8);
assert!(
frame[7].contains("marked 2.0 MiB in 1 directory"),
"{frame:#?}"
);
}
#[test]
fn a_report_in_the_footer_says_how_to_get_rid_of_it() {
let mut view = view();
view.deleted(
Notice::passing("removed 2.0 MiB from 1 directory"),
2 * 1024 * 1024,
);
let frame = painted(&mut view, 100, 8);
assert!(
frame[7].contains("removed 2.0 MiB from 1 directory"),
"{frame:#?}"
);
assert!(frame[7].contains("Esc to dismiss"), "{frame:#?}");
}
#[test]
fn the_hint_sits_against_the_sentence_and_not_against_the_freed_total() {
let mut view = view();
view.deleted(
Notice::passing("removed 2.0 MiB from 1 directory"),
2 * 1024 * 1024,
);
view.animate(std::time::Instant::now() + COUNT_UP * 8);
let frame = painted(&mut view, 100, 8);
let line = &frame[7];
let hint = line.find("Esc to dismiss").expect("{line}");
let total = line.find("freed 2.0 MiB").expect("{line}");
assert!(hint < total, "{line}");
}
#[test]
fn the_footer_goes_back_to_its_keys_once_the_report_is_dismissed() {
let mut view = view();
view.deleted(
Notice::passing("removed 2.0 MiB from 1 directory"),
2 * 1024 * 1024,
);
painted(&mut view, 100, 8);
view.apply(Action::Back);
let frame = painted(&mut view, 100, 8);
assert!(!frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
assert!(!frame[7].contains("Esc to dismiss"), "{frame:#?}");
assert!(frame[7].contains("nothing marked"), "{frame:#?}");
assert!(frame[7].contains("space mark · x delete"), "{frame:#?}");
}
#[test]
fn pressing_on_a_report_dismisses_it_and_never_reaches_the_tree_behind_it() {
let mut view = view();
view.deleted(
Notice::passing("removed 2.0 MiB from 1 directory"),
2 * 1024 * 1024,
);
let (_, placed) = frame_of(&mut view, 100, 8);
let spot = press_on(&view, &placed, Position::new(4, 7));
assert_eq!(spot, Spot::Notice);
assert_eq!(pointer(Gesture::Click, spot), Action::Dismiss);
view.apply(Action::Back);
let (_, placed) = frame_of(&mut view, 100, 8);
assert_eq!(press_on(&view, &placed, Position::new(4, 7)), Spot::Nowhere);
}
#[test]
fn an_ancestor_of_a_mark_is_drawn_as_a_block_filled_to_the_marked_share() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Expand);
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
let frame = painted(&mut view, 100, 8);
let root = frame
.iter()
.find(|line| line.starts_with('[') && line.contains("/scan"))
.unwrap_or_else(|| panic!("{frame:#?}"));
assert!(root.contains("[▄]"), "{frame:#?}");
assert!(!root.contains("[x]"), "{frame:#?}");
}
#[test]
fn the_confirmation_says_what_it_will_delete_and_what_it_will_not() {
let mut view = view();
view.asking(
&[
Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
Planned::at("/scan/old/target", Size::Unmeasured),
],
&[Refused {
path: "/scan/gone".into(),
reason: Refusal::HoldsCheckout,
}],
);
let frame = painted(&mut view, 100, 20);
let box_text = frame.join("\n");
assert!(
box_text.contains("Delete 2 directories, giving back 2.0 MiB?"),
"{box_text}"
);
assert!(box_text.contains("carry no price"), "{box_text}");
assert!(box_text.contains("cancel"), "{box_text}");
assert!(box_text.contains("delete"), "{box_text}");
}
#[test]
fn the_confirmation_lists_the_batch_it_is_holding_grouped_by_what_each_thing_is() {
let mut view = view();
view.asking(
&[
Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
Planned::at("/scan/old/target", Size::Unmeasured),
],
&[Refused {
path: "/scan/gone".into(),
reason: Refusal::HoldsCheckout,
}],
);
let frame = painted(&mut view, 100, 24);
let box_text = frame.join("\n");
assert!(box_text.contains("/scan/nx/node_modules"), "{box_text}");
assert!(box_text.contains("/scan/old/target"), "{box_text}");
assert!(
box_text.contains("kept — holds a git checkout"),
"{box_text}"
);
assert!(
box_text.contains("will be left alone by the safety model"),
"{box_text}"
);
let listed: Vec<&String> = frame
.iter()
.filter(|line| line.contains("/scan/") && line.contains('│'))
.collect();
assert_eq!(listed.len(), 3, "{box_text}");
assert!(listed[0].contains("Dependencies"), "{box_text}");
assert!(!listed[1].contains("Dependencies"), "{box_text}");
assert!(listed[0].contains('›'), "{box_text}");
}
#[test]
fn a_marked_directory_the_view_is_hiding_says_so_on_its_own_line() {
let mut view = view();
view.apply(Action::ToggleKind(Kind::Dependencies));
view.asking(
&[Planned::at(
"/scan/nx/node_modules",
Size::Measured(2 * 1024 * 1024),
)],
&[],
);
let box_text = painted(&mut view, 100, 24).join("\n");
let unwrapped = box_text
.replace('│', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(unwrapped.contains("out of sight under"), "{box_text}");
assert!(unwrapped.contains("deleting takes it anyway"), "{box_text}");
let frame = painted(&mut view, 100, 24);
let listed = row_with(&frame, "/scan/nx/node_modules");
assert!(listed.contains("hidden"), "{listed}");
}
#[test]
fn the_help_overlay_is_the_keymap_itself() {
let mut view = view();
view.apply(Action::Help);
let frame = painted(&mut view, 100, 30).join("\n");
assert!(frame.contains("Everywhere"), "{frame}");
assert!(frame.contains("quit"), "{frame}");
assert!(frame.contains("mark this row's whole subtree"), "{frame}");
assert!(
frame.contains("delete what is marked — asks first"),
"{frame}"
);
assert!(frame.contains("show or hide the map"), "{frame}");
}
#[test]
fn the_help_page_names_the_gesture_that_gets_rid_of_a_report() {
let mut view = view();
view.apply(Action::Help);
let frame = painted(&mut view, 100, 110).join("\n");
assert!(frame.contains("what the footer is saying"), "{frame}");
assert!(frame.contains("dismiss what it says"), "{frame}");
}
#[test]
fn a_narrow_terminal_drops_the_label_column_rather_than_the_path() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Expand);
let frame = painted(&mut view, 48, 8);
let row = frame.iter().find(|line| line.contains("nx")).unwrap();
assert!(row.contains("2.0 MiB"), "{row}");
let claim = frame
.iter()
.find(|line| line.contains("node_modules"))
.unwrap();
assert!(!claim.contains("Dependencies"), "{claim}");
}
fn cell_of(frame: &[String], placed: &Placed, path: &str, glyph: &str) -> Position {
let y = frame
.iter()
.position(|line| line.contains(path))
.unwrap_or_else(|| panic!("{path} is not on the frame: {frame:#?}"));
let line = &frame[y];
let x = line
.char_indices()
.position(|(at, _)| line[at..].starts_with(glyph))
.unwrap_or_else(|| panic!("no {glyph} on {line:?}"));
let at = Position::new(u16::try_from(x).unwrap(), u16::try_from(y).unwrap());
assert!(
placed.rows.contains(at) || placed.heading.is_some_and(|head| head.contains(at)),
"{glyph} on {path} is at {at:?}, which the frame says is neither a row nor the heading"
);
at
}
#[test]
fn a_press_on_a_rows_glyphs_lands_on_the_part_of_it_that_was_drawn_there() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Expand);
let (frame, placed) = frame_of(&mut view, 100, 10);
let nx = view.tree().find(std::path::Path::new("/scan/nx")).unwrap();
assert_eq!(
press_on(&view, &placed, cell_of(&frame, &placed, "nx", "[")),
Spot::Row {
id: nx,
zone: Zone::Mark
}
);
assert_eq!(
press_on(&view, &placed, cell_of(&frame, &placed, "nx", "▾")),
Spot::Row {
id: nx,
zone: Zone::Open
}
);
assert_eq!(
press_on(&view, &placed, cell_of(&frame, &placed, "nx", "nx")),
Spot::Row {
id: nx,
zone: Zone::Name
}
);
}
#[test]
fn a_leaf_leaves_the_indicators_cell_blank_and_a_press_there_is_a_press_on_the_row() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Expand);
let (frame, placed) = frame_of(&mut view, 100, 10);
let leaf = view
.tree()
.find(std::path::Path::new("/scan/nx/node_modules"))
.unwrap();
let depth = view
.rows()
.iter()
.find(|row| row.id == leaf)
.map(|row| row.depth)
.unwrap();
let row = u16::try_from(
frame
.iter()
.position(|line| line.contains("node_modules"))
.unwrap(),
)
.unwrap();
let x = placed.columns.unwrap().name.x + u16::try_from(MARKER + INDENT * depth).unwrap();
assert_eq!(
frame[row as usize].chars().nth(usize::from(x)),
Some(' '),
"a leaf drew something in the indicator's cell: {:?}",
frame[row as usize]
);
assert_eq!(
press_on(&view, &placed, Position::new(x, row)),
Spot::Row {
id: leaf,
zone: Zone::Open
}
);
}
#[test]
fn a_press_on_a_column_heading_names_the_order_that_column_is_headed_with() {
let mut view = view();
let (frame, placed) = frame_of(&mut view, 100, 8);
let head = placed.heading.unwrap();
assert_eq!(head.y, 1, "{frame:#?}");
for (name, order) in [
("directory", Order::Path),
("size", Order::Size),
("age", Order::Age),
] {
let at = cell_of(&frame, &placed, "directory", name);
assert_eq!(
press_on(&view, &placed, at),
Spot::Heading(order),
"{name} at {at:?}: {:?}",
frame[1]
);
}
let label = placed.columns.unwrap().label.unwrap();
assert_eq!(
press_on(&view, &placed, Position::new(label.x, head.y)),
Spot::Nowhere
);
}
#[test]
fn a_press_past_the_last_row_is_the_pane_and_a_press_on_the_chrome_is_nothing() {
let mut view = view();
let (_, placed) = frame_of(&mut view, 100, 12);
assert_eq!(view.rows().len(), 3);
assert_eq!(
press_on(&view, &placed, Position::new(4, placed.rows.y + 5)),
Spot::Tree
);
assert_eq!(press_on(&view, &placed, Position::new(4, 0)), Spot::Nowhere);
assert_eq!(
press_on(&view, &placed, Position::new(4, 11)),
Spot::Nowhere
);
}
#[test]
fn an_overlay_takes_every_press_over_the_screen_it_covers() {
let mut view = view();
view.asking(
&[Planned::at(
"/scan/nx/node_modules",
Size::Measured(2 * 1024 * 1024),
)],
&[],
);
let (frame, placed) = frame_of(&mut view, 100, 20);
let [cancel, delete] = placed.answers.unwrap();
assert!(frame[delete.y as usize].contains("delete"), "{frame:#?}");
assert_eq!(
press_on(&view, &placed, Position::new(cancel.x + 2, cancel.y)),
Spot::Answer(Answer::Cancel)
);
assert_eq!(
press_on(&view, &placed, Position::new(delete.x + 2, delete.y)),
Spot::Answer(Answer::Delete)
);
assert_eq!(
press_on(&view, &placed, Position::new(cancel.right(), cancel.y)),
Spot::Confirm
);
assert_eq!(
press_on(&view, &placed, Position::new(delete.x, delete.y - 2)),
Spot::Confirm
);
assert_eq!(press_on(&view, &placed, Position::new(1, 3)), Spot::Outside);
}
#[test]
fn the_map_takes_its_columns_off_the_tree_and_only_when_there_is_room_for_both() {
let mut view = view();
let (_, plain) = frame_of(&mut view, 140, 20);
assert_eq!(plain.map, None);
assert_eq!(plain.rows.width, 140);
view.allow_maps(Maps::Can);
let (frame, placed) = frame_of(&mut view, 140, 20);
let map = placed.map.unwrap();
assert!(map.width >= 32, "{map:?}");
assert_eq!(placed.rows.right() + 1 + map.width, 140);
assert_eq!(placed.columns.unwrap().name.x, placed.rows.x);
assert!(frame[1].contains("/scan"), "{:?}", frame[1]);
assert_eq!(
press_on(&view, &placed, Position::new(map.x + 2, map.y + 2)),
Spot::Nowhere
);
}
#[test]
fn a_terminal_that_will_not_say_how_big_a_cell_is_reserves_no_pane_for_the_picture() {
let mut view = view();
view.allow_maps(Maps::Unmeasured);
let (_, placed) = frame_of(&mut view, 140, 20);
assert_eq!(placed.map, None);
assert_eq!(placed.rows.width, 140, "the tree paid for an empty pane");
}
#[test]
fn a_terminal_with_no_room_for_a_map_is_all_tree() {
let mut view = view();
view.allow_maps(Maps::Can);
assert_eq!(frame_of(&mut view, 99, 30).1.map, None);
assert_eq!(frame_of(&mut view, 140, 8).1.map, None);
view.apply(Action::ToggleMap);
assert_eq!(frame_of(&mut view, 140, 30).1.map, None);
}
#[test]
fn a_claim_a_pricing_thread_is_inside_shimmers_where_its_dash_would_be() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/one/node_modules", Size::Unmeasured, 0));
tree.insert(hit("/scan/two/target", Size::Unmeasured, 0));
let mut view = View::new(tree);
open_everything(&mut view);
view.pricing(Path::new("/scan/one/node_modules"));
let frame = painted(&mut view, 100, 10);
assert!(row_with(&frame, "node_modules").contains('━'), "{frame:#?}");
let queued = row_with(&frame, "target");
assert!(queued.contains('—'), "{frame:#?}");
assert!(!queued.contains('━'), "{frame:#?}");
}
#[test]
fn an_ancestor_that_is_still_being_priced_draws_its_number_as_a_floor() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/a/node_modules", 2 * 1024 * 1024));
tree.insert(hit("/scan/nx/b/target", Size::Unmeasured, 0));
let mut view = View::new(tree);
let frame = painted(&mut view, 100, 10);
assert!(row_with(&frame, "nx").contains("> 2.0 MiB"), "{frame:#?}");
}
#[test]
fn a_directory_a_removal_left_standing_is_marked_calmly_rather_than_as_an_error() {
let mut view = view();
view.refused(&[Refused {
path: "/scan/old/target".into(),
reason: Refusal::HoldsCheckout,
}]);
open_everything(&mut view);
let frame = painted(&mut view, 110, 10);
let kept = row_with(&frame, "target");
assert!(kept.contains("kept — holds a git checkout"), "{frame:#?}");
assert!(
!row_with(&frame, "node_modules").contains("kept"),
"{frame:#?}"
);
}
#[test]
fn the_footer_says_where_the_deleter_is_and_not_only_what_it_has_given_back() {
let mut view = view();
view.asking(
&[
Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
Planned::at("/scan/old/target", Size::Measured(0)),
Planned::at("/scan/gone", Size::Measured(0)),
Planned::at("/scan/also-gone", Size::Measured(0)),
],
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
view.removed(Path::new("/scan/nx/node_modules"), 1024 * 1024, true);
view.swept(Path::new("/scan/nx/node_modules"));
view.animate(std::time::Instant::now());
let frame = painted(&mut view, 100, 8);
assert!(
frame[7].contains("removing 1 of 4 directories"),
"{frame:#?}"
);
assert!(frame[7].contains("25%"), "{frame:#?}");
assert!(frame[7].contains("freed 1.0 MiB"), "{frame:#?}");
assert!(frame[7].contains("1.0 MiB of 2.0 MiB"), "{frame:#?}");
view.apply(Action::Quit);
let frame = painted(&mut view, 100, 8);
assert!(frame[7].contains("removing 1 of 4"), "{frame:#?}");
assert!(frame[7].contains("the removal has to finish"), "{frame:#?}");
}
#[test]
fn the_footer_names_the_target_the_batch_is_waiting_on() {
let mut view = view();
view.asking(
&[
Planned::at("/scan/nx/node_modules", Size::Measured(8 * 1024 * 1024)),
Planned::at("/scan/old/target", Size::Measured(1024)),
],
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
view.freeing(Path::new("/scan/old/target"), 900);
view.freeing(Path::new("/scan/nx/node_modules"), 512);
view.animate(std::time::Instant::now());
let frame = painted(&mut view, 120, 8);
assert!(
frame[7].contains("removing 0 of 2 directories"),
"{frame:#?}"
);
assert!(frame[7].contains("nx/node_modules"), "{frame:#?}");
assert!(!frame[7].contains("old/target"), "{frame:#?}");
}
#[test]
fn the_footer_keeps_the_freed_total_after_the_notice_has_moved_on() {
let mut view = view();
view.deleted(
Notice::passing("removed 2.0 MiB from 1 directory"),
2 * 1024 * 1024,
);
view.animate(std::time::Instant::now() + COUNT_UP * 8);
let frame = painted(&mut view, 100, 8);
assert!(frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
assert!(frame[7].contains("freed 2.0 MiB"), "{frame:#?}");
view.apply(Action::Back);
let frame = painted(&mut view, 100, 8);
assert!(!frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
assert!(frame[7].contains("freed 2.0 MiB"), "{frame:#?}");
}
#[test]
fn a_scan_that_could_not_read_everything_says_so_beside_its_own_numbers() {
let mut view = view();
let mut terminal = Terminal::new(TestBackend::new(120, 6)).unwrap();
let errors = vec![crate::walk::WalkError {
path: Some("/scan/locked".into()),
message: "Permission denied".to_owned(),
forbidden: true,
}];
terminal
.draw(|frame| {
draw(frame, &mut view, &errors);
})
.unwrap();
let header: String = (0..120)
.map(|x| terminal.backend().buffer()[(x, 0)].symbol())
.collect();
assert!(header.contains("1 path unread"), "{header}");
assert!(header.contains("floor"), "{header}");
}
}