Skip to main content

pristine/tui/treemap/
tiles.rs

1//! Where each rectangle goes, and what it is allowed to claim about itself.
2//!
3//! Two separate problems, and the second one is the whole reason this task was a spike.
4//!
5//! # Squarifying
6//!
7//! [`squarify`] is Bruls, Huizing and van Wijk's algorithm: lay the weights out in rows
8//! against the shorter side of what is left, closing a row the moment adding to it would make
9//! the worst aspect ratio worse. Slice-and-dice — the obvious alternative — gives exact areas
10//! too and gives them as slivers, and a sliver is a rectangle whose *area* nobody can read.
11//!
12//! # The honest map
13//!
14//! A treemap encodes one measure as area, and pristine does not have one measure. A claim is
15//! published the moment it is judged and priced later (#618), so at any instant the tree holds
16//! bytes for some claims and nothing at all for the rest — and a directory nobody has measured
17//! is not worth zero bytes, it is worth an unknown number of them.
18//!
19//! Laying children out by priced bytes alone would draw a 40 GB `node_modules` nobody has
20//! reached as a sliver, or as nothing. **That is the one lie this tool must not tell**, and it
21//! is not a rounding error: a default `--breakdown` over one real `~/repos` publishes its last
22//! claim at 7.5 s and finishes pricing at 63 s, so for a minute most of the map would be a
23//! lie about most of the disk.
24//!
25//! So the map is split in two before anything is laid out. The **known** region is squarified
26//! by priced bytes and every rectangle in it means exactly what it looks like. The
27//! **unknown** region is squarified by *unpriced claim count*, drawn as texture rather than as
28//! colour, and labelled in directories rather than in bytes.
29//!
30//! The unknown region's **area is not information and is not claimed to be** — there is no
31//! honest area for "we have not looked", and the count in the label is the only thing in that
32//! half of the picture that is a fact. What its area buys is presence: the region cannot be
33//! zero-width while anything is unpriced ([`MIN_UNKNOWN`]), so a reader can never mistake a
34//! half-measured tree for a measured one, and it shrinks as the pool catches up — which is
35//! #621's rule that motion should be a value the view already holds, in the one place a
36//! treemap can obey it.
37//!
38//! This is the spatial form of the answer `Roll::label` and #621's `> 4.2 GiB` already give in
39//! text: say what is known, and say that the rest is not known, rather than averaging the two
40//! into a number that is wrong in the direction a cleaner must never be wrong in.
41
42use crate::size::human;
43use crate::tree::NodeId;
44use crate::tui::state::{Mark, View};
45
46/// The smallest share of the map the unknown region takes while anything is unpriced.
47///
48/// A floor and not a fudge. One unpriced claim in a thousand is a genuinely tiny share of
49/// what is unknown *by count*, and drawn to scale it would be a sub-pixel stripe — which is
50/// visually identical to a map with nothing missing from it. The two states have to look
51/// different, because one of them is complete and the other is not.
52const MIN_UNKNOWN: f64 = 0.08;
53
54/// How deep the map nests.
55///
56/// A backstop rather than the control: what actually stops the nesting is [`NEST`], the size
57/// below which a rectangle has no room for a caption and two children — and an unlabelled
58/// rectangle is a shape rather than an answer. The first render of this spike capped at two
59/// and left a 14 GiB `packages` drawn as one blank box with plenty of room inside it, which
60/// is the wrong reason to stop.
61const MAX_DEPTH: usize = 3;
62
63/// The narrowest and shortest a rectangle can be and still be worth nesting into, in pixels.
64const NEST: (f64, f64) = (110.0, 54.0);
65
66/// How much of a nested rectangle its own label and border take off the top.
67const CAPTION: f64 = 13.0;
68
69/// A rectangle, in image pixels.
70///
71/// Floating point all the way to the paint, and rounded once: rounding each level as it is
72/// laid out accumulates, and a treemap whose children do not quite fill their parent has a
73/// seam down it that reads as a boundary nobody put there.
74#[derive(Clone, Copy, Debug, Default, PartialEq)]
75pub struct Area {
76    /// Left edge.
77    pub x: f64,
78    /// Top edge.
79    pub y: f64,
80    /// Width.
81    pub w: f64,
82    /// Height.
83    pub h: f64,
84}
85
86impl Area {
87    /// A rectangle at the origin.
88    #[must_use]
89    pub fn of(w: f64, h: f64) -> Self {
90        Self {
91            x: 0.0,
92            y: 0.0,
93            w,
94            h,
95        }
96    }
97
98    /// How much of the map this rectangle covers.
99    #[must_use]
100    pub fn size(&self) -> f64 {
101        self.w.max(0.0) * self.h.max(0.0)
102    }
103
104    /// The same rectangle, pulled in by `by` on every side.
105    #[must_use]
106    fn inset(&self, by: f64) -> Self {
107        Self {
108            x: self.x + by,
109            y: self.y + by,
110            w: (self.w - 2.0 * by).max(0.0),
111            h: (self.h - 2.0 * by).max(0.0),
112        }
113    }
114
115    /// This rectangle with `off` taken off the top, for a caption.
116    #[must_use]
117    fn below(&self, off: f64) -> Self {
118        Self {
119            x: self.x,
120            y: self.y + off,
121            w: self.w,
122            h: (self.h - off).max(0.0),
123        }
124    }
125
126    /// The two halves this rectangle splits into, cut across its longer axis, with `share` of
127    /// it going to the second.
128    fn split(&self, share: f64) -> (Self, Self) {
129        if self.w >= self.h {
130            let second = (self.w * share).round();
131            (
132                Self {
133                    w: self.w - second,
134                    ..*self
135                },
136                Self {
137                    x: self.x + self.w - second,
138                    w: second,
139                    ..*self
140                },
141            )
142        } else {
143            let second = (self.h * share).round();
144            (
145                Self {
146                    h: self.h - second,
147                    ..*self
148                },
149                Self {
150                    y: self.y + self.h - second,
151                    h: second,
152                    ..*self
153                },
154            )
155        }
156    }
157}
158
159impl std::hash::Hash for Area {
160    /// By bit pattern, because these are only ever hashed to answer "is this the same
161    /// picture as last frame" — where two rectangles that differ in the last bit really are
162    /// a different frame, and no rectangle is ever a NaN.
163    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
164        for side in [self.x, self.y, self.w, self.h] {
165            side.to_bits().hash(state);
166        }
167    }
168}
169
170/// What a rectangle's area is made of, which decides how it is drawn and what it may say.
171#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
172pub enum Kind {
173    /// Bytes somebody measured. The area is proportional to them and the label states them.
174    Priced,
175    /// Claims nobody has measured. The area stands for a *count*, the fill is texture rather
176    /// than colour, and the label is in directories — never in bytes.
177    Unpriced,
178}
179
180/// One rectangle of the map.
181#[derive(Clone, Debug, Hash, PartialEq)]
182pub struct Tile {
183    /// Which directory.
184    pub id: NodeId,
185    /// Where it goes.
186    pub area: Area,
187    /// 1 for a child of the mapped directory, 2 for a grandchild.
188    pub depth: usize,
189    /// Whether its area is bytes or a count of things nobody has counted.
190    pub kind: Kind,
191    /// Whether the reader has marked this whole subtree.
192    pub marked: bool,
193    /// Whether the tree's cursor is on this directory — the map's half of "you are here".
194    pub cursor: bool,
195    /// Whether rectangles are drawn inside this one.
196    ///
197    /// The paint needs to know, because a nested rectangle owns everything below this one's
198    /// caption strip: a label laid out as though it had the whole tile gets its second line
199    /// half eaten by its own child, which is what the first render of this spike did.
200    pub nested: bool,
201    /// The directory's own name.
202    pub name: String,
203    /// What it is worth, in the unit its area is in.
204    pub worth: String,
205}
206
207/// A whole map: the mapped directory, its rectangles, and where the two regions meet.
208#[derive(Clone, Debug, Hash, PartialEq)]
209pub struct Map {
210    /// The directory the map is of.
211    pub root: NodeId,
212    /// Every rectangle, outermost first — so painting them in order draws children over the
213    /// parent they are inside.
214    pub tiles: Vec<Tile>,
215    /// The unknown region, when anything under the mapped directory is unpriced.
216    pub unknown: Option<Area>,
217    /// The line across the top of the map.
218    pub caption: String,
219}
220
221/// Which directory the map is of, given where the cursor is.
222///
223/// A row with children maps itself; a claim — which is a leaf — maps its **parent**, because
224/// a treemap of one rectangle says nothing and what a reader on a leaf wants to know is how
225/// it compares with what is beside it. So drilling in with `→` re-draws the map one level
226/// down, and landing on a `node_modules` at the bottom shows the level it lives in.
227#[must_use]
228pub fn focus(view: &View) -> Option<NodeId> {
229    let id = view.row()?.id;
230    if view.tree().children(id).is_empty() {
231        return view.tree().node(id).parent.or(Some(id));
232    }
233    Some(id)
234}
235
236/// Whether there is a map of `root` in `area` at all.
237///
238/// [`plan`]'s first question, split out because it is also the one the pane asks on **every**
239/// frame: a directory the deleter has emptied has no map, and taking a picture down is not a
240/// redraw to be held back until the view settles. Stated once rather than in the two places
241/// that ask it, so they cannot come to disagree about what an empty map is — and it costs
242/// nothing to ask, being a rolled-up count already on the node.
243#[must_use]
244pub fn mappable(view: &View, root: NodeId, area: Area) -> bool {
245    view.roll(root).claims > 0 && area.w >= 1.0 && area.h >= 1.0
246}
247
248/// The map of `root` inside `area`, or `None` when there is nothing honest to draw.
249///
250/// `None` rather than an empty rectangle in two cases, and they are different: a directory
251/// with no children under the current filter has nothing to divide, and one whose whole
252/// subtree is both unpriced and uncounted has nothing to divide it *by*.
253#[must_use]
254pub fn plan(view: &View, root: NodeId, area: Area) -> Option<Map> {
255    if !mappable(view, root, area) {
256        return None;
257    }
258    let roll = view.roll(root);
259    // The split comes first, before a single rectangle is placed: what is unknown is not a
260    // leftover of the layout, it is a share of the map reserved before the layout runs.
261    let share = if roll.unpriced == 0 {
262        0.0
263    } else {
264        #[expect(
265            clippy::cast_precision_loss,
266            reason = "claim counts are in the tens of thousands; the ratio is a fraction of a \
267                      pane, not an accounting figure"
268        )]
269        let exact = roll.unpriced as f64 / roll.claims as f64;
270        exact.clamp(MIN_UNKNOWN, 1.0)
271    };
272    let (known, unknown) = area.split(share);
273
274    let mut tiles = Vec::new();
275    if share < 1.0 {
276        lay(view, root, known, 1, &mut tiles, Kind::Priced);
277    }
278    if share > 0.0 {
279        lay(view, root, unknown, 1, &mut tiles, Kind::Unpriced);
280    }
281    if tiles.is_empty() {
282        return None;
283    }
284    Some(Map {
285        root,
286        tiles,
287        unknown: (share > 0.0).then_some(unknown),
288        caption: caption(view, root),
289    })
290}
291
292/// The line across the top: which directory this is a map of, and what it is worth.
293///
294/// Drawn by the *renderer* as ordinary terminal text above the image rather than painted
295/// into it, so the one line a reader is most likely to want to copy is real text at the
296/// terminal's own font size. The image below it carries no chrome of its own.
297#[must_use]
298pub fn caption(view: &View, root: NodeId) -> String {
299    let roll = view.roll(root);
300    let node = view.tree().node(root);
301    let name = if node.parent.is_none() {
302        node.path.display().to_string()
303    } else {
304        node.name.to_string_lossy().into_owned()
305    };
306    if roll.unpriced == 0 {
307        return format!("{name} — {}", human(roll.bytes));
308    }
309    // The header's own `>`, for #621's reason: a total over a subtree that is still being
310    // priced is a lower bound tightening rather than a figure, and saying so costs nothing.
311    format!(
312        "{name} — > {} · {} unpriced",
313        human(roll.bytes),
314        roll.unpriced
315    )
316}
317
318/// The children of `parent` that carry any of the measure `kind` stands for.
319///
320/// Under a filter this is filter-aware by construction, because [`View::roll`] is: a
321/// rectangle drawn for bytes the filter is hiding is a rectangle whose mark would delete
322/// them, which is #602 finding 5 arriving in a second place.
323fn weighted(view: &View, parent: NodeId, kind: Kind) -> Vec<(NodeId, u64)> {
324    let mut children: Vec<(NodeId, u64)> = view
325        .tree()
326        .children(parent)
327        .iter()
328        .filter_map(|&id| {
329            let roll = view.roll(id);
330            let weight = match kind {
331                Kind::Priced => roll.bytes,
332                Kind::Unpriced => roll.unpriced as u64,
333            };
334            (weight > 0).then_some((id, weight))
335        })
336        .collect();
337    // Descending, which is what squarifying assumes and also what a reader expects: the
338    // biggest thing is in the corner the eye starts at. The id breaks ties so that two
339    // equal siblings do not swap places every time a price lands somewhere else.
340    children.sort_unstable_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
341    children
342}
343
344/// Follows a chain of only-children down, and says where it ended and what to call it.
345///
346/// `~/repos/a/node_modules` is three nodes and one fact. Nesting a rectangle inside a
347/// rectangle of nearly the same size, with a caption bar between them, spends two labels and
348/// most of the ink saying it twice — so a run of directories with one weighted child each is
349/// one rectangle called `a/node_modules`. This is what makes the map denser than the tree
350/// rather than a picture of it.
351fn collapse(view: &View, id: NodeId, kind: Kind) -> (NodeId, Vec<NodeId>, String) {
352    let mut chain = vec![id];
353    let mut name = view.tree().node(id).name.to_string_lossy().into_owned();
354    let mut at = id;
355    while let [(only, _)] = weighted(view, at, kind).as_slice() {
356        at = *only;
357        chain.push(at);
358        name.push('/');
359        name.push_str(&view.tree().node(at).name.to_string_lossy());
360    }
361    (at, chain, name)
362}
363
364/// Squarifies `parent`'s children into `area` and recurses while there is room to.
365fn lay(view: &View, parent: NodeId, area: Area, depth: usize, out: &mut Vec<Tile>, kind: Kind) {
366    let children = weighted(view, parent, kind);
367    if children.is_empty() {
368        return;
369    }
370    let weights: Vec<u64> = children.iter().map(|(_, weight)| *weight).collect();
371    for ((id, weight), placed) in children.iter().zip(squarify(area, &weights)) {
372        if placed.size() < 1.0 {
373            continue;
374        }
375        let (deepest, chain, name) = collapse(view, *id, kind);
376        // Nesting is only ever worth it inside the known region: the unknown one is already
377        // saying the only thing it knows, and dividing "we have not looked" into smaller
378        // pieces of "we have not looked" adds nothing.
379        let nested = kind == Kind::Priced
380            && depth < MAX_DEPTH
381            && placed.w >= NEST.0
382            && placed.h >= NEST.1
383            && !weighted(view, deepest, kind).is_empty();
384        out.push(Tile {
385            id: *id,
386            area: placed,
387            depth,
388            kind,
389            marked: view.mark_of(*id) == Mark::All,
390            // Anywhere in the collapsed run, because the run is one rectangle: a cursor on
391            // `a` and a cursor on `a/node_modules` are the same place on this picture.
392            cursor: view.row().is_some_and(|row| chain.contains(&row.id)),
393            nested,
394            name,
395            worth: match kind {
396                Kind::Priced => human(*weight),
397                Kind::Unpriced => format!("{weight} unpriced"),
398            },
399        });
400        if nested {
401            lay(
402                view,
403                deepest,
404                placed.inset(2.0).below(CAPTION),
405                depth + 1,
406                out,
407                kind,
408            );
409        }
410    }
411}
412
413/// Bruls, Huizing and van Wijk's squarified treemap.
414///
415/// One rectangle per weight, in the order given — which the caller has already sorted
416/// descending, because the algorithm's aspect-ratio bound assumes it. Weights of zero get an
417/// empty rectangle rather than being dropped, so the two lists stay index-for-index.
418///
419/// The rectangles tile `area` exactly: each one's share of the total area equals its share of
420/// the total weight, which is the whole claim a treemap makes and the one thing worth
421/// asserting about it.
422#[must_use]
423pub fn squarify(area: Area, weights: &[u64]) -> Vec<Area> {
424    let mut out = vec![Area::default(); weights.len()];
425    let total: u128 = weights.iter().map(|&weight| u128::from(weight)).sum();
426    if total == 0 || area.size() <= 0.0 {
427        return out;
428    }
429    // Scaled once, against the *original* rectangle: the areas left to place then always sum
430    // to exactly the free rectangle, so no rounding creeps in as rows are closed.
431    #[expect(
432        clippy::cast_precision_loss,
433        reason = "byte totals reach terabytes; f64 carries 53 bits of mantissa, so the error \
434                  is far below one pixel of a pane"
435    )]
436    let scale = area.size() / total as f64;
437    #[expect(
438        clippy::cast_precision_loss,
439        reason = "as above — these are pixel areas, not ledgers"
440    )]
441    let sized: Vec<f64> = weights
442        .iter()
443        .map(|&weight| weight as f64 * scale)
444        .collect();
445
446    // The indices worth placing, biggest first as the caller ordered them.
447    let order: Vec<usize> = (0..sized.len()).filter(|&at| sized[at] > 0.0).collect();
448    let mut free = area;
449    let mut next = 0;
450    while next < order.len() {
451        let short = free.w.min(free.h);
452        if short <= 0.0 {
453            break;
454        }
455        // Grow the row while doing so improves the worst aspect ratio in it.
456        let mut end = next + 1;
457        let mut row = sized[order[next]];
458        let mut best = worst(row, row, row, short);
459        while end < order.len() {
460            let candidate = sized[order[end]];
461            let grown = row + candidate;
462            // The row is descending, so the newcomer is always the smallest in it.
463            let ratio = worst(grown, sized[order[next]], candidate, short);
464            if ratio > best {
465                break;
466            }
467            best = ratio;
468            row = grown;
469            end += 1;
470        }
471        free = place(&sized, &order[next..end], row, free, &mut out);
472        next = end;
473    }
474    out
475}
476
477/// The worst aspect ratio in a row of total area `row` laid against a side of length `short`.
478fn worst(row: f64, largest: f64, smallest: f64, short: f64) -> f64 {
479    if row <= 0.0 || smallest <= 0.0 {
480        return f64::INFINITY;
481    }
482    let side = short * short;
483    let sum = row * row;
484    f64::max(side * largest / sum, sum / (side * smallest))
485}
486
487/// Lays one closed row along the short side of `free` and returns what is left.
488fn place(sized: &[f64], row: &[usize], total: f64, free: Area, out: &mut [Area]) -> Area {
489    let short = free.w.min(free.h);
490    let thick = total / short;
491    let mut along = 0.0;
492    if free.w <= free.h {
493        for &at in row {
494            let width = sized[at] / thick;
495            out[at] = Area {
496                x: free.x + along,
497                y: free.y,
498                w: width,
499                h: thick,
500            };
501            along += width;
502        }
503        Area {
504            y: free.y + thick,
505            h: free.h - thick,
506            ..free
507        }
508    } else {
509        for &at in row {
510            let height = sized[at] / thick;
511            out[at] = Area {
512                x: free.x,
513                y: free.y + along,
514                w: thick,
515                h: height,
516            };
517            along += height;
518        }
519        Area {
520            x: free.x + thick,
521            w: free.w - thick,
522            ..free
523        }
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::{Area, Kind, MIN_UNKNOWN, focus, plan, squarify};
530    use crate::fixture::{hit, priced};
531    use crate::size::Size;
532    use crate::tree::Tree;
533    use crate::tui::keymap::{Action, Motion};
534    use crate::tui::state::View;
535
536    fn pane() -> Area {
537        Area::of(400.0, 300.0)
538    }
539
540    /// Every rectangle's share of the area is its share of the weight, and none of them
541    /// overlap. The whole claim a treemap makes, over a set with a hard spread.
542    #[test]
543    fn area_is_proportional_to_weight_and_nothing_overlaps() {
544        let weights = [4096_u64, 2048, 1024, 900, 512, 64, 8, 1];
545        let area = pane();
546        let placed = squarify(area, &weights);
547
548        #[expect(clippy::cast_precision_loss, reason = "a test fixture's totals")]
549        let total = weights.iter().sum::<u64>() as f64;
550        for (weight, rect) in weights.iter().zip(&placed) {
551            #[expect(clippy::cast_precision_loss, reason = "a test fixture's totals")]
552            let want = area.size() * (*weight as f64) / total;
553            assert!(
554                (rect.size() - want).abs() < 1e-6,
555                "{weight} got {:?}, worth {want}",
556                rect.size()
557            );
558            assert!(
559                rect.x >= -1e-9
560                    && rect.y >= -1e-9
561                    && rect.x + rect.w <= area.w + 1e-9
562                    && rect.y + rect.h <= area.h + 1e-9,
563                "{rect:?} escaped {area:?}"
564            );
565        }
566        for (at, first) in placed.iter().enumerate() {
567            for second in &placed[at + 1..] {
568                let across = (first.x + first.w).min(second.x + second.w) - first.x.max(second.x);
569                let down = (first.y + first.h).min(second.y + second.h) - first.y.max(second.y);
570                assert!(
571                    across <= 1e-9 || down <= 1e-9,
572                    "{first:?} overlaps {second:?}"
573                );
574            }
575        }
576    }
577
578    /// The reason for squarifying rather than slicing. A sliver has an exact area nobody can
579    /// read, so the bound on the aspect ratio is the feature.
580    #[test]
581    fn no_rectangle_comes_out_a_sliver() {
582        let weights = [4096_u64, 2048, 1024, 900, 512, 64, 32, 16];
583        for rect in squarify(pane(), &weights) {
584            let ratio = (rect.w / rect.h).max(rect.h / rect.w);
585            assert!(ratio < 8.0, "{rect:?} has an aspect ratio of {ratio}");
586        }
587    }
588
589    #[test]
590    fn one_weight_takes_the_whole_rectangle_and_no_weight_takes_none_of_it() {
591        let whole = squarify(pane(), &[7]);
592        assert!((whole[0].size() - pane().size()).abs() < 1e-6, "{whole:?}");
593        assert_eq!(squarify(pane(), &[]), Vec::new());
594        assert_eq!(squarify(pane(), &[0, 0]), vec![Area::default(); 2]);
595        // A zero keeps its slot rather than shifting the rectangles after it onto the wrong
596        // directories.
597        let mixed = squarify(pane(), &[8, 0, 8]);
598        assert_eq!(mixed[1], Area::default());
599        assert!((mixed[0].size() - mixed[2].size()).abs() < 1e-6);
600    }
601
602    // ---- what the map is allowed to say -----------------------------------------------
603
604    fn view() -> View {
605        let mut tree = Tree::new("/scan");
606        tree.insert(priced("/scan/a/node_modules", 8 * 1024 * 1024));
607        tree.insert(priced("/scan/b/target", 2 * 1024 * 1024));
608        View::new(tree)
609    }
610
611    #[test]
612    fn a_priced_tree_is_a_plain_treemap_with_no_unknown_region() {
613        let view = view();
614        let map = plan(&view, view.tree().root(), pane()).unwrap();
615
616        assert!(map.unknown.is_none(), "{:?}", map.unknown);
617        assert!(map.tiles.iter().all(|tile| tile.kind == Kind::Priced));
618        let a = map
619            .tiles
620            .iter()
621            .find(|tile| tile.name == "a/node_modules")
622            .unwrap_or_else(|| panic!("{:?}", map.tiles));
623        let b = map
624            .tiles
625            .iter()
626            .find(|tile| tile.name == "b/target")
627            .unwrap();
628        // Four times the bytes, four times the area — the claim the picture makes.
629        assert!((a.area.size() / b.area.size() - 4.0).abs() < 1e-6);
630        assert_eq!(a.worth, "8.0 MiB");
631        assert!(map.caption.contains("/scan"), "{}", map.caption);
632        assert!(!map.caption.contains("unpriced"), "{}", map.caption);
633    }
634
635    /// The finding this whole task turns on. A directory nobody has measured is not worth
636    /// zero bytes, so it must never be drawn as a rectangle among ones that are.
637    #[test]
638    fn an_unpriced_claim_is_never_a_sliver_among_priced_ones() {
639        let mut tree = Tree::new("/scan");
640        tree.insert(priced("/scan/small/target", 1024));
641        // The 40 GB `node_modules` nobody has reached. By bytes it weighs nothing.
642        tree.insert(hit("/scan/huge/node_modules", Size::Unmeasured, 0));
643        let view = View::new(tree);
644        let map = plan(&view, view.tree().root(), pane()).unwrap();
645
646        let huge = map
647            .tiles
648            .iter()
649            .find(|tile| tile.name == "huge/node_modules")
650            .unwrap_or_else(|| panic!("the unpriced subtree vanished: {:?}", map.tiles));
651        assert_eq!(huge.kind, Kind::Unpriced);
652        assert_eq!(huge.worth, "1 unpriced", "stated in bytes it does not have");
653        assert!(map.unknown.is_some());
654        // And it is a real part of the picture rather than a hairline: half the claims are
655        // unpriced, so half the map is.
656        assert!(
657            huge.area.size() > pane().size() * 0.4,
658            "{:?} of {:?}",
659            huge.area,
660            pane()
661        );
662    }
663
664    #[test]
665    fn one_unpriced_claim_in_a_hundred_is_still_visible() {
666        let mut tree = Tree::new("/scan");
667        for n in 0..99 {
668            tree.insert(priced(&format!("/scan/p{n}/target"), 1024));
669        }
670        tree.insert(hit("/scan/late/node_modules", Size::Unmeasured, 0));
671        let view = View::new(tree);
672        let map = plan(&view, view.tree().root(), pane()).unwrap();
673
674        // Drawn to scale this is 1% of the map, which at this size is a sub-pixel stripe —
675        // visually identical to a tree with nothing missing from it. The two states have to
676        // look different.
677        let unknown = map.unknown.unwrap();
678        assert!(
679            unknown.size() >= pane().size() * MIN_UNKNOWN - 1.0,
680            "{unknown:?}"
681        );
682    }
683
684    #[test]
685    fn a_wholly_unpriced_tree_is_wholly_texture_and_says_so() {
686        let mut tree = Tree::new("/scan");
687        tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 0));
688        tree.insert(hit("/scan/b/target", Size::Unmeasured, 0));
689        let view = View::new(tree);
690        let map = plan(&view, view.tree().root(), pane()).unwrap();
691
692        assert!(map.tiles.iter().all(|tile| tile.kind == Kind::Unpriced));
693        assert!((map.unknown.unwrap().size() - pane().size()).abs() < 1e-6);
694        assert!(map.caption.contains("2 unpriced"), "{}", map.caption);
695        // `> 0 B` and not `0 B`: the map of a tree nobody has measured must not read as a map
696        // of a tree with nothing in it.
697        assert!(map.caption.contains("> "), "{}", map.caption);
698    }
699
700    #[test]
701    fn a_partly_priced_directory_appears_in_both_regions() {
702        let mut tree = Tree::new("/scan");
703        tree.insert(priced("/scan/a/node_modules", 4 * 1024 * 1024));
704        tree.insert(hit("/scan/a/target", Size::Unmeasured, 0));
705        let view = View::new(tree);
706        let map = plan(&view, view.tree().root(), pane()).unwrap();
707
708        let named: Vec<Kind> = map
709            .tiles
710            .iter()
711            .filter(|tile| tile.name.starts_with("a/"))
712            .map(|tile| tile.kind)
713            .collect();
714        assert!(named.contains(&Kind::Priced), "{named:?}");
715        assert!(named.contains(&Kind::Unpriced), "{named:?}");
716    }
717
718    #[test]
719    fn the_map_follows_the_cursor_and_a_leaf_maps_the_level_it_lives_in() {
720        let mut view = view();
721        // Row 0 is the scan root, which has children, so it maps itself.
722        assert_eq!(focus(&view), Some(view.tree().root()));
723
724        view.apply(Action::Cursor(Motion::Down));
725        view.apply(Action::Expand);
726        let a = view.tree().find(std::path::Path::new("/scan/a")).unwrap();
727        assert_eq!(focus(&view), Some(a));
728
729        // …and on the claim itself, which is a leaf: a map of one rectangle says nothing, so
730        // it shows the level the claim is in and marks where the cursor is.
731        view.apply(Action::Cursor(Motion::Down));
732        assert_eq!(focus(&view), Some(a));
733        let claim = view
734            .tree()
735            .find(std::path::Path::new("/scan/a/node_modules"))
736            .unwrap();
737        let map = plan(&view, a, pane()).unwrap();
738        assert!(
739            map.tiles.iter().any(|tile| tile.id == claim && tile.cursor),
740            "nothing on the map says where the cursor is: {:?}",
741            map.tiles
742        );
743    }
744
745    #[test]
746    fn a_marked_subtree_is_marked_on_the_map_too() {
747        let mut view = view();
748        view.apply(Action::Cursor(Motion::Down));
749        view.apply(Action::Mark);
750        let map = plan(&view, view.tree().root(), pane()).unwrap();
751
752        let marked: Vec<&str> = map
753            .tiles
754            .iter()
755            .filter(|tile| tile.marked)
756            .map(|tile| tile.name.as_str())
757            .collect();
758        assert_eq!(marked, ["a/node_modules"], "{:?}", map.tiles);
759    }
760
761    #[test]
762    fn a_filter_maps_what_it_shows_and_not_what_is_there() {
763        // The safety property from #602 finding 5, in a second place: a rectangle drawn for
764        // bytes the filter is hiding is a rectangle whose mark would delete them.
765        let mut view = view();
766        view.apply(Action::OpenFilter);
767        for character in "target".chars() {
768            view.apply(Action::Type(character));
769        }
770        view.apply(Action::Submit);
771        let map = plan(&view, view.tree().root(), pane()).unwrap();
772
773        let names: Vec<&str> = map.tiles.iter().map(|tile| tile.name.as_str()).collect();
774        assert_eq!(names, ["b/target"], "{:?}", map.tiles);
775    }
776
777    #[test]
778    fn there_is_no_map_of_a_directory_with_nothing_under_it() {
779        let empty = View::new(Tree::new("/scan"));
780        assert_eq!(plan(&empty, empty.tree().root(), pane()), None);
781        // …nor of a pane with no room in it, which is what a very narrow terminal gives.
782        let view = view();
783        assert_eq!(plan(&view, view.tree().root(), Area::of(0.0, 40.0)), None);
784    }
785}