pristine/tui/render.rs
1//! Drawing one frame, and reading back where it put things.
2//!
3//! Everything here reads the [`View`] and writes cells. The two things it writes *back* are
4//! measurements — how many rows the tree pane got, and how far the help page can scroll —
5//! and a [`Placed`], which is where the frame put everything a pointer can aim at.
6//!
7//! # What a row has to say
8//!
9//! Four things, in the order a reader needs them: how much of it is marked, where it is,
10//! what it is worth, and when it was last touched. The last two are npkill's columns and the
11//! first is what npkill's flat list cannot have.
12//!
13//! The size column has a fifth state the other tools never needed: **unpriced**. A claim is
14//! recorded without being measured unless somebody asks, so `0 B` and "nobody has looked" are
15//! different facts about a row, and a dash is what keeps them apart. It has a sixth as well,
16//! which is the one that moves: a claim a pricing thread is *inside at this instant* draws a
17//! shimmer through that dash rather than the dash. The pool is bounded, so however many rows
18//! shimmer is however many threads are working, and that is a number worth being able to see.
19//!
20//! # The layout is stated once and read twice
21//!
22//! [`hit`] resolves a cell to a [`Spot`], and it does that against the rectangles [`draw`]
23//! actually produced rather than against a second description of them. Being one cell out
24//! here is not a cosmetic bug: it is a press that opens, marks or prices a row nobody aimed
25//! at. So the tree's columns come out of one [`columns`] call that both lays the table out
26//! and answers the hit test, and a row's own cells — the mark box, the indent, the expander —
27//! are spelled by the constants [`name`] draws them with.
28//!
29//! # Nothing here decides what moves, only how it looks
30//!
31//! Every animated value is asked for by name — [`View::drawn`], [`View::freshness`],
32//! [`View::is_pricing`] — and the view has already advanced them all once for this frame. So
33//! this file has no clock in it and no state between frames, and the drawing stays a pure
34//! function of the view, which is what keeps it assertable against a [`TestBackend`] one
35//! property at a time.
36//!
37//! [`TestBackend`]: ratatui::backend::TestBackend
38
39use std::path::Path;
40
41use ratatui::Frame;
42use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
43use ratatui::style::{Color, Modifier, Style};
44use ratatui::text::{Line, Span, Text};
45use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row as TableRow, Table, Wrap};
46
47use crate::rules::Kind;
48
49use super::keymap::help;
50use super::state::{Answer, Mark, Pending, Roll, View, plural};
51use super::treemap;
52use super::treemap::tiles;
53use crate::size::human;
54use crate::tree::{NodeId, Order, Sort};
55use crate::walk::WalkError;
56
57/// Bytes the size column is given. Exactly `> 1023.9 GiB`, which is the widest a rolled-up
58/// lower bound gets.
59const SIZE: u16 = 12;
60/// Cells for `3 months`.
61const AGE: u16 = 9;
62/// Cells for the label, when the terminal is wide enough to carry one. Enough for the longest
63/// the shipped ruleset composes, `Haskell / Stack Build Artifacts`, and for the other thing
64/// this column carries: the reason a removal left a directory standing.
65const LABEL: u16 = 31;
66/// Below this the label column is dropped: a path a reader cannot read is worse than a name
67/// they can get at by selecting the row.
68const NARROW: u16 = 94;
69/// The blank cells between two columns.
70const SPACING: u16 = 2;
71/// How many cells the pricing shimmer travels across, in place of the dash.
72const SHIMMER: usize = 5;
73/// The eight steps a partial mark is filled to, one per eighth.
74///
75/// Never empty and never full: an empty box is [`Mark::None`] and a full one is [`Mark::All`],
76/// so a partial row is always somewhere strictly in between and the glyph says where.
77const BLOCKS: [&str; 7] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇"];
78
79/// How wide the confirmation is. Wider than the question it used to hold, because it now
80/// holds the batch: a path cut in half is a directory a reader cannot recognise, and
81/// recognising them is the whole job of the screen.
82const LISTING: u16 = 92;
83/// The cursor marker on a line of that listing.
84const MARK: usize = 2;
85/// Cells for the kind, which is named on the first line of each group.
86const KIND: usize = 18;
87/// Cells for the word that says the current view is hiding this entry.
88const FLAG: usize = 8;
89/// Cells for the size, or for the start of a refusal.
90const TAIL: usize = 11;
91
92/// The cells `[x]` occupies — the mark box, and the whole of what a press can aim at.
93const BOX: usize = 3;
94/// The box plus the blank that separates it from the indent.
95const MARKER: usize = BOX + 1;
96/// One level of indent.
97const INDENT: usize = 2;
98
99/// Draws the whole frame, and says where it put what a pointer can aim at.
100pub fn draw(frame: &mut Frame, view: &mut View, errors: &[WalkError]) -> Placed {
101 let [header, body, footer] = Layout::vertical([
102 Constraint::Length(1),
103 Constraint::Min(1),
104 Constraint::Length(1),
105 ])
106 .areas(frame.area());
107
108 frame.render_widget(headline(view, errors), header);
109 // The map takes its columns off the tree before anything else is laid out, so every
110 // rectangle below — the heading, the columns, the rows a press is resolved against — is
111 // of the tree's *own* pane rather than of the frame. Nothing is drawn into the map's
112 // cells here beyond its caption: the picture arrives afterwards, from outside ratatui,
113 // and cells this frame wrote would be cells the image covers.
114 let (body, map) = split_off_map(frame, view, body);
115 // The heading is a line of the body, so it comes out of the body's height before the
116 // view is told how many rows it has — a page size that counted the heading would scroll
117 // one row further than the pane can draw. A body with no room for both is all rows: a
118 // column name is worth less than the row it would cost.
119 let (head, rows) = if body.height > 1 {
120 let [head, rows] =
121 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body);
122 (Some(head), rows)
123 } else {
124 (None, body)
125 };
126 let columns = columns(rows);
127 view.viewport(rows.height as usize);
128 if let Some(head) = head {
129 heading(frame, head, columns, view.sort());
130 }
131 frame.render_widget(tree(view, columns, rows.height as usize), rows);
132 frame.render_widget(status(view), footer);
133
134 let mut placed = Placed {
135 columns: Some(columns),
136 heading: head,
137 rows,
138 map,
139 // Only on a frame that drew one. A footer saying what is marked is a line to read, not
140 // a button, so there is nothing there for a press to dismiss.
141 notice: view.notice().is_some().then_some(footer),
142 scroll: view.scroll(),
143 overlay: None,
144 answers: None,
145 };
146
147 // Where each overlay was drawn, kept rather than assigned as it is drawn: the drawing
148 // order is bottom-up and [`Placed::overlay`] holds the **topmost** one, so the two would
149 // disagree the moment a prompt is opened over a question.
150 let mut prompt_at = None;
151 let mut confirm_at = None;
152 let mut help_at = None;
153
154 if view.prompt().is_some() {
155 prompt_at = Some(prompting(frame, view, footer));
156 }
157 if view.pending().is_some() {
158 confirm_at = Some(confirming(frame, view));
159 }
160 if view.help().is_some() {
161 help_at = Some(helping(frame, view));
162 }
163
164 // The same ranking [`View::overlay`] gives the keyboard, so a click and a keystroke
165 // cannot be told that different surfaces are in front.
166 placed.overlay = prompt_at.or(help_at).or(confirm_at.map(|(area, _)| area));
167 placed.answers = confirm_at.map(|(_, answers)| answers);
168 placed
169}
170
171/// Takes the map's columns off the right of the body, when there is a map and room for one.
172///
173/// The caption is drawn here as ordinary terminal text rather than painted into the image:
174/// it is the one line of the pane a reader might want to select or copy, and text the
175/// terminal drew is text at the terminal's own font size.
176fn split_off_map(frame: &mut Frame, view: &View, body: Rect) -> (Rect, Option<Rect>) {
177 if !view.maps() || body.height < treemap::MIN_HEIGHT {
178 return (body, None);
179 }
180 let Some(width) = treemap::Pane::width_in(body.width) else {
181 return (body, None);
182 };
183 let [tree, gap, map] = Layout::horizontal([
184 Constraint::Min(1),
185 Constraint::Length(1),
186 Constraint::Length(width),
187 ])
188 .areas(body);
189 let _ = gap;
190 let [caption, picture] =
191 Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(map);
192 let said = tiles::focus(view).map_or_else(String::new, |root| tiles::caption(view, root));
193 frame.render_widget(
194 Paragraph::new(Line::from(Span::styled(
195 said,
196 Style::default()
197 .fg(Color::Cyan)
198 .add_modifier(Modifier::BOLD),
199 )))
200 .style(Style::default().bg(Color::Rgb(24, 24, 30))),
201 caption,
202 );
203 (tree, Some(picture))
204}
205
206/// The filter prompt, over the footer it borrows.
207fn prompting(frame: &mut Frame, view: &View, footer: Rect) -> Rect {
208 let Some(prompt) = view.prompt() else {
209 return footer;
210 };
211 let line = Line::from(vec![
212 Span::styled("/", Style::default().fg(Color::Yellow)),
213 Span::raw(prompt.text()),
214 match prompt.error() {
215 Some(err) => Span::styled(
216 format!(" {err}"),
217 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
218 ),
219 None => Span::raw(""),
220 },
221 ]);
222 frame.render_widget(Paragraph::new(line), footer);
223 // The caret is the terminal's own, so a reader's cursor is where they are typing rather
224 // than drawn as a block that their terminal's blink rate disagrees with.
225 frame.set_cursor_position((
226 footer.x + 1 + u16::try_from(prompt.caret()).unwrap_or(u16::MAX),
227 footer.y,
228 ));
229 footer
230}
231
232/// The question, the batch behind it, and where its two answers went.
233///
234/// # It lists what it is holding
235///
236/// The listing is the safety half of a selection that is independent of what is on screen. A
237/// reader marks broadly under one view, narrows, forgets, and would otherwise be confirming a
238/// deletion whose contents they cannot see — so the box shows every directory, grouped by what
239/// kind of artefact it is, says plainly which of them the current view is hiding, and lets any
240/// of them be taken out from here. It is drawn whether or not anything is hidden: a
241/// confirmation that can state its batch and instead states a number is a confirmation that
242/// has to be trusted rather than read.
243fn confirming(frame: &mut Frame, view: &mut View) -> (Rect, [Rect; 2]) {
244 let Some(pending) = view.pending() else {
245 return (Rect::default(), [Rect::default(); 2]);
246 };
247 let mut lines = vec![Line::from(format!(
248 "Delete {}, giving back {}?",
249 plural(pending.targets.len(), "directory", "directories"),
250 human(pending.bytes)
251 ))];
252 if pending.unpriced > 0 {
253 lines.push(Line::styled(
254 format!(
255 "{} of them carry no price yet, so the figure is a floor.",
256 pending.unpriced
257 ),
258 Style::default().fg(Color::DarkGray),
259 ));
260 }
261 let hidden = pending.hidden();
262 if hidden > 0 {
263 // The warning the whole screen exists for, and the reason it names the view: a reader
264 // who cannot see a row has no way to tell "hidden" from "never found" unless something
265 // says which.
266 lines.push(Line::styled(
267 format!(
268 "{} out of sight under {} — deleting takes {} anyway.",
269 plural(hidden, "directory is", "directories are"),
270 pending.view,
271 if hidden == 1 { "it" } else { "them" }
272 ),
273 Style::default()
274 .fg(Color::Yellow)
275 .add_modifier(Modifier::BOLD),
276 ));
277 }
278 if pending.kept() > 0 {
279 lines.push(Line::styled(
280 format!(
281 "{} will be left alone by the safety model, marked below.",
282 pending.kept()
283 ),
284 Style::default().fg(Color::Cyan),
285 ));
286 }
287 let unrecoverable = pending.unrecoverable();
288 if unrecoverable > 0 {
289 // The one line on this screen that is not about bytes. Everything else in a batch is
290 // regenerable — a cache is free, an output is a compile, dependencies are a fetch — so
291 // "this cannot be undone" in the title is, for every other row, undone by waiting for a
292 // rebuild. These are the rows where it is literally true, and a reader who marked one
293 // by accident has exactly one place left to notice.
294 lines.push(Line::styled(
295 format!(
296 "{} of them {} — {}. Listed first, and `space` takes one out.",
297 unrecoverable,
298 if unrecoverable == 1 { "is" } else { "are" },
299 Kind::Unrecoverable.cost_said()
300 ),
301 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
302 ));
303 }
304
305 // The box is as tall as it needs to be and no taller than the frame, with the listing
306 // taking whatever the fixed lines leave: a batch of four should not be drawn in a box
307 // sized for eight thousand.
308 //
309 // Counted in **drawn** lines rather than in written ones, and that is a correctness fix
310 // rather than a tidying one: these lines wrap, so a warning longer than the box was being
311 // given one row and silently losing its tail — and the tail is the half that says deleting
312 // takes the hidden entries anyway. The one warning here that has no bound on its length is
313 // the one naming the view, since a lens that names its axes is as long as its axes are.
314 let inner_width = usize::from(LISTING.min(frame.area().width).saturating_sub(2));
315 let drawn_lines: usize = lines
316 .iter()
317 .map(|line| wrapped_rows(line, inner_width))
318 .sum();
319 let said = u16::try_from(drawn_lines).unwrap_or(4);
320 let wanted = u16::try_from(pending.entries().len()).unwrap_or(u16::MAX);
321 let area = centred(
322 frame.area(),
323 LISTING,
324 said.saturating_add(wanted).saturating_add(5),
325 );
326 let block = Block::default()
327 .borders(Borders::ALL)
328 .title(" this cannot be undone ")
329 .border_style(Style::default().fg(Color::Red));
330 let inner = block.inner(area);
331 frame.render_widget(Clear, area);
332 frame.render_widget(block, area);
333 // The answers get a line of the box rather than a line of the paragraph, and that is a
334 // correctness change rather than a tidying one: the lines above them **wrap**, so one
335 // long warning would push a button a row down from where a press was told it is.
336 let [top, batch, hint, asked] = Layout::vertical([
337 Constraint::Length(said.min(inner.height)),
338 Constraint::Min(0),
339 Constraint::Length(1),
340 Constraint::Length(1),
341 ])
342 .areas(inner);
343 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), top);
344 view.listing(batch.height as usize);
345 let Some(pending) = view.pending() else {
346 return (area, [Rect::default(); 2]);
347 };
348 frame.render_widget(Paragraph::new(entries(pending, batch.width)), batch);
349 frame.render_widget(
350 Paragraph::new(Line::styled(
351 "↑↓ move · space take one out · ←→ choose · Enter answer",
352 Style::default().fg(Color::DarkGray),
353 )),
354 hint,
355 );
356 (area, buttons(frame, asked, pending.answer))
357}
358
359/// The batch, one line per directory, from the scroll offset down.
360///
361/// Grouped by kind by being **sorted** by kind and naming the kind on the first line of each
362/// run, which is the one arrangement where a group heading cannot disagree with the cursor:
363/// one entry is one line, so the index the keys move is the line a reader is looking at.
364fn entries(pending: &Pending, width: u16) -> Vec<Line<'static>> {
365 let tail = usize::from(width).saturating_sub(MARK + KIND + FLAG + TAIL);
366 let mut drawn = Vec::new();
367 let mut group = None;
368 for (at, entry) in pending.entries().iter().enumerate() {
369 let heads = group != Some(entry.kind);
370 group = Some(entry.kind);
371 if at < pending.scroll() {
372 continue;
373 }
374 // Bounded by the box rather than by the batch, which is the difference between
375 // drawing a screen and building one: a home directory's worth of marks is 8,660
376 // entries, and every line not drawn is a `Vec` of styled spans not allocated.
377 if drawn.len() >= pending.page() {
378 break;
379 }
380 let here = at == pending.at();
381 let kind = if heads {
382 entry.kind.map_or_else(
383 || crate::walk::UNLABELLED.to_owned(),
384 |kind| kind.to_string(),
385 )
386 } else {
387 String::new()
388 };
389 let mut line = vec![
390 Span::styled(
391 if here { "› " } else { " " },
392 Style::default().fg(Color::White),
393 ),
394 Span::styled(format!("{:<tail$}", shorten(&entry.path, tail)), {
395 // The group heading names the kind on the first line of a run only, so a
396 // reader who has scrolled into the middle of a long unrecoverable group
397 // would otherwise have nothing on the line telling them what it is. Red
398 // here and cyan for a refusal is the tree's own division: a refusal is the
399 // safety model working, and this is the safety model being overruled.
400 let style = if entry.kept.is_none() && entry.kind == Some(Kind::Unrecoverable) {
401 Style::default().fg(Color::Red)
402 } else {
403 Style::default()
404 };
405 if here {
406 style.add_modifier(Modifier::BOLD)
407 } else {
408 style
409 }
410 }),
411 ];
412 match &entry.kept {
413 // A refusal takes the whole of the right-hand side rather than the size column,
414 // for the reason it wins the last column on a row of the tree: it is the newer and
415 // the stranger fact, and a reason cut off half way through is a reason a reader
416 // cannot act on.
417 Some(reason) => line.push(Span::styled(
418 format!("kept — {reason}"),
419 Style::default().fg(Color::Cyan),
420 )),
421 None => line.extend([
422 Span::styled(
423 format!("{kind:<KIND$}"),
424 Style::default().fg(Color::DarkGray),
425 ),
426 Span::styled(
427 format!("{:<FLAG$}", if entry.hidden { "hidden" } else { "" }),
428 Style::default()
429 .fg(Color::Yellow)
430 .add_modifier(Modifier::BOLD),
431 ),
432 Span::styled(
433 format!("{:>TAIL$}", entry.size.label()),
434 Style::default().fg(Color::DarkGray),
435 ),
436 ]),
437 }
438 drawn.push(Line::from(line));
439 }
440 drawn
441}
442
443/// A path cut to `width` from the **left**, because the end of a path is the part that says
444/// which directory this is.
445fn shorten(path: &Path, width: usize) -> String {
446 let said = path.display().to_string();
447 let held = said.chars().count();
448 if held <= width || width <= 1 {
449 return said;
450 }
451 let cut = held + 1 - width;
452 format!("…{}", said.chars().skip(cut).collect::<String>())
453}
454
455/// The generated key reference, and how far down it the reader is.
456fn helping(frame: &mut Frame, view: &mut View) -> Rect {
457 let area = centred(frame.area(), 74, frame.area().height.saturating_sub(4));
458 let page = help_page();
459 let height = area.height.saturating_sub(2) as usize;
460 view.clamp_help(page.lines.len().saturating_sub(height));
461 let at = view.help().unwrap_or(0);
462 frame.render_widget(Clear, area);
463 frame.render_widget(
464 Paragraph::new(page)
465 .scroll((u16::try_from(at).unwrap_or(u16::MAX), 0))
466 .block(
467 Block::default()
468 .borders(Borders::ALL)
469 .title(" keys — Esc or ? to close "),
470 ),
471 area,
472 );
473 area
474}
475
476/// A confirmation's two answers, drawn into a line of their own.
477///
478/// Returns where they went, in [`Answer::ALL`] order, from the same widths they were drawn
479/// with — so "which button is this" is decided once and read by both halves.
480fn buttons(frame: &mut Frame, area: Rect, chosen: Answer) -> [Rect; 2] {
481 /// The blank between the two, which belongs to neither: a press that lands here is a
482 /// press on the box, and the box does nothing.
483 const GAP: u16 = 3;
484
485 let mut at = area.x;
486 Answer::ALL.map(|answer| {
487 let label = format!(" {} ", answer.label());
488 let width = u16::try_from(label.chars().count()).unwrap_or(u16::MAX);
489 let rect = Rect {
490 x: at,
491 y: area.y,
492 width: width.min(area.right().saturating_sub(at)),
493 height: 1,
494 };
495 at = at.saturating_add(width + GAP);
496 frame.render_widget(
497 Paragraph::new(Span::styled(label, answered(answer == chosen))),
498 rect,
499 );
500 rect
501 })
502}
503
504/// The line across the top: where the scan is, what it has found, and whether it is done.
505fn headline(view: &View, errors: &[WalkError]) -> Paragraph<'static> {
506 let total = view.drawn_total();
507 let mut spans = vec![
508 Span::styled(
509 format!(" {} ", view.tree().root_path().display()),
510 Style::default().add_modifier(Modifier::BOLD),
511 ),
512 // The drawn total rather than the true one, so it climbs as the scan finds and falls
513 // as a removal frees. Which of the two it is doing is the one thing this line cannot
514 // say in words and can say by moving.
515 Span::raw(format!(
516 " {} reclaimable in {} ",
517 total.label(),
518 plural(total.claims, "directory", "directories")
519 )),
520 ];
521 if total.unpriced > 0 {
522 // Two different facts, and saying the wrong one is a small lie a reader would catch:
523 // while the walk is running an unpriced claim is one the pool has not reached yet,
524 // and afterwards — under `--breakdown-under` — it is one nobody is ever going to
525 // price.
526 spans.push(Span::styled(
527 if view.is_scanning() {
528 format!("· {} still being priced ", total.unpriced)
529 } else {
530 format!("· {} unpriced ", total.unpriced)
531 },
532 Style::default().fg(Color::DarkGray),
533 ));
534 }
535 if view.is_scanning() {
536 spans.push(Span::styled(
537 "· scanning ",
538 Style::default().fg(Color::Cyan),
539 ));
540 }
541 // What the view is leaving out, beside the number it qualifies. A run opens on `default`,
542 // which hides the gitignored tier, so the headline is a narrowed answer to "how much do I
543 // get back" from the first frame — and a narrowed number that does not say so is the
544 // failure `--older-than` being off by default already avoids, wearing a different hat.
545 let out_of_view = view.out_of_view();
546 if out_of_view > 0 {
547 spans.push(Span::styled(
548 format!("· {out_of_view} out of view ({}) ", view.view_label()),
549 Style::default().fg(Color::Yellow),
550 ));
551 }
552 if let Some(pattern) = view.filter() {
553 spans.push(Span::styled(
554 format!("· /{pattern} "),
555 Style::default().fg(Color::Yellow),
556 ));
557 }
558 if !errors.is_empty() {
559 // The listing's rule, kept: a scan that could not read everything says so beside the
560 // numbers it qualifies, because a lower bound that looks like a total is the one
561 // wrong answer a cleaner must not give.
562 spans.push(Span::styled(
563 format!(
564 "· {} unread, so this is a floor ",
565 plural(errors.len(), "path", "paths")
566 ),
567 Style::default().fg(Color::Red),
568 ));
569 }
570 Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(32, 32, 40)))
571}
572
573/// Where the tree's columns went, laid out once for the table and for the hit test.
574///
575/// `Length` constraints derived from these are what the [`Table`] is given, so the widget's
576/// own split reproduces this one exactly rather than merely agreeing with it — which is the
577/// difference between a heading a click sorts by and a heading a click sorts *near*.
578///
579/// The rectangles cover the tree's **rows**. The heading is a line above them and takes only
580/// the horizontal extent, which is the whole of what a column is: `x` and `width` are the
581/// column, and `y` is whichever band asked for it.
582#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
583pub struct Columns {
584 /// The mark box, the indent, the expander and the name.
585 pub name: Rect,
586 /// What the subtree is worth.
587 pub size: Rect,
588 /// How long ago it was touched.
589 pub age: Rect,
590 /// What a claim is, when the terminal is wide enough to carry the column.
591 pub label: Option<Rect>,
592}
593
594/// Splits the tree pane into its columns.
595fn columns(area: Rect) -> Columns {
596 // A path a reader cannot read is worse than a name they can get at by selecting the row.
597 let wide = area.width >= NARROW;
598 let mut widths = vec![
599 Constraint::Min(20),
600 Constraint::Length(SIZE),
601 Constraint::Length(AGE),
602 ];
603 if wide {
604 widths.push(Constraint::Length(LABEL));
605 }
606 let split = Layout::horizontal(widths).spacing(SPACING).split(area);
607 Columns {
608 name: split[0],
609 size: split[1],
610 age: split[2],
611 label: split.get(3).copied(),
612 }
613}
614
615/// The line of column names above the rows — and the one thing on the screen a click sorts by.
616fn heading(frame: &mut Frame, area: Rect, at: Columns, sort: Sort) {
617 frame.render_widget(
618 Block::default().style(Style::default().bg(Color::Rgb(24, 24, 30))),
619 area,
620 );
621 let named = |order: Order| {
622 let mut name = order.column().to_owned();
623 if sort.by == order {
624 // The footer's own arrow, so the two places that say which way the tree is
625 // sorted say it the same way.
626 name.push_str(if sort.reverse { " ↑" } else { " ↓" });
627 }
628 let style = if sort.by == order {
629 Style::default()
630 .fg(Color::Cyan)
631 .add_modifier(Modifier::BOLD)
632 } else {
633 Style::default().fg(Color::DarkGray)
634 };
635 Span::styled(name, style)
636 };
637 for (rect, order, align) in [
638 (at.name, Order::Path, Alignment::Left),
639 (at.size, Order::Size, Alignment::Right),
640 (at.age, Order::Age, Alignment::Right),
641 ] {
642 frame.render_widget(
643 Paragraph::new(Line::from(named(order))).alignment(align),
644 Rect {
645 y: area.y,
646 height: 1,
647 ..rect
648 },
649 );
650 }
651 if let Some(rect) = at.label {
652 // Named but not a button: an ancestor row has no label of its own, so a level sorted
653 // by this column would sort most of itself by a blank. A press here deliberately does
654 // nothing rather than reversing a neighbour the reader did not aim at. See
655 // [`heading_at`].
656 frame.render_widget(
657 Paragraph::new(Span::styled(
658 "what it is",
659 Style::default().fg(Color::Rgb(70, 70, 84)),
660 )),
661 Rect {
662 y: area.y,
663 height: 1,
664 ..rect
665 },
666 );
667 }
668}
669
670/// The tree itself: one table row per *visible* row, from the scroll offset down.
671///
672/// Bounded by the pane rather than by the tree, which is the difference between drawing a screen
673/// and building one. A home directory fully expanded is 32,634 rows; the widget would draw the
674/// first `height` of them either way, but every row handed to it is a `Vec` of styled spans
675/// allocated first and thrown away second.
676fn tree(view: &View, at: Columns, height: usize) -> Table<'static> {
677 let wide = at.label.is_some();
678 let rows: Vec<TableRow> = view
679 .rows()
680 .iter()
681 .enumerate()
682 .skip(view.scroll())
683 .take(height)
684 .map(|(index, row)| {
685 let node = view.tree().node(row.id);
686 let selected = view.cursor() == Some(index);
687 let mut cells = vec![
688 Cell::from(Line::from(name(view, row.id, row.depth))),
689 Cell::from(size_of(view, row.id)),
690 Cell::from(Text::from(age(node.modified)).alignment(Alignment::Right)),
691 ];
692 if wide {
693 cells.push(aside(view, row.id));
694 }
695 let style = if selected {
696 Style::default()
697 .bg(Color::Rgb(48, 48, 64))
698 .add_modifier(Modifier::BOLD)
699 } else if view.is_spent(row.id) {
700 // Emptied, and on its way out — dimmed only once its number has reached zero,
701 // never while it is still falling. A row dimmed throughout would be saying
702 // "this is over" during the seconds it is actually happening, which is the
703 // opposite of what the falling number is for. Dim rather than struck through
704 // or coloured: the directory is gone and the row is a receipt now, so it
705 // should recede rather than compete.
706 Style::default().fg(Color::DarkGray)
707 } else {
708 arrival(view, row.id)
709 };
710 TableRow::new(cells).style(style)
711 })
712 .collect();
713
714 // The widths the split above produced, handed back as fixed lengths: with every
715 // constraint a `Length` the table's own `Layout::horizontal` can only reproduce them,
716 // which is what makes [`Columns`] the geometry rather than a guess about it.
717 let mut widths = vec![
718 Constraint::Length(at.name.width),
719 Constraint::Length(at.size.width),
720 Constraint::Length(at.age.width),
721 ];
722 if let Some(label) = at.label {
723 widths.push(Constraint::Length(label.width));
724 }
725 Table::new(rows, widths).column_spacing(SPACING)
726}
727
728/// A row's marker, indent, expander and name, as one run of spans.
729///
730/// The cell offsets are [`BOX`], [`MARKER`] and [`INDENT`] rather than literals, because
731/// [`zone_at`] resolves a press by them: a mark box drawn one cell wider than the hit test
732/// believes is a click that selects where it meant to mark.
733fn name(view: &View, id: NodeId, depth: usize) -> Vec<Span<'static>> {
734 let node = view.tree().node(id);
735 let name = if node.parent.is_none() {
736 node.path.display().to_string()
737 } else {
738 node.name.to_string_lossy().into_owned()
739 };
740 vec![
741 marker(view, id),
742 // Spelled with [`INDENT`] rather than a literal pair of spaces, because [`zone_at`]
743 // resolves a press by that same constant: an indent drawn a cell wider than the hit
744 // test believes is a click that lands on the row beside the one it aimed at.
745 Span::raw(" ".repeat(INDENT * depth)),
746 Span::raw(if view.tree().children(id).is_empty() {
747 " "
748 } else if view.is_expanded(id) {
749 "▾ "
750 } else {
751 "▸ "
752 }),
753 Span::styled(
754 name,
755 if view.kept_reason(id).is_some() {
756 // A directory the safety model refused. Distinct, and deliberately not red:
757 // this is the tool working, and teaching a reader to read correct behaviour
758 // as a failure is worse than not marking it at all.
759 Style::default().fg(Color::Cyan)
760 } else if node.hit.is_some() {
761 Style::default().fg(Color::White)
762 } else {
763 Style::default().fg(Color::Rgb(150, 160, 180))
764 },
765 ),
766 ]
767}
768
769/// The mark box: empty, full, or a block filled to the marked share of the subtree.
770///
771/// The fractional block is what makes a partial ancestor worth reading rather than merely
772/// noticing. `[~]` says "some of this"; `[▆]` says most of the bytes under here are spoken
773/// for, which is a real number in one character and the thing a reader needs in order to
774/// decide whether opening the row is worth it.
775fn marker(view: &View, id: NodeId) -> Span<'static> {
776 let (glyph, colour) = match view.mark_of(id) {
777 Mark::None => ("[ ] ".to_owned(), Color::DarkGray),
778 Mark::Partial => {
779 #[expect(
780 clippy::cast_precision_loss,
781 clippy::cast_possible_truncation,
782 clippy::cast_sign_loss,
783 reason = "a share in 0..1 scaled to one of seven glyphs, clamped either side"
784 )]
785 let step = (view.share(id) * BLOCKS.len() as f64)
786 .round()
787 .clamp(1.0, 7.0) as usize;
788 (format!("[{}] ", BLOCKS[step - 1]), Color::Yellow)
789 }
790 Mark::All => ("[x] ".to_owned(), Color::Green),
791 };
792 let style = if view.is_cascading(id) {
793 // The mark passing through on its way up. Reversed rather than merely brighter,
794 // because it is on screen for a sixth of a second and has to be caught rather than
795 // studied.
796 Style::default()
797 .fg(Color::Black)
798 .bg(Color::Green)
799 .add_modifier(Modifier::BOLD)
800 } else {
801 Style::default().fg(colour)
802 };
803 Span::styled(glyph, style)
804}
805
806/// The size column, which is where most of the movement is.
807///
808/// Three states rather than the two a listing has. A claim a pricing thread is inside right
809/// now gets the shimmer; everything else gets its number, climbing toward the truth, with a
810/// `>` in front of it while any of what it is summing is still unpriced.
811fn size_of(view: &View, id: NodeId) -> Text<'static> {
812 if view.is_pricing(id) {
813 let lit = view.shimmer(SHIMMER);
814 return Text::from(Line::from(
815 (0..SHIMMER)
816 .map(|cell| {
817 if cell == lit {
818 Span::styled(
819 "━",
820 Style::default()
821 .fg(Color::Cyan)
822 .add_modifier(Modifier::BOLD),
823 )
824 } else {
825 Span::styled("─", Style::default().fg(Color::Rgb(70, 78, 92)))
826 }
827 })
828 .collect::<Vec<_>>(),
829 ))
830 .alignment(Alignment::Right);
831 }
832 let roll = view.drawn(id);
833 let style = if roll.unpriced > 0 && roll.bytes > 0 {
834 // A floor is a different kind of number from a total, and it reads as one.
835 Style::default().fg(Color::Rgb(150, 160, 180))
836 } else {
837 Style::default()
838 };
839 Text::from(Line::styled(roll.label(), style)).alignment(Alignment::Right)
840}
841
842/// The last column: what brings this row back, or why a removal left it standing.
843///
844/// The refusal wins, because it is the newer and the more surprising fact. A reader who marked
845/// forty directories and got thirty-eight needs to see which two on the rows themselves. The
846/// footer waits to be dismissed when it is naming a refusal — that is what a standing
847/// [`Notice`](crate::tui::state::Notice) is for — but it still has only one line, and one line
848/// can say how many were left alone without ever saying which.
849fn aside(view: &View, id: NodeId) -> Cell<'static> {
850 match view.kept_reason(id) {
851 Some(reason) => Cell::from(Span::styled(
852 format!("kept — {reason}"),
853 Style::default().fg(Color::Cyan),
854 )),
855 None => Cell::from(Span::styled(
856 label(view, id),
857 Style::default().fg(Color::DarkGray),
858 )),
859 }
860}
861
862/// How lit a row is because the walk has just found it.
863///
864/// A decaying wash rather than a permanent colour, and it is the alternative to a scrolling
865/// log: the eye is drawn to what is new without the tree having to give up being a tree. It
866/// fades on a square root rather than linearly, so it holds long enough to be *looked at*
867/// after it is noticed instead of already going by the time the eye lands.
868fn arrival(view: &View, id: NodeId) -> Style {
869 let lit = view.freshness(id);
870 if lit <= 0.0 {
871 return Style::default();
872 }
873 let lit = lit.sqrt();
874 #[expect(
875 clippy::cast_possible_truncation,
876 clippy::cast_sign_loss,
877 reason = "a channel scaled by a factor between zero and one, clamped by construction"
878 )]
879 let channel = |peak: f64| (peak * lit).round() as u8;
880 Style::default().bg(Color::Rgb(channel(26.0), channel(62.0), channel(44.0)))
881}
882
883/// What this row is, when anything knows.
884///
885/// Only on a claim: an ancestor covers several rules at once, and a directory holding a
886/// `node_modules` and a `target` is not one artefact. The tier-two gap is carried through
887/// rather than papered over — a row that says only "gitignored" is a row nothing has named.
888fn label(view: &View, id: NodeId) -> String {
889 match &view.tree().node(id).hit {
890 Some(hit) => hit.label().into_owned(),
891 None => String::new(),
892 }
893}
894
895/// How long ago, in the coarsest unit that is still true.
896fn age(modified: Option<std::time::SystemTime>) -> String {
897 let Some(modified) = modified else {
898 return crate::size::UNPRICED.to_owned();
899 };
900 let Ok(since) = std::time::SystemTime::now().duration_since(modified) else {
901 // A directory stamped in the future: a clock that has been put back, or a filesystem
902 // that never had one. "now" is the honest reading and it is also the safe one, since
903 // an age floor is a reason to keep something.
904 return "now".to_owned();
905 };
906 let days = since.as_secs() / 86_400;
907 match days {
908 0 => format!("{}h", since.as_secs() / 3_600),
909 1..=30 => format!("{days}d"),
910 31..=364 => format!("{}mo", days / 30),
911 _ => format!("{}y", days / 365),
912 }
913}
914
915/// The line across the bottom: what is marked, or what just happened.
916///
917/// A notice takes the keys away, so it carries its own way out: a reader who cannot see
918/// `space mark · x delete · … · ? help` has nothing else on the screen telling them the sentence
919/// in front of them can be got rid of. The hint is drawn dim, in the same grammar the keys it
920/// replaced are written in, and directly against the sentence it applies to — the two figures
921/// that share this line, where the removal has got to and what the session has freed, are not
922/// things `Esc` takes away.
923fn status(view: &View) -> Paragraph<'static> {
924 // The freed counter outlives the notice, because it is the answer to the question the
925 // reader who walked away came back for. It is also the *other* of the two numbers a
926 // removal moves — the header falls, this rises — and the pair of them is the whole payoff
927 // of the one irreversible thing this tool does. Nothing decorates it.
928 let freed = view.has_freed().then(|| {
929 Span::styled(
930 format!("· freed {} ", human(view.drawn_freed())),
931 Style::default()
932 .fg(Color::Green)
933 .add_modifier(Modifier::BOLD),
934 )
935 });
936 let said = Style::default().fg(Color::Black).bg(Color::Yellow);
937 // Where the deleter is, which the freed counter beside it cannot say: bytes report how
938 // much has gone and nothing about how much is left, so a count against the batch's own
939 // size is what tells a third of the way through from nearly finished.
940 //
941 // **Beside a notice rather than instead of one.** The only thing that speaks while a
942 // removal is running is `q`, which is held back until the batch finishes and says so — and
943 // a reader who pressed a key and got nothing back has no way to tell a refusal from a
944 // terminal that stopped listening.
945 let mut spans: Vec<Span<'static>> = Vec::new();
946 if let Some(removing) = view.removing() {
947 spans.push(Span::styled(format!(" {} ", removing.label()), said));
948 // The target the batch is waiting on, named where the reader is already looking for
949 // news of it. Dimmed and after the counts, because it is the answer to "what is taking
950 // so long" rather than to "how far through is this" — and a path long enough to push
951 // the counts off the line would trade the second question for the first.
952 if let Some(busiest) = removing.busiest() {
953 let shown = busiest
954 .strip_prefix(view.tree().root_path())
955 .unwrap_or(busiest);
956 spans.push(Span::styled(
957 format!(" {} ", shown.display()),
958 Style::default().fg(Color::DarkGray),
959 ));
960 }
961 }
962 if let Some(notice) = view.notice() {
963 // One colour for every notice, refusals included. A sentence that says a subtree was
964 // left alone is the safety model *working*, and drawing it as an alarm would teach a
965 // reader that correct behaviour is a failure — the same reason a kept row goes cyan
966 // and calm rather than red. What a standing notice does differently is **wait**, and
967 // waiting is not a thing a colour can say.
968 spans.push(Span::styled(format!(" {notice} "), said));
969 // Against the sentence rather than at the end of the line: what follows is the freed
970 // counter, and `Esc` does not take that away.
971 spans.push(Span::styled(
972 " Esc to dismiss",
973 Style::default().fg(Color::DarkGray),
974 ));
975 }
976 if !spans.is_empty() {
977 spans.extend(freed);
978 return Paragraph::new(Line::from(spans));
979 }
980 let marked = view.marked();
981 let mut spans = vec![Span::styled(
982 format!(" {} ", counter(marked)),
983 if marked.claims == 0 {
984 Style::default().fg(Color::DarkGray)
985 } else {
986 Style::default()
987 .fg(Color::Green)
988 .add_modifier(Modifier::BOLD)
989 },
990 )];
991 // The hazard orthogonal selection creates, on the line a reader is already reading. The
992 // counter above states the whole selection because that is what `x` acts on, and a reader
993 // who cannot see part of it has to be told so *here* rather than only in the box that
994 // comes after they have decided.
995 let hidden = view.hidden();
996 if hidden > 0 {
997 spans.push(Span::styled(
998 format!("· {hidden} out of sight "),
999 Style::default()
1000 .fg(Color::Yellow)
1001 .add_modifier(Modifier::BOLD),
1002 ));
1003 }
1004 spans.extend(freed);
1005 if view.is_deleting() {
1006 spans.push(Span::styled(
1007 "· deleting ",
1008 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1009 ));
1010 }
1011 spans.push(Span::styled(
1012 format!(
1013 "· space mark · x delete · f view ({}) · / filter · s sort ({}{}) · ? help",
1014 // Named rather than left implicit, and named as the *reader* left it: a view the
1015 // axis keys built has no preset name, and rounding it to the nearest one would say
1016 // they are somewhere they are not.
1017 view.view_label(),
1018 view.sort().by.label(),
1019 if view.sort().reverse { " ↑" } else { "" }
1020 ),
1021 Style::default().fg(Color::DarkGray),
1022 ));
1023 Paragraph::new(Line::from(spans))
1024}
1025
1026/// npkill's selection counter, over subtrees rather than rows.
1027fn counter(marked: Roll) -> String {
1028 if marked.claims == 0 {
1029 return "nothing marked".to_owned();
1030 }
1031 let said = format!(
1032 "marked {} in {}",
1033 human(marked.bytes),
1034 plural(marked.claims, "directory", "directories")
1035 );
1036 if marked.unpriced > 0 {
1037 // The count rather than a bigger number, because there is no bigger number to give:
1038 // an unpriced claim's bytes are not a small contribution, they are an unknown one.
1039 return format!("{said} (+{} unpriced)", marked.unpriced);
1040 }
1041 said
1042}
1043
1044/// How one of a confirmation's two answers is drawn, highlighted or not.
1045fn answered(highlighted: bool) -> Style {
1046 if highlighted {
1047 Style::default()
1048 .fg(Color::Black)
1049 .bg(Color::White)
1050 .add_modifier(Modifier::BOLD)
1051 } else {
1052 Style::default().fg(Color::DarkGray)
1053 }
1054}
1055
1056/// The help page, generated from the keymap so it cannot drift from what the keys do.
1057fn help_page() -> Text<'static> {
1058 let mut lines = Vec::new();
1059 for (title, rows) in help() {
1060 if !lines.is_empty() {
1061 lines.push(Line::raw(""));
1062 }
1063 lines.push(Line::styled(
1064 title,
1065 Style::default()
1066 .fg(Color::Cyan)
1067 .add_modifier(Modifier::BOLD),
1068 ));
1069 for (keys, what) in rows {
1070 lines.push(Line::from(vec![
1071 Span::styled(format!(" {keys:<18}"), Style::default().fg(Color::Yellow)),
1072 Span::raw(what),
1073 ]));
1074 }
1075 }
1076 Text::from(lines)
1077}
1078
1079/// A box of this size in the middle of `area`, clamped to fit.
1080/// How many rows one wrapped line of the confirmation will take in a box `width` wide.
1081///
1082/// Greedy word wrap, which is what `Wrap { trim: true }` does, because the alternative is to
1083/// hand a wrapping paragraph a fixed one-row-per-line box and lose whatever does not fit — and
1084/// what does not fit is the end of the sentence, which is where the consequence is. Erring
1085/// upward is a blank row inside a box; erring downward is a warning cut in half.
1086fn wrapped_rows(line: &Line<'_>, width: usize) -> usize {
1087 if width == 0 {
1088 return 1;
1089 }
1090 let text: String = line
1091 .spans
1092 .iter()
1093 .map(|span| span.content.as_ref())
1094 .collect();
1095 let mut rows = 1;
1096 let mut used = 0;
1097 for word in text.split_whitespace() {
1098 let len = word.chars().count();
1099 if used == 0 {
1100 used = len;
1101 } else if used + 1 + len <= width {
1102 used += 1 + len;
1103 } else {
1104 rows += 1;
1105 used = len;
1106 }
1107 // A word longer than the box is broken across rows rather than dropped.
1108 while used > width {
1109 rows += 1;
1110 used -= width;
1111 }
1112 }
1113 rows
1114}
1115
1116fn centred(area: Rect, width: u16, height: u16) -> Rect {
1117 let width = width.min(area.width);
1118 let height = height.min(area.height);
1119 Rect {
1120 x: area.x + (area.width - width) / 2,
1121 y: area.y + (area.height - height) / 2,
1122 width,
1123 height,
1124 }
1125}
1126
1127/// Where the last frame put everything a pointer can aim at.
1128///
1129/// Read back from the layout [`draw`] produced rather than described a second time. A
1130/// hard-coded row-to-`y` map would be one line out the first time the header or the heading
1131/// changed height, and being one line out here presses a row nobody chose — on a screen whose
1132/// keys delete directories.
1133///
1134/// [`Default`] is a frame that was never drawn, on which every press resolves to
1135/// [`Spot::Nowhere`]. That is the honest answer for the first mouse event of a run and for a
1136/// test that has not painted: a synthetic geometry would be a second layout, which is the
1137/// thing this type exists to avoid.
1138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1139pub struct Placed {
1140 /// The tree's columns, or `None` on a frame that drew no tree.
1141 pub columns: Option<Columns>,
1142 /// The heading line, when the pane was tall enough to spend a row on one.
1143 pub heading: Option<Rect>,
1144 /// The body rows.
1145 pub rows: Rect,
1146 /// Where the treemap's image goes, when the pane is up. `None` is a frame with no map on
1147 /// it, which is every frame in a terminal that cannot draw one.
1148 pub map: Option<Rect>,
1149 /// The footer, on a frame where it was saying what just happened. `None` whenever there is
1150 /// nothing to dismiss — which is what keeps a press on the ordinary footer a miss.
1151 ///
1152 /// The whole line, though a notice no longer has the whole line to itself: a removal's
1153 /// position and the session's freed total share it. Both are figures rather than buttons,
1154 /// so a press that lands on one has nothing of its own to do, and giving it the dismissal
1155 /// beats making a reader aim at a sentence whose length they did not choose.
1156 pub notice: Option<Rect>,
1157 /// Which row of the tree the top drawn row held.
1158 ///
1159 /// Recorded rather than re-read from the view, so a press maps `y` to a row through the
1160 /// offset the rows were **drawn** at — a sync between the frame and the press would
1161 /// otherwise shift every row by the difference.
1162 pub scroll: usize,
1163 /// The topmost overlay's box, in the ranking [`super::state::View::overlay`] uses. One
1164 /// slot, because a press lands on one thing.
1165 pub overlay: Option<Rect>,
1166 /// A confirmation's two answers, in [`Answer::ALL`] order, where they were drawn.
1167 ///
1168 /// Inside [`overlay`](Self::overlay) and checked first. `None` whenever no question was
1169 /// on the frame — which is what a press made *before* the box appeared lands on, and is
1170 /// the whole of what stops such a press from answering one. See
1171 /// [`super::keymap::finish`].
1172 pub answers: Option<[Rect; 2]>,
1173}
1174
1175/// Which part of a row the pointer is on.
1176///
1177/// pristine's rows carry a target pua's do not: the mark box. A box that is drawn and cannot
1178/// be pressed is a lie the pointer tells about itself, so it is a zone of its own rather than
1179/// part of the name.
1180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1181pub enum Zone {
1182 /// The `[x]` box — mark this row's subtree, or unmark it.
1183 Mark,
1184 /// The `▸`/`▾` indicator — open the row, or close it.
1185 ///
1186 /// A leaf leaves this cell blank, and a blank cell cannot have been aimed at: opening a
1187 /// row with nothing under it is a no-op in [`super::state::View`] rather than a case the
1188 /// hit test has to know about.
1189 Open,
1190 /// The indent, the name, and everything past it — put the cursor here.
1191 Name,
1192}
1193
1194/// What is under the pointer, on the frame that was drawn.
1195///
1196/// The routing chain the keymap states for keys — overlay first, then the tree, then the
1197/// globals — falls out of resolving this **geometrically** rather than being re-implemented:
1198/// an overlay covers the screen it is over, so a press inside one cannot also be a press on
1199/// the tree, and one outside is the dismissal. [`super::keymap::pointer`] turns a spot and a
1200/// gesture into an action; nothing else has to know the order.
1201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1202pub enum Spot {
1203 /// A column heading, over the column that orders a level by this key.
1204 Heading(Order),
1205 /// A tree row, named by the **directory** on it rather than by its position.
1206 ///
1207 /// The identity rule this whole model turns on. Rows re-sort as prices land and *vanish*
1208 /// as removals complete, so a row index resolved at the press and acted on at the release
1209 /// names a different directory — and the action on the other end of it deletes one. A
1210 /// [`NodeId`] is not that index: [`crate::tree::Tree`] only ever pushes a new slot and
1211 /// never recycles a detached one, so an id names one directory for the life of the run,
1212 /// and a press on a row that has since been removed resolves to a row that is no longer
1213 /// on screen and does nothing at all.
1214 Row {
1215 /// Which directory.
1216 id: NodeId,
1217 /// Which part of its row.
1218 zone: Zone,
1219 },
1220 /// The tree pane, past the end of its rows.
1221 Tree,
1222 /// Inside the help overlay.
1223 Help,
1224 /// On one of a confirmation's two answers.
1225 Answer(Answer),
1226 /// Inside a confirmation, and not on either answer.
1227 ///
1228 /// The question, the consequence and the padding around them are things to read rather
1229 /// than things to press, so landing near an answer is a miss and does nothing. What
1230 /// guards the irreversible press is the gesture — down and up in the same button — rather
1231 /// than the absence of a target; see [`super::keymap::finish`].
1232 Confirm,
1233 /// Inside the filter prompt.
1234 Prompt,
1235 /// Outside the overlay that is up — where a press dismisses it.
1236 Outside,
1237 /// The footer, while it is saying what just happened — where a press dismisses that.
1238 ///
1239 /// Under the overlays and never over the tree, which is the whole of why it is safe: the
1240 /// footer is a line of its own, so a press that lands on a stale report cannot also be a
1241 /// press on a row, and no gesture aimed at a sentence can mark a subtree behind it.
1242 Notice,
1243 /// Chrome, or a frame with nothing on it: the header line, and a footer with only the keys
1244 /// on it.
1245 Nowhere,
1246}
1247
1248/// What the pointer is over, resolved against the frame that was drawn.
1249///
1250/// Takes the view as well as the frame because two of the answers are about content rather
1251/// than geometry: which directory a row is, so that a re-sort between the press and the
1252/// release cannot select a stranger, and how deep it is, so that the expander's cell is the
1253/// one it was drawn in.
1254#[must_use]
1255pub fn hit(view: &View, placed: &Placed, at: Position) -> Spot {
1256 // The overlay first, and by **omission** rather than by a branch: while one is up nothing
1257 // below this returns, so there is no arrangement in which a press reaches the tree behind
1258 // it.
1259 if let Some(over) = placed.overlay {
1260 if !over.contains(at) {
1261 return Spot::Outside;
1262 }
1263 // In the ranking the keyboard is routed by, so the surface a press lands on is the
1264 // one a keystroke would reach.
1265 if view.prompt().is_some() {
1266 return Spot::Prompt;
1267 }
1268 if view.help().is_some() {
1269 return Spot::Help;
1270 }
1271 return answer_at(placed.answers, at).map_or(Spot::Confirm, Spot::Answer);
1272 }
1273
1274 // After the overlays and before the tree, which is where the footer is drawn: the prompt
1275 // borrows this very line, so while one is up the branch above has already answered.
1276 if placed.notice.is_some_and(|footer| footer.contains(at)) {
1277 return Spot::Notice;
1278 }
1279
1280 let Some(columns) = placed.columns else {
1281 return Spot::Nowhere;
1282 };
1283 if placed.heading.is_some_and(|head| head.contains(at)) {
1284 return heading_at(columns, at.x);
1285 }
1286 if placed.rows.contains(at) {
1287 let index = placed.scroll + usize::from(at.y - placed.rows.y);
1288 return match view.rows().get(index) {
1289 Some(row) => Spot::Row {
1290 id: row.id,
1291 zone: zone_at(columns, row.depth, at.x),
1292 },
1293 None => Spot::Tree,
1294 };
1295 }
1296 Spot::Nowhere
1297}
1298
1299/// Which of a confirmation's answers the pointer is on, if either.
1300fn answer_at(answers: Option<[Rect; 2]>, at: Position) -> Option<Answer> {
1301 Answer::ALL
1302 .into_iter()
1303 .zip(answers?)
1304 .find(|(_, rect)| rect.contains(at))
1305 .map(|(answer, _)| answer)
1306}
1307
1308/// Which ordering the heading holds at cell `x`.
1309///
1310/// Each gap belongs to the column before it, which is where that heading's trailing blanks
1311/// are anyway. The regeneration column is the one part of the line that is *not* a button:
1312/// nothing orders a level by the command that rebuilds it, so a press there is a miss rather
1313/// than a reversal of the neighbour the reader did not aim at.
1314fn heading_at(at: Columns, x: u16) -> Spot {
1315 if x < at.size.x {
1316 return Spot::Heading(Order::Path);
1317 }
1318 if x < at.age.x {
1319 return Spot::Heading(Order::Size);
1320 }
1321 match at.label {
1322 Some(label) if x >= label.x => Spot::Nowhere,
1323 _ => Spot::Heading(Order::Age),
1324 }
1325}
1326
1327/// Which part of a row's name column cell `x` is in.
1328///
1329/// Walked through the same offsets [`name`] draws with, so each target is where its glyph
1330/// is rather than where a second description says it should be.
1331fn zone_at(at: Columns, depth: usize, x: u16) -> Zone {
1332 let offset = usize::from(x.saturating_sub(at.name.x));
1333 if offset < BOX {
1334 return Zone::Mark;
1335 }
1336 if offset == MARKER + INDENT * depth {
1337 return Zone::Open;
1338 }
1339 Zone::Name
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use super::treemap::Maps;
1345 use super::{INDENT, MARKER, Placed, Spot, Zone, draw, hit as press_on};
1346 use crate::delete::{Refusal, Refused};
1347 use crate::fixture::{hit, priced};
1348 use crate::rules::Kind;
1349 use crate::size::Size;
1350 use crate::tree::{Order, Tree};
1351 use crate::tui::keymap::{Action, Gesture, Motion, Turn, pointer};
1352 use crate::tui::moving::COUNT_UP;
1353 use crate::tui::state::{Answer, Notice, Planned, View};
1354 use ratatui::Terminal;
1355 use ratatui::backend::TestBackend;
1356 use ratatui::layout::Position;
1357 use std::path::Path;
1358
1359 /// Opens every row. Twice, because the root starts open and the first `*` closes it.
1360 fn open_everything(view: &mut View) {
1361 view.apply(Action::Cursor(Motion::Top));
1362 view.apply(Action::ToggleSubtree);
1363 view.apply(Action::ToggleSubtree);
1364 }
1365
1366 /// The one drawn line holding `needle`.
1367 fn row_with<'a>(frame: &'a [String], needle: &str) -> &'a str {
1368 frame
1369 .iter()
1370 .find(|line| line.contains(needle))
1371 .unwrap_or_else(|| panic!("no row mentions {needle}: {frame:#?}"))
1372 }
1373
1374 /// Everything the frame drew, one line per row, trailing blanks trimmed.
1375 fn painted(view: &mut View, width: u16, height: u16) -> Vec<String> {
1376 frame_of(view, width, height).0
1377 }
1378
1379 /// The same frame, with the geometry it reported — for the tests about pressing on it.
1380 fn frame_of(view: &mut View, width: u16, height: u16) -> (Vec<String>, Placed) {
1381 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1382 let mut placed = Placed::default();
1383 terminal
1384 .draw(|frame| placed = draw(frame, view, &[]))
1385 .unwrap();
1386 let buffer = terminal.backend().buffer().clone();
1387 let lines = (0..buffer.area.height)
1388 .map(|y| {
1389 (0..buffer.area.width)
1390 .map(|x| buffer[(x, y)].symbol())
1391 .collect::<String>()
1392 .trim_end()
1393 .to_owned()
1394 })
1395 .collect();
1396 (lines, placed)
1397 }
1398
1399 fn view() -> View {
1400 let mut tree = Tree::new("/scan");
1401 tree.insert(priced("/scan/nx/node_modules", 2 * 1024 * 1024));
1402 tree.insert(hit("/scan/old/target", Size::Unmeasured, 0));
1403 View::new(tree)
1404 }
1405
1406 #[test]
1407 fn a_row_carries_its_marker_its_rollup_and_what_it_is() {
1408 let mut view = view();
1409 view.apply(Action::Cursor(Motion::Down));
1410 view.apply(Action::Mark);
1411 view.apply(Action::Expand);
1412 let frame = painted(&mut view, 100, 8);
1413
1414 assert!(frame[0].contains("/scan"), "{frame:#?}");
1415 assert!(
1416 frame[0].contains("2.0 MiB reclaimable in 2 directories"),
1417 "{frame:#?}"
1418 );
1419 // The marked row and its rolled-up size…
1420 let marked = frame.iter().find(|line| line.contains("nx")).unwrap();
1421 assert!(marked.contains("[x]"), "{marked}");
1422 assert!(marked.contains("▾"), "{marked}");
1423 assert!(marked.contains("2.0 MiB"), "{marked}");
1424 // …and — because the terminal is wide enough — what the claim under it IS. The
1425 // ancestor above carries no label of its own: a directory holding a `node_modules`
1426 // and a `target` is not one artefact.
1427 let claim = frame
1428 .iter()
1429 .find(|line| line.contains("node_modules"))
1430 .unwrap();
1431 assert!(claim.contains("Node Dependencies"), "{claim}");
1432 assert!(!marked.contains("Node Dependencies"), "{marked}");
1433 // …and the row nobody has priced shows a dash rather than a zero.
1434 let unpriced = frame.iter().find(|line| line.contains("old")).unwrap();
1435 assert!(unpriced.contains("—"), "{unpriced}");
1436 assert!(!unpriced.contains("0 B"), "{unpriced}");
1437 }
1438
1439 #[test]
1440 fn the_footer_counts_what_is_marked() {
1441 let mut view = view();
1442 let frame = painted(&mut view, 100, 8);
1443 assert!(frame[7].contains("nothing marked"), "{frame:#?}");
1444
1445 view.apply(Action::Cursor(Motion::Down));
1446 view.apply(Action::Mark);
1447 let frame = painted(&mut view, 100, 8);
1448 assert!(
1449 frame[7].contains("marked 2.0 MiB in 1 directory"),
1450 "{frame:#?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn a_report_in_the_footer_says_how_to_get_rid_of_it() {
1456 let mut view = view();
1457 view.deleted(
1458 Notice::passing("removed 2.0 MiB from 1 directory"),
1459 2 * 1024 * 1024,
1460 );
1461 let frame = painted(&mut view, 100, 8);
1462
1463 // A notice takes the keys away, so the reader has nothing else on the screen telling
1464 // them it can be got rid of. Without the hint the sentence reads as permanent
1465 // furniture, which is exactly how it was found.
1466 assert!(
1467 frame[7].contains("removed 2.0 MiB from 1 directory"),
1468 "{frame:#?}"
1469 );
1470 assert!(frame[7].contains("Esc to dismiss"), "{frame:#?}");
1471 }
1472
1473 #[test]
1474 fn the_hint_sits_against_the_sentence_and_not_against_the_freed_total() {
1475 let mut view = view();
1476 view.deleted(
1477 Notice::passing("removed 2.0 MiB from 1 directory"),
1478 2 * 1024 * 1024,
1479 );
1480 view.animate(std::time::Instant::now() + COUNT_UP * 8);
1481 let frame = painted(&mut view, 100, 8);
1482
1483 // Two figures share this line with the report and neither is dismissible, so the hint
1484 // is drawn against the one thing `Esc` does take away. A hint at the end of the line
1485 // would read as an offer to clear the session's freed total, which nothing clears.
1486 let line = &frame[7];
1487 let hint = line.find("Esc to dismiss").expect("{line}");
1488 let total = line.find("freed 2.0 MiB").expect("{line}");
1489 assert!(hint < total, "{line}");
1490 }
1491
1492 #[test]
1493 fn the_footer_goes_back_to_its_keys_once_the_report_is_dismissed() {
1494 let mut view = view();
1495 view.deleted(
1496 Notice::passing("removed 2.0 MiB from 1 directory"),
1497 2 * 1024 * 1024,
1498 );
1499 painted(&mut view, 100, 8);
1500
1501 view.apply(Action::Back);
1502 let frame = painted(&mut view, 100, 8);
1503
1504 // Nothing of the report is left on the line — not the sentence, not its hint. The
1505 // frame is drawn inside a synchronized update, so a cell of it still standing here
1506 // would be a stale claim the next full repaint cannot clear.
1507 assert!(!frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
1508 assert!(!frame[7].contains("Esc to dismiss"), "{frame:#?}");
1509 assert!(frame[7].contains("nothing marked"), "{frame:#?}");
1510 assert!(frame[7].contains("space mark · x delete"), "{frame:#?}");
1511 }
1512
1513 #[test]
1514 fn pressing_on_a_report_dismisses_it_and_never_reaches_the_tree_behind_it() {
1515 let mut view = view();
1516 view.deleted(
1517 Notice::passing("removed 2.0 MiB from 1 directory"),
1518 2 * 1024 * 1024,
1519 );
1520 let (_, placed) = frame_of(&mut view, 100, 8);
1521
1522 // The footer is a line of its own, under the tree rather than over it, so a press on
1523 // a report cannot also be a press on a row — there is no marking a subtree the reader
1524 // could not see.
1525 let spot = press_on(&view, &placed, Position::new(4, 7));
1526 assert_eq!(spot, Spot::Notice);
1527 assert_eq!(pointer(Gesture::Click, spot), Action::Dismiss);
1528
1529 // …and with nothing being said the same cell is a miss, because there is nothing
1530 // there to dismiss — the freed total the removal left on the line is not a button.
1531 view.apply(Action::Back);
1532 let (_, placed) = frame_of(&mut view, 100, 8);
1533 assert_eq!(press_on(&view, &placed, Position::new(4, 7)), Spot::Nowhere);
1534 }
1535
1536 #[test]
1537 fn an_ancestor_of_a_mark_is_drawn_as_a_block_filled_to_the_marked_share() {
1538 let mut view = view();
1539 view.apply(Action::Cursor(Motion::Down));
1540 view.apply(Action::Expand);
1541 view.apply(Action::Cursor(Motion::Down));
1542 view.apply(Action::Mark);
1543 let frame = painted(&mut view, 100, 8);
1544
1545 // One of the root's two claims is marked, and the other has no price, so the share is
1546 // stated in claims: half, which is the fourth of seven blocks. A bare `[~]` would say
1547 // "some of this" and stop there; the glyph says how much, which is what a reader
1548 // deciding whether to open the row actually needs.
1549 //
1550 // Found rather than indexed: the heading is a line of the body, so the root row is no
1551 // longer whichever line the frame happens to put second.
1552 let root = frame
1553 .iter()
1554 .find(|line| line.starts_with('[') && line.contains("/scan"))
1555 .unwrap_or_else(|| panic!("{frame:#?}"));
1556 assert!(root.contains("[▄]"), "{frame:#?}");
1557 assert!(!root.contains("[x]"), "{frame:#?}");
1558 }
1559
1560 #[test]
1561 fn the_confirmation_says_what_it_will_delete_and_what_it_will_not() {
1562 let mut view = view();
1563 view.asking(
1564 &[
1565 Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
1566 Planned::at("/scan/old/target", Size::Unmeasured),
1567 ],
1568 &[Refused {
1569 path: "/scan/gone".into(),
1570 reason: Refusal::HoldsCheckout,
1571 }],
1572 );
1573 let frame = painted(&mut view, 100, 20);
1574 let box_text = frame.join("\n");
1575
1576 assert!(
1577 box_text.contains("Delete 2 directories, giving back 2.0 MiB?"),
1578 "{box_text}"
1579 );
1580 assert!(box_text.contains("carry no price"), "{box_text}");
1581 assert!(box_text.contains("cancel"), "{box_text}");
1582 assert!(box_text.contains("delete"), "{box_text}");
1583 }
1584
1585 #[test]
1586 fn the_confirmation_lists_the_batch_it_is_holding_grouped_by_what_each_thing_is() {
1587 // The safety half of a selection that does not follow the view: a reader who marked
1588 // broadly and then narrowed has to be able to *see* what they are confirming, one line
1589 // per directory, before the one irreversible thing this tool does.
1590 let mut view = view();
1591 view.asking(
1592 &[
1593 Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
1594 Planned::at("/scan/old/target", Size::Unmeasured),
1595 ],
1596 &[Refused {
1597 path: "/scan/gone".into(),
1598 reason: Refusal::HoldsCheckout,
1599 }],
1600 );
1601 let frame = painted(&mut view, 100, 24);
1602 let box_text = frame.join("\n");
1603
1604 // Every directory is on the screen, with what it is worth beside it…
1605 assert!(box_text.contains("/scan/nx/node_modules"), "{box_text}");
1606 assert!(box_text.contains("/scan/old/target"), "{box_text}");
1607 // …and the refusal is said HERE, before the reader commits, rather than in the report
1608 // afterwards — with the whole reason on the line rather than the start of it.
1609 assert!(
1610 box_text.contains("kept — holds a git checkout"),
1611 "{box_text}"
1612 );
1613 assert!(
1614 box_text.contains("will be left alone by the safety model"),
1615 "{box_text}"
1616 );
1617
1618 // Grouped by kind, and the group is named on the first line of its run: the shipped
1619 // ruleset's first rule is a Dependencies one, which is what the fixture's claims carry.
1620 let listed: Vec<&String> = frame
1621 .iter()
1622 .filter(|line| line.contains("/scan/") && line.contains('│'))
1623 .collect();
1624 assert_eq!(listed.len(), 3, "{box_text}");
1625 assert!(listed[0].contains("Dependencies"), "{box_text}");
1626 assert!(!listed[1].contains("Dependencies"), "{box_text}");
1627 // The cursor starts on the first line, which is what `space` acts on.
1628 assert!(listed[0].contains('›'), "{box_text}");
1629 }
1630
1631 #[test]
1632 fn a_marked_directory_the_view_is_hiding_says_so_on_its_own_line() {
1633 // The hazard orthogonal selection creates, and the mitigation for it. The count is in
1634 // the warning at the top and the *which* is on the line, because a number a reader
1635 // cannot resolve to a directory is a number they can only accept.
1636 let mut view = view();
1637 // One axis key, on its own: the fixture's claims are Dependencies, so turning that
1638 // member off is the smallest view that hides them — and it is not a preset, which is
1639 // the point of the key existing.
1640 view.apply(Action::ToggleKind(Kind::Dependencies));
1641 view.asking(
1642 &[Planned::at(
1643 "/scan/nx/node_modules",
1644 Size::Measured(2 * 1024 * 1024),
1645 )],
1646 &[],
1647 );
1648 let box_text = painted(&mut view, 100, 24).join("\n");
1649 // Read across the wrap, because the warning genuinely wraps: it names the view, and a
1650 // view that has to spell out its axes is as long as its axes are. The box is sized in
1651 // *drawn* rows for exactly that reason, so what this checks is that the end of the
1652 // sentence — the half that carries the consequence — survived.
1653 let unwrapped = box_text
1654 .replace('│', " ")
1655 .split_whitespace()
1656 .collect::<Vec<_>>()
1657 .join(" ");
1658
1659 assert!(unwrapped.contains("out of sight under"), "{box_text}");
1660 assert!(unwrapped.contains("deleting takes it anyway"), "{box_text}");
1661 let frame = painted(&mut view, 100, 24);
1662 let listed = row_with(&frame, "/scan/nx/node_modules");
1663 assert!(listed.contains("hidden"), "{listed}");
1664 }
1665
1666 #[test]
1667 fn the_help_overlay_is_the_keymap_itself() {
1668 let mut view = view();
1669 view.apply(Action::Help);
1670 let frame = painted(&mut view, 100, 30).join("\n");
1671
1672 assert!(frame.contains("Everywhere"), "{frame}");
1673 assert!(frame.contains("quit"), "{frame}");
1674 assert!(frame.contains("mark this row's whole subtree"), "{frame}");
1675 // The one key that writes says out loud that it asks first.
1676 assert!(
1677 frame.contains("delete what is marked — asks first"),
1678 "{frame}"
1679 );
1680 // …and the map's key is on the page by construction rather than by anyone remembering
1681 // to add it, which is what the table is for.
1682 assert!(frame.contains("show or hide the map"), "{frame}");
1683 }
1684
1685 #[test]
1686 fn the_help_page_names_the_gesture_that_gets_rid_of_a_report() {
1687 let mut view = view();
1688 view.apply(Action::Help);
1689 // Tall enough for the whole page: the pointer's rows are the last section, so a
1690 // frame the size the other help test uses would put them below the fold. The
1691 // confirmation's own keys are a section above them, so the page is taller than the
1692 // 80 this needed before that screen existed.
1693 let frame = painted(&mut view, 100, 110).join("\n");
1694
1695 // Generated from [`POINTER`] rather than written here, which is the guarantee the
1696 // table exists for: a gesture that acts is a gesture the page lists.
1697 assert!(frame.contains("what the footer is saying"), "{frame}");
1698 assert!(frame.contains("dismiss what it says"), "{frame}");
1699 }
1700
1701 #[test]
1702 fn a_narrow_terminal_drops_the_label_column_rather_than_the_path() {
1703 let mut view = view();
1704 view.apply(Action::Cursor(Motion::Down));
1705 view.apply(Action::Expand);
1706 let frame = painted(&mut view, 48, 8);
1707 let row = frame.iter().find(|line| line.contains("nx")).unwrap();
1708 assert!(row.contains("2.0 MiB"), "{row}");
1709 let claim = frame
1710 .iter()
1711 .find(|line| line.contains("node_modules"))
1712 .unwrap();
1713 assert!(!claim.contains("Dependencies"), "{claim}");
1714 }
1715
1716 // ---- what a press lands on --------------------------------------------------------
1717
1718 /// Where the cell holding `glyph` on the row named by `path` actually is.
1719 ///
1720 /// The point of the whole test section: the hit test is asked about the cell the frame
1721 /// *drew* the glyph in, found by looking at the painted buffer, rather than about a cell
1722 /// a second description of the layout believes it is in.
1723 fn cell_of(frame: &[String], placed: &Placed, path: &str, glyph: &str) -> Position {
1724 let y = frame
1725 .iter()
1726 .position(|line| line.contains(path))
1727 .unwrap_or_else(|| panic!("{path} is not on the frame: {frame:#?}"));
1728 let line = &frame[y];
1729 let x = line
1730 .char_indices()
1731 .position(|(at, _)| line[at..].starts_with(glyph))
1732 .unwrap_or_else(|| panic!("no {glyph} on {line:?}"));
1733 let at = Position::new(u16::try_from(x).unwrap(), u16::try_from(y).unwrap());
1734 assert!(
1735 placed.rows.contains(at) || placed.heading.is_some_and(|head| head.contains(at)),
1736 "{glyph} on {path} is at {at:?}, which the frame says is neither a row nor the heading"
1737 );
1738 at
1739 }
1740
1741 #[test]
1742 fn a_press_on_a_rows_glyphs_lands_on_the_part_of_it_that_was_drawn_there() {
1743 let mut view = view();
1744 view.apply(Action::Cursor(Motion::Down));
1745 view.apply(Action::Expand);
1746 let (frame, placed) = frame_of(&mut view, 100, 10);
1747 let nx = view.tree().find(std::path::Path::new("/scan/nx")).unwrap();
1748
1749 // The three targets a row carries, each asked about at the cell its own glyph was
1750 // painted in. Being one cell out here is a press that marks where it meant to open.
1751 assert_eq!(
1752 press_on(&view, &placed, cell_of(&frame, &placed, "nx", "[")),
1753 Spot::Row {
1754 id: nx,
1755 zone: Zone::Mark
1756 }
1757 );
1758 assert_eq!(
1759 press_on(&view, &placed, cell_of(&frame, &placed, "nx", "▾")),
1760 Spot::Row {
1761 id: nx,
1762 zone: Zone::Open
1763 }
1764 );
1765 assert_eq!(
1766 press_on(&view, &placed, cell_of(&frame, &placed, "nx", "nx")),
1767 Spot::Row {
1768 id: nx,
1769 zone: Zone::Name
1770 }
1771 );
1772 }
1773
1774 #[test]
1775 fn a_leaf_leaves_the_indicators_cell_blank_and_a_press_there_is_a_press_on_the_row() {
1776 let mut view = view();
1777 view.apply(Action::Cursor(Motion::Down));
1778 view.apply(Action::Expand);
1779 let (frame, placed) = frame_of(&mut view, 100, 10);
1780 let leaf = view
1781 .tree()
1782 .find(std::path::Path::new("/scan/nx/node_modules"))
1783 .unwrap();
1784
1785 // The cell the indicator would be in, computed from the row's depth exactly as
1786 // `zone_at` does. A leaf draws nothing there, so it resolves to `Open` and the view
1787 // makes it a no-op rather than the hit test having to know about leaves.
1788 let depth = view
1789 .rows()
1790 .iter()
1791 .find(|row| row.id == leaf)
1792 .map(|row| row.depth)
1793 .unwrap();
1794 let row = u16::try_from(
1795 frame
1796 .iter()
1797 .position(|line| line.contains("node_modules"))
1798 .unwrap(),
1799 )
1800 .unwrap();
1801 let x = placed.columns.unwrap().name.x + u16::try_from(MARKER + INDENT * depth).unwrap();
1802 assert_eq!(
1803 frame[row as usize].chars().nth(usize::from(x)),
1804 Some(' '),
1805 "a leaf drew something in the indicator's cell: {:?}",
1806 frame[row as usize]
1807 );
1808 assert_eq!(
1809 press_on(&view, &placed, Position::new(x, row)),
1810 Spot::Row {
1811 id: leaf,
1812 zone: Zone::Open
1813 }
1814 );
1815 }
1816
1817 #[test]
1818 fn a_press_on_a_column_heading_names_the_order_that_column_is_headed_with() {
1819 let mut view = view();
1820 let (frame, placed) = frame_of(&mut view, 100, 8);
1821 let head = placed.heading.unwrap();
1822 assert_eq!(head.y, 1, "{frame:#?}");
1823
1824 for (name, order) in [
1825 ("directory", Order::Path),
1826 ("size", Order::Size),
1827 ("age", Order::Age),
1828 ] {
1829 let at = cell_of(&frame, &placed, "directory", name);
1830 assert_eq!(
1831 press_on(&view, &placed, at),
1832 Spot::Heading(order),
1833 "{name} at {at:?}: {:?}",
1834 frame[1]
1835 );
1836 }
1837 // The one part of the line that is not a button: an ancestor row carries no label, so
1838 // a press there does nothing rather than reversing the neighbour the reader did not
1839 // aim at.
1840 let label = placed.columns.unwrap().label.unwrap();
1841 assert_eq!(
1842 press_on(&view, &placed, Position::new(label.x, head.y)),
1843 Spot::Nowhere
1844 );
1845 }
1846
1847 #[test]
1848 fn a_press_past_the_last_row_is_the_pane_and_a_press_on_the_chrome_is_nothing() {
1849 let mut view = view();
1850 let (_, placed) = frame_of(&mut view, 100, 12);
1851 // Three rows in a pane with room for nine.
1852 assert_eq!(view.rows().len(), 3);
1853 assert_eq!(
1854 press_on(&view, &placed, Position::new(4, placed.rows.y + 5)),
1855 Spot::Tree
1856 );
1857 assert_eq!(press_on(&view, &placed, Position::new(4, 0)), Spot::Nowhere);
1858 assert_eq!(
1859 press_on(&view, &placed, Position::new(4, 11)),
1860 Spot::Nowhere
1861 );
1862 }
1863
1864 #[test]
1865 fn an_overlay_takes_every_press_over_the_screen_it_covers() {
1866 let mut view = view();
1867 view.asking(
1868 &[Planned::at(
1869 "/scan/nx/node_modules",
1870 Size::Measured(2 * 1024 * 1024),
1871 )],
1872 &[],
1873 );
1874 let (frame, placed) = frame_of(&mut view, 100, 20);
1875 let [cancel, delete] = placed.answers.unwrap();
1876
1877 // The buttons, where they were drawn — the rectangles come out of the same widths
1878 // the spans were rendered with, so a press cannot land near a word instead of on it.
1879 assert!(frame[delete.y as usize].contains("delete"), "{frame:#?}");
1880 assert_eq!(
1881 press_on(&view, &placed, Position::new(cancel.x + 2, cancel.y)),
1882 Spot::Answer(Answer::Cancel)
1883 );
1884 assert_eq!(
1885 press_on(&view, &placed, Position::new(delete.x + 2, delete.y)),
1886 Spot::Answer(Answer::Delete)
1887 );
1888 // The blank between them belongs to neither, and the box's own text is something to
1889 // read rather than something to press.
1890 assert_eq!(
1891 press_on(&view, &placed, Position::new(cancel.right(), cancel.y)),
1892 Spot::Confirm
1893 );
1894 assert_eq!(
1895 press_on(&view, &placed, Position::new(delete.x, delete.y - 2)),
1896 Spot::Confirm
1897 );
1898 // …and there is no arrangement in which a press reaches the tree behind it. This is
1899 // the row `nx` is on, and it is not a row while the question is up.
1900 assert_eq!(press_on(&view, &placed, Position::new(1, 3)), Spot::Outside);
1901 }
1902
1903 // ---- the map pane ------------------------------------------------------------------
1904
1905 #[test]
1906 fn the_map_takes_its_columns_off_the_tree_and_only_when_there_is_room_for_both() {
1907 let mut view = view();
1908 // A terminal that cannot draw one never reserves the space, whatever the width.
1909 let (_, plain) = frame_of(&mut view, 140, 20);
1910 assert_eq!(plain.map, None);
1911 assert_eq!(plain.rows.width, 140);
1912
1913 view.allow_maps(Maps::Can);
1914 let (frame, placed) = frame_of(&mut view, 140, 20);
1915 let map = placed.map.unwrap();
1916 assert!(map.width >= 32, "{map:?}");
1917 // The tree gave up exactly those columns and no more, and every rectangle a press is
1918 // resolved against is of the *tree's* pane rather than of the frame.
1919 assert_eq!(placed.rows.right() + 1 + map.width, 140);
1920 assert_eq!(placed.columns.unwrap().name.x, placed.rows.x);
1921 // The caption is terminal text above the picture, one row down from the header.
1922 assert!(frame[1].contains("/scan"), "{:?}", frame[1]);
1923
1924 // …and a press in the map is a press on nothing: the picture is not a button in this
1925 // spike, and a hit test that guessed otherwise would act on a rectangle nobody could
1926 // aim at yet.
1927 assert_eq!(
1928 press_on(&view, &placed, Position::new(map.x + 2, map.y + 2)),
1929 Spot::Nowhere
1930 );
1931 }
1932
1933 #[test]
1934 fn a_terminal_that_will_not_say_how_big_a_cell_is_reserves_no_pane_for_the_picture() {
1935 // #656, where the layout meets it. A terminal on the allowlist inside tmux answers
1936 // the window size with zero pixels, and the pane used to be reserved anyway because
1937 // the layout was reading the allowlist alone — the pixel size was not asked until the
1938 // image was about to be written, by which point the columns were already gone.
1939 let mut view = view();
1940 view.allow_maps(Maps::Unmeasured);
1941 let (_, placed) = frame_of(&mut view, 140, 20);
1942 assert_eq!(placed.map, None);
1943 assert_eq!(placed.rows.width, 140, "the tree paid for an empty pane");
1944 }
1945
1946 #[test]
1947 fn a_terminal_with_no_room_for_a_map_is_all_tree() {
1948 let mut view = view();
1949 view.allow_maps(Maps::Can);
1950 // Too narrow: the map costs the tree the columns it takes, and the tree is the
1951 // interface.
1952 assert_eq!(frame_of(&mut view, 99, 30).1.map, None);
1953 // Too short: rectangles in a pane eight rows tall are a shape, not an answer.
1954 assert_eq!(frame_of(&mut view, 140, 8).1.map, None);
1955 // And the reader can always say no.
1956 view.apply(Action::ToggleMap);
1957 assert_eq!(frame_of(&mut view, 140, 30).1.map, None);
1958 }
1959
1960 #[test]
1961 fn a_claim_a_pricing_thread_is_inside_shimmers_where_its_dash_would_be() {
1962 let mut tree = Tree::new("/scan");
1963 tree.insert(hit("/scan/one/node_modules", Size::Unmeasured, 0));
1964 tree.insert(hit("/scan/two/target", Size::Unmeasured, 0));
1965 let mut view = View::new(tree);
1966 open_everything(&mut view);
1967 view.pricing(Path::new("/scan/one/node_modules"));
1968 let frame = painted(&mut view, 100, 10);
1969
1970 // Exactly as many rows shimmer as the pool has threads working, which is the honest
1971 // reading of "which of these dashes is being worked on right now". The other is
1972 // queued, and a dash that will be measured in four minutes is a different fact about
1973 // a row from one being measured this instant.
1974 assert!(row_with(&frame, "node_modules").contains('━'), "{frame:#?}");
1975 let queued = row_with(&frame, "target");
1976 assert!(queued.contains('—'), "{frame:#?}");
1977 assert!(!queued.contains('━'), "{frame:#?}");
1978 }
1979
1980 #[test]
1981 fn an_ancestor_that_is_still_being_priced_draws_its_number_as_a_floor() {
1982 let mut tree = Tree::new("/scan");
1983 tree.insert(priced("/scan/nx/a/node_modules", 2 * 1024 * 1024));
1984 tree.insert(hit("/scan/nx/b/target", Size::Unmeasured, 0));
1985 let mut view = View::new(tree);
1986 let frame = painted(&mut view, 100, 10);
1987
1988 // `2.0 MiB` on its own would be wrong in the one direction a cleaner must not be
1989 // wrong in. The `>` is true every moment it is up and costs nothing.
1990 assert!(row_with(&frame, "nx").contains("> 2.0 MiB"), "{frame:#?}");
1991 }
1992
1993 #[test]
1994 fn a_directory_a_removal_left_standing_is_marked_calmly_rather_than_as_an_error() {
1995 let mut view = view();
1996 view.refused(&[Refused {
1997 path: "/scan/old/target".into(),
1998 reason: Refusal::HoldsCheckout,
1999 }]);
2000 open_everything(&mut view);
2001 let frame = painted(&mut view, 110, 10);
2002
2003 // The safety model refusing a subtree is the tool working. It says which directory
2004 // and why, on the row, where the footer's count cannot.
2005 let kept = row_with(&frame, "target");
2006 assert!(kept.contains("kept — holds a git checkout"), "{frame:#?}");
2007 // And it does not take the row the regeneration command would have had, on a row
2008 // that has one — the newer and stranger fact wins.
2009 assert!(
2010 !row_with(&frame, "node_modules").contains("kept"),
2011 "{frame:#?}"
2012 );
2013 }
2014
2015 #[test]
2016 fn the_footer_says_where_the_deleter_is_and_not_only_what_it_has_given_back() {
2017 let mut view = view();
2018 view.asking(
2019 &[
2020 Planned::at("/scan/nx/node_modules", Size::Measured(2 * 1024 * 1024)),
2021 Planned::at("/scan/old/target", Size::Measured(0)),
2022 Planned::at("/scan/gone", Size::Measured(0)),
2023 Planned::at("/scan/also-gone", Size::Measured(0)),
2024 ],
2025 &[],
2026 );
2027 view.apply(Action::Highlight(Turn::Next));
2028 view.apply(Action::Answer);
2029 view.removed(Path::new("/scan/nx/node_modules"), 1024 * 1024, true);
2030 view.swept(Path::new("/scan/nx/node_modules"));
2031 view.animate(std::time::Instant::now());
2032 let frame = painted(&mut view, 100, 8);
2033
2034 // A running byte total says how much has gone and nothing about how much is left, so
2035 // a reader cannot tell a third of the way through from nearly finished. The count
2036 // against the batch's own size can, and it is beside the bytes rather than instead of
2037 // them: the pair is what somebody who walked away comes back to read.
2038 assert!(
2039 frame[7].contains("removing 1 of 4 directories"),
2040 "{frame:#?}"
2041 );
2042 assert!(frame[7].contains("25%"), "{frame:#?}");
2043 assert!(frame[7].contains("freed 1.0 MiB"), "{frame:#?}");
2044 // The batch's weight beside its position, which is the pair that tells "nearly over"
2045 // from "the big one has not started". A count alone cannot: the targets left at 98%
2046 // are routinely most of the bytes, because the small ones drain first.
2047 assert!(frame[7].contains("1.0 MiB of 2.0 MiB"), "{frame:#?}");
2048
2049 // And the one thing that speaks while a removal runs still gets through. `q` is held
2050 // back until the batch finishes; a reader who pressed it and saw nothing change would
2051 // have no way to tell a refusal from a terminal that had stopped listening.
2052 view.apply(Action::Quit);
2053 let frame = painted(&mut view, 100, 8);
2054 assert!(frame[7].contains("removing 1 of 4"), "{frame:#?}");
2055 assert!(frame[7].contains("the removal has to finish"), "{frame:#?}");
2056 }
2057
2058 #[test]
2059 fn the_footer_names_the_target_the_batch_is_waiting_on() {
2060 // The question a reader actually has at 98% is not "how far through" — the count
2061 // already said — but "what is it doing". A batch's last targets are its biggest, and a
2062 // single target is swept by a single thread, so the name of the largest one still
2063 // going is the answer to how much longer this is.
2064 let mut view = view();
2065 view.asking(
2066 &[
2067 Planned::at("/scan/nx/node_modules", Size::Measured(8 * 1024 * 1024)),
2068 Planned::at("/scan/old/target", Size::Measured(1024)),
2069 ],
2070 &[],
2071 );
2072 view.apply(Action::Highlight(Turn::Next));
2073 view.apply(Action::Answer);
2074
2075 // Both in flight, the small one further along. The name is the *large* one: how much
2076 // has already gone is not what decides when the batch ends.
2077 view.freeing(Path::new("/scan/old/target"), 900);
2078 view.freeing(Path::new("/scan/nx/node_modules"), 512);
2079 view.animate(std::time::Instant::now());
2080 let frame = painted(&mut view, 120, 8);
2081
2082 assert!(
2083 frame[7].contains("removing 0 of 2 directories"),
2084 "{frame:#?}"
2085 );
2086 // Relative to the scan root, which is how the tree spells it two lines above — a
2087 // reader should not have to translate between the row and the footer.
2088 assert!(frame[7].contains("nx/node_modules"), "{frame:#?}");
2089 assert!(!frame[7].contains("old/target"), "{frame:#?}");
2090 }
2091
2092 #[test]
2093 fn the_footer_keeps_the_freed_total_after_the_notice_has_moved_on() {
2094 let mut view = view();
2095 view.deleted(
2096 Notice::passing("removed 2.0 MiB from 1 directory"),
2097 2 * 1024 * 1024,
2098 );
2099 view.animate(std::time::Instant::now() + COUNT_UP * 8);
2100 let frame = painted(&mut view, 100, 8);
2101
2102 // The number the reader who walked away came back for. It outlives the notice
2103 // because the notice is about what just happened and this is about the session.
2104 assert!(frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
2105 assert!(frame[7].contains("freed 2.0 MiB"), "{frame:#?}");
2106
2107 // Literally outlives it: the report can be got rid of and this cannot. Nothing takes
2108 // the freed total off the line, because there is no later frame on which the session
2109 // gave those bytes back less.
2110 view.apply(Action::Back);
2111 let frame = painted(&mut view, 100, 8);
2112 assert!(!frame[7].contains("removed 2.0 MiB"), "{frame:#?}");
2113 assert!(frame[7].contains("freed 2.0 MiB"), "{frame:#?}");
2114 }
2115
2116 #[test]
2117 fn a_scan_that_could_not_read_everything_says_so_beside_its_own_numbers() {
2118 let mut view = view();
2119 let mut terminal = Terminal::new(TestBackend::new(120, 6)).unwrap();
2120 let errors = vec![crate::walk::WalkError {
2121 path: Some("/scan/locked".into()),
2122 message: "Permission denied".to_owned(),
2123 forbidden: true,
2124 }];
2125 terminal
2126 .draw(|frame| {
2127 draw(frame, &mut view, &errors);
2128 })
2129 .unwrap();
2130 let header: String = (0..120)
2131 .map(|x| terminal.backend().buffer()[(x, 0)].symbol())
2132 .collect();
2133 assert!(header.contains("1 path unread"), "{header}");
2134 assert!(header.contains("floor"), "{header}");
2135 }
2136}