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}