Skip to main content

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}