pristine/tui/treemap.rs
1//! The treemap pane — **a spike**, and its escape hatch is [`Maps`] not being [`Maps::Can`].
2//!
3//! Reclaimable space is spatial, and a treemap answers "where are the bytes" in a way a
4//! sorted list structurally cannot: `~/repos/archived` being two thirds of the picture is one
5//! glance, where the list makes it one number to compare against forty others. kondo, npkill
6//! and `dua` all render rows and nothing else, so this is also the one thing on the table
7//! that would make pristine visibly *unlike* them rather than better along an axis they
8//! already occupy.
9//!
10//! # It degrades to nothing, three times over
11//!
12//! 1. **The terminal has to be known to speak the protocol**, from the environment and
13//! nothing else — see [`super::chrome::Decor`], whose table this reads a column of. There
14//! is a documented "do you speak this" query and it is a round trip with no bound on the
15//! silence, which is exactly the blocking probe the chrome refuses to make.
16//! 2. **The terminal has to report a pixel size.** `TIOCGWINSZ` carries one and costs no
17//! round trip, so a terminal that fills it in with zeros — which is most of them, and
18//! every terminal seen through tmux — gets no map rather than a guess at its own cell
19//! size.
20//! 3. **The pane has to fit.** Below [`MIN_WIDTH`] columns the map would cost the tree more
21//! than it is worth, and the tree alone is the complete interface.
22//!
23//! **The first two are one answer, [`Maps`], and that is #656's whole lesson.** They were two
24//! answers taken at two different times — the allowlist before the layout, the pixel size at
25//! the draw — so a terminal that passed one and failed the other cost the tree columns that
26//! nothing was ever drawn in. Both are now folded into the predicate the layout reads, and it
27//! is re-asked every frame, because a window can lose its pixel fields while a run is going.
28//!
29//! Nothing above is a flag the reader has to find. What *is* a key is `m`, which turns the
30//! pane off on a terminal that could have one — and on a terminal that could not, says which
31//! of the two reasons it is, because an enhancement you cannot dismiss is not an enhancement
32//! and a rectangle that declines without a word is worse than no rectangle.
33//!
34//! # What is expensive, and what is done about it
35//!
36//! A pane of 44×40 cells on a retina terminal is about 900 kB of RGB, which is 1.2 MB of
37//! base64 down the pty. At the 100 ms frame rate that would be 12 MB/s to say nothing new, so
38//! the image is emitted only when the picture actually **changes** — and the two kinds of
39//! change are treated differently, because they have different deadlines:
40//!
41//! - **Steering** — the cursor moving, a drill-in, a mark, a filter, the pane resizing — is
42//! the reader's own hand and is redrawn on the next frame, always.
43//! - **Arriving** — a price landing, a claim appearing, a row being deleted — happens
44//! hundreds of times a second during a breakdown and is redrawn at most every
45//! [`SETTLE`]. A map that repaints 10 times a second while 16,013 prices land is a map
46//! nobody can read anyway.
47//!
48//! **"Has it changed" is answered from what the map is made of, never from the map.** The
49//! spike asked it by squarifying the whole thing and comparing, which cost 467 µs on a frame
50//! where nothing had happened — 200× what the animation beside it spends to answer the same
51//! question, paid forever, on a pane showing the picture it showed last frame. Reading the
52//! inputs instead costs **50 ns**: a [`View::map_stamp`](super::state::View::map_stamp) for
53//! the mapped subtree, and a hash of the handful of values the reader controls.
54//!
55//! That stamp is **lens-aware**, and it has to be. A run opens on a view that hides the
56//! gitignored tier, so tier-two claims stream in under the very directory the map is of while
57//! changing not one rectangle; answering each of those with the tree's own stamp would be a
58//! megabyte down the pty to redraw the picture already on it.
59//!
60//! Taking the picture **down** is not a redraw and is never throttled — see
61//! [`tiles::mappable`].
62//!
63//! See the note in brain — `areas/pristine/design/2026-08-11-treemap-spike.md` — for what
64//! this measured out at, and for the verdict.
65
66pub mod kitty;
67pub mod paint;
68pub mod tiles;
69
70use std::hash::{DefaultHasher, Hash, Hasher};
71use std::io::{self, Write};
72use std::time::{Duration, Instant};
73
74use ratatui::layout::Rect;
75
76use super::state::View;
77use kitty::Image;
78use tiles::Area;
79
80/// The narrowest terminal that gets a map.
81///
82/// The pane costs the tree the columns it takes, and below this the tree is left too narrow
83/// to read a path in — which is the thing the map is an enhancement *to*.
84pub const MIN_WIDTH: u16 = 100;
85
86/// The shortest pane worth drawing rectangles in.
87pub const MIN_HEIGHT: u16 = 12;
88
89/// How wide the pane is, as a share of the terminal, and the bounds on that.
90const SHARE: (f32, u16, u16) = (0.36, 32, 56);
91
92/// How long a map has to have been up before something *arriving* redraws it.
93///
94/// Not a frame-rate cap: steering ignores it entirely. It bounds only the redraws nobody
95/// asked for, which during a breakdown is all of them.
96const SETTLE: Duration = Duration::from_millis(250);
97
98/// Whether a map can appear in this terminal right now, and when it cannot, why.
99///
100/// **One answer, because there were two and they were asked at different times.** The
101/// allowlist was read before the layout and the pixel size at the draw, so a terminal that
102/// passed the first and failed the second cost the tree its columns and then had nothing drawn
103/// in them. Neither gate was wrong; only one of them was visible to the layout.
104///
105/// The case that separates them is a multiplexer carrying the outer terminal's `TERM` through
106/// — tmux with `default-terminal "xterm-ghostty"`, which is what a workspace manager sets up.
107/// [`super::chrome::Decor`] then reads Ghostty and says yes, and tmux forwards no pixel fields
108/// at all, so the winsize reads zero. A bare `TERM=tmux-256color` never got this far: the
109/// allowlist refuses it, which is [`Maps::Unread`] and a different sentence.
110///
111/// So this is the *only* predicate the layout reads, and it carries the reason with it,
112/// because a bool would take the pane away and leave nobody able to say why it went.
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum Maps {
115 /// The terminal reads the protocol and says how big its cells are.
116 Can,
117 /// It is not known to read the graphics protocol. The default, because a view nobody has
118 /// told anything to has not been told this either.
119 #[default]
120 Unread,
121 /// It reads the protocol but reports no pixel size.
122 ///
123 /// Named for [`crate::size::Size::Unmeasured`] and for the same reason: there is no
124 /// honest cell size to be had here, and the answer to an absent measurement is to say it
125 /// is absent rather than to assume 8×16 and draw an image at the wrong scale over the
126 /// text it is meant to sit beside.
127 Unmeasured,
128}
129
130impl Maps {
131 /// Both gates in one answer: what the terminal is, and what it has just said about its
132 /// own window. Reached through [`Screen::mapping`], which is the only caller that holds
133 /// both halves.
134 fn of(reads: bool, cell: Option<(u16, u16)>) -> Self {
135 match (reads, cell) {
136 (false, _) => Self::Unread,
137 (true, None) => Self::Unmeasured,
138 (true, Some(_)) => Self::Can,
139 }
140 }
141
142 /// Whether a map can be drawn.
143 #[must_use]
144 pub fn can(self) -> bool {
145 matches!(self, Self::Can)
146 }
147
148 /// The one line for a reader who wanted a map there cannot be, or `None` when there can.
149 ///
150 /// Two sentences and not one, because the two refusals have different answers: a terminal
151 /// off the allowlist is the wrong terminal, where a terminal reporting no pixel size is
152 /// usually the right one with a multiplexer in between — and that is something the reader
153 /// can act on.
154 #[must_use]
155 pub fn why(self) -> Option<&'static str> {
156 match self {
157 Self::Can => None,
158 Self::Unread => {
159 Some("this terminal does not read the graphics protocol, so there is no map")
160 }
161 Self::Unmeasured => Some(
162 "this terminal reports no pixel size — tmux and screen do not pass one on — \
163 so there is no map",
164 ),
165 }
166 }
167}
168
169/// What a call to [`Screen::show`] left on the terminal.
170///
171/// An answer rather than `Ok(())`, because "there is no picture" and "the picture is already
172/// right" are the same silence otherwise — which is exactly how #656 went unreported for as
173/// long as it did. The caller can see which it got, and say so.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum Drawn {
176 /// A map is on the terminal: written by this call, or still right from an earlier one.
177 Map,
178 /// None, because there is nothing under the cursor worth dividing into rectangles — an
179 /// empty directory, or one whose whole subtree the lens hides. Not a fault in anything.
180 Nothing,
181 /// None, because this terminal cannot have one — and which of the two reasons it is.
182 ///
183 /// **The layout should never have reserved a pane, and this is how the caller finds out
184 /// that it did.** A run that reaches this has [`Maps`] and the pane it was handed
185 /// disagreeing, which is #656's shape returning; the caller's job is to believe the
186 /// screen, which has actually tried, over the layout, which has only asked.
187 ///
188 /// The reason travels with it rather than being assumed at the other end, because the
189 /// caller assuming would be the wrong sentence under the pane on the terminal where the
190 /// other reason was the true one.
191 Cannot(Maps),
192}
193
194/// The pane the map goes in, in cells and in pixels.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub struct Pane {
197 /// Where the image goes, in terminal cells.
198 pub cells: Rect,
199 /// How many pixels one cell is, across and down.
200 pub cell: (u16, u16),
201}
202
203impl Pane {
204 /// The pane's size in pixels, or `None` if the terminal reports no pixel size.
205 ///
206 /// A terminal that answers `TIOCGWINSZ` with zeros is one that does not know how big its
207 /// own cells are, and an image sized from a guess is an image that does not line up with
208 /// the text beside it.
209 #[must_use]
210 pub fn pixels(&self) -> Option<(u32, u32)> {
211 if self.cell.0 == 0 || self.cell.1 == 0 {
212 return None;
213 }
214 let across = u32::from(self.cells.width) * u32::from(self.cell.0);
215 let down = u32::from(self.cells.height) * u32::from(self.cell.1);
216 (across > 0 && down > 0).then_some((across, down))
217 }
218
219 /// How wide a map pane should be beside a tree in a terminal `width` columns across, or
220 /// `None` when there is not room for both.
221 #[must_use]
222 pub fn width_in(width: u16) -> Option<u16> {
223 if width < MIN_WIDTH {
224 return None;
225 }
226 #[expect(
227 clippy::cast_possible_truncation,
228 clippy::cast_sign_loss,
229 reason = "a share of a terminal width, clamped into u16 bounds either side"
230 )]
231 let want = (f32::from(width) * SHARE.0) as u16;
232 Some(want.clamp(SHARE.1, SHARE.2).min(width / 2))
233 }
234}
235
236/// The image on the terminal, and the promise to take it back.
237///
238/// Generic over its sink for [`super::chrome::Chrome`]'s reason, which is sharper here: every
239/// byte this writes is invisible to every other kind of test, and the one that matters most —
240/// an image left in the terminal's memory after the process has gone — is invisible to the
241/// *reader* too.
242#[derive(Debug)]
243pub struct Screen<W: Write> {
244 out: W,
245 /// Whether this terminal is known to read the protocol at all.
246 ///
247 /// Half of [`Screen::mapping`]'s answer and the last gate rather than the only one: the
248 /// view holds the reader's `m` and the renderer holds whether there is room, and both of
249 /// those ask that first. Checked again in [`Screen::show`] because it is the one that must
250 /// never be got wrong — a byte of this written to a terminal that cannot decode it is
251 /// base64 in somebody's scrollback.
252 allowed: bool,
253 /// Whether there is an image on the terminal right now.
254 up: bool,
255 /// What the reader's own hand had set when the picture went up: which directory, which
256 /// row, what is marked, what the filter shows, how big the pane. Changes to this are
257 /// never throttled.
258 steering: u64,
259 /// What the mapped subtree was showing when the picture went up. See
260 /// [`View::map_stamp`].
261 arriving: u64,
262 /// When the image was last written.
263 since: Option<Instant>,
264}
265
266impl<W: Write> Screen<W> {
267 /// A screen that will draw if `allowed`, and never otherwise.
268 pub fn new(out: W, allowed: bool) -> Self {
269 Self {
270 out,
271 allowed,
272 up: false,
273 steering: 0,
274 arriving: 0,
275 since: None,
276 }
277 }
278
279 /// Whether a map could appear at all, given what the terminal has just said one cell
280 /// measures — `None` when it will not say.
281 ///
282 /// The one predicate, asked here rather than in two places: this is the half of the
283 /// answer only the screen knows, and the cell size is the half only the terminal knows,
284 /// and #656 was those two halves being combined nowhere. Asked **every frame**, because
285 /// only one of them is a constant: a window can lose its pixel fields without the program
286 /// at the other end changing — a tmux client attaching, a pane moving between displays.
287 #[must_use]
288 pub fn mapping(&self, cell: Option<(u16, u16)>) -> Maps {
289 Maps::of(self.allowed, cell)
290 }
291
292 /// Draws the map of whatever the cursor is on, if anything has changed since the last one.
293 ///
294 /// Answers what it left on the screen rather than `Ok(())`: see [`Drawn`], and #656 for
295 /// what a silent decline costs.
296 ///
297 /// # Errors
298 ///
299 /// Anything the terminal refuses.
300 pub fn show(&mut self, view: &View, pane: Pane, now: Instant) -> io::Result<Drawn> {
301 if !self.allowed {
302 return Ok(Drawn::Cannot(Maps::Unread));
303 }
304 let Some((width, height)) = pane.pixels() else {
305 self.hide()?;
306 return Ok(Drawn::Cannot(Maps::Unmeasured));
307 };
308 let Some(root) = tiles::focus(view) else {
309 self.hide()?;
310 return Ok(Drawn::Nothing);
311 };
312 let area = Area::of(f64::from(width), f64::from(height));
313 // Asked on every frame and never throttled, because it is not a redraw: a map of a
314 // directory the deleter has just emptied is a picture of something that is no longer
315 // there, and on this tool that is a picture of what was about to be deleted. It is
316 // also free — see [`tiles::mappable`].
317 if !tiles::mappable(view, root, area) {
318 self.hide()?;
319 return Ok(Drawn::Nothing);
320 }
321
322 // Two fingerprints, because the two kinds of change have different deadlines. The
323 // reader's hand is answered on the next frame; the pool's arrivals wait for `SETTLE`,
324 // which is what stops 16,013 prices from each buying a megabyte of redraw.
325 //
326 // Both are taken from what the map is made **of** rather than from the map, and
327 // that is the whole of this. Asking "did the picture change" by building the picture
328 // and comparing costs a squarify, a collapse and two strings per rectangle on every
329 // frame forever — 467 µs against the 2 µs the animation beside it spends to answer
330 // the same question, on a pane showing what it showed last frame.
331 //
332 // The inputs are: which directory is mapped, where the cursor is, what is marked,
333 // what the lens shows, how big the pane is, and whether anything under the mapped
334 // directory has moved. Nothing else reaches [`tiles::plan`] — the order the tree
335 // holds its children in does not, because the map sorts its own rectangles by weight.
336 let steering = fingerprint(&(
337 root,
338 // By `NodeId` and never by row index: rows re-sort as prices land, so an index
339 // that stayed the same names a different directory, and one that changed names
340 // the same one.
341 view.row().map(|row| row.id),
342 view.mark_stamp(),
343 // The whole lens and not just its `/` pattern: the tier and kind axes decide what
344 // [`View::roll`] counts, so a rectangle's area is as much theirs as the pattern's.
345 view.lens(),
346 pane.cells,
347 pane.cell,
348 ));
349 // The map's own stamp and not the tree's: the tree's is lens-blind, and a run opens on
350 // a view that hides a whole tier. See [`View::map_stamp`].
351 let arriving = view.map_stamp(root);
352 let steered = steering != self.steering;
353 let settled = self
354 .since
355 .is_none_or(|last| now.saturating_duration_since(last) >= SETTLE);
356 // Nothing the map is drawn from has moved, so what is on the terminal is still the
357 // right picture — and it is the still frame, which is nearly all of them.
358 if self.up && !steered && arriving == self.arriving {
359 return Ok(Drawn::Map);
360 }
361 if self.up && !steered && !settled {
362 return Ok(Drawn::Map);
363 }
364
365 // Only now, once something is known to have changed, is the map worth laying out.
366 let Some(map) = tiles::plan(view, root, area) else {
367 self.hide()?;
368 return Ok(Drawn::Nothing);
369 };
370 let canvas = paint::paint(&map, width, height);
371 let at = (pane.cells.y + 1, pane.cells.x + 1);
372 let cells = (pane.cells.width, pane.cells.height);
373 // Armed **before** the write, which is [`super::chrome::Chrome::enter`]'s rule and
374 // is load-bearing here for a sharper version of its reason. `put` writes the whole
375 // image and then flushes, so a flush that fails has left a megabyte in the
376 // terminal's memory — and a flag set afterwards would still say there is nothing to
377 // take back. Of the two ways to be wrong, deleting an image that never landed costs
378 // twenty bytes at a terminal already being restored, while skipping the delete
379 // leaves the picture there after this process has gone, with nothing alive to notice.
380 self.up = true;
381 self.put(&Image::shown(&canvas, at, cells))?;
382 // The fingerprints only afterwards, and that is the other half: a write that failed
383 // has left the screen in a state this cannot describe, so the next `show` has to
384 // treat it as a picture it has not drawn and send it again.
385 self.steering = steering;
386 self.arriving = arriving;
387 self.since = Some(now);
388 Ok(Drawn::Map)
389 }
390
391 /// Takes the image down, if one is up.
392 ///
393 /// Called whenever the map cannot be right: an overlay is over it, the terminal has no
394 /// room, or the reader turned it off. Forgetting the fingerprint with it is the part that
395 /// is easy to miss — a map hidden behind the help page and then unhidden is the *same*
396 /// picture, so a `show` that only compared fingerprints would leave the pane blank for
397 /// as long as nothing else changed.
398 ///
399 /// # Errors
400 ///
401 /// Anything the terminal refuses.
402 pub fn hide(&mut self) -> io::Result<()> {
403 if !self.up {
404 return Ok(());
405 }
406 self.steering = 0;
407 self.arriving = 0;
408 self.since = None;
409 self.put(&Image::gone())?;
410 // Cleared only once the delete has actually gone out, which is the mirror of the
411 // arming in [`Screen::show`]. A terminal that refused thirty bytes once will often
412 // take them a moment later, and this runs twice by design — the ordinary way out and
413 // then the guard's `Drop` — so the second pass is a free retry. Clearing the flag
414 // first would spend it on a delete that never left.
415 self.up = false;
416 Ok(())
417 }
418
419 /// Puts back everything this took, and can be called twice.
420 ///
421 /// The state being given back here lives in **another program's memory**: an image is
422 /// stored by the terminal, and a process that exits without deleting one leaves a
423 /// megabyte behind with nothing alive to notice. #619's rule — a state that cannot be
424 /// given back is a state you do not take — with the same answer, an id and a delete.
425 ///
426 /// # Errors
427 ///
428 /// Anything the terminal refuses.
429 pub fn restore(&mut self) -> io::Result<()> {
430 self.hide()
431 }
432
433 fn put(&mut self, bytes: &[u8]) -> io::Result<()> {
434 self.out.write_all(bytes)?;
435 self.out.flush()
436 }
437
438 /// What has been written, for the tests that are about exactly that.
439 #[cfg(test)]
440 pub(crate) fn sink(&self) -> &W {
441 &self.out
442 }
443}
444
445/// One number standing for a value, for "has this changed since last frame".
446fn fingerprint(of: &impl Hash) -> u64 {
447 let mut hasher = DefaultHasher::new();
448 of.hash(&mut hasher);
449 hasher.finish()
450}
451
452#[cfg(test)]
453mod tests {
454 use super::{Drawn, MIN_WIDTH, Maps, Pane, SETTLE, Screen, kitty, paint, tiles};
455 use crate::fixture::{gitignored, hit, priced};
456 use crate::size::Size;
457 use crate::tree::Tree;
458 use crate::tui::keymap::{Action, Motion};
459 use crate::tui::state::View;
460 use crate::tui::treemap::tiles::Area;
461 use ratatui::layout::Rect;
462 use std::sync::Arc;
463 use std::sync::atomic::{AtomicBool, Ordering};
464 use std::time::{Duration, Instant};
465
466 /// A view with the map turned on, which is the only kind that draws one.
467 ///
468 /// Said out loud in every fixture here rather than defaulted, because it is what decides
469 /// whether [`View::map_stamp`] has its lens-aware table behind it or falls back to the
470 /// tree's lens-blind one — so a test that left it off would be asserting about a run
471 /// nobody has.
472 fn view() -> View {
473 let mut tree = Tree::new("/scan");
474 tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
475 tree.insert(priced("/scan/pua/target", 2 * 1024 * 1024));
476 let mut view = View::new(tree);
477 view.allow_maps(Maps::Can);
478 view.sync();
479 view
480 }
481
482 fn pane() -> Pane {
483 Pane {
484 cells: Rect::new(60, 1, 40, 30),
485 cell: (9, 19),
486 }
487 }
488
489 fn screen() -> Screen<Vec<u8>> {
490 Screen::new(Vec::new(), true)
491 }
492
493 fn written(screen: &Screen<Vec<u8>>) -> String {
494 String::from_utf8_lossy(screen.sink()).into_owned()
495 }
496
497 #[test]
498 fn a_terminal_that_is_not_known_to_read_the_protocol_is_written_nothing() {
499 // The property the whole module rests on, and the one the task made a hard
500 // requirement: not one byte reaches a terminal that might not understand it.
501 let mut screen = Screen::new(Vec::new(), false);
502 let view = view();
503 screen.show(&view, pane(), Instant::now()).unwrap();
504 screen.hide().unwrap();
505 screen.restore().unwrap();
506
507 assert_eq!(written(&screen), "", "an escape reached a terminal");
508 // …and a pixel size it *does* report changes nothing: the allowlist is the gate this
509 // one fails, and the answer names which.
510 assert_eq!(screen.mapping(Some((9, 19))), Maps::Unread);
511 }
512
513 #[test]
514 fn a_terminal_that_reports_no_pixel_size_gets_no_map() {
515 // Most terminals answer `TIOCGWINSZ` with zeros for the pixel fields. An image sized
516 // from a guess at the cell size is one that does not line up with the text beside it.
517 let mut screen = screen();
518 let view = view();
519 let drawn = screen
520 .show(
521 &view,
522 Pane {
523 cell: (0, 0),
524 ..pane()
525 },
526 Instant::now(),
527 )
528 .unwrap();
529 assert_eq!(written(&screen), "");
530 // #656's second half: the caller is told it drew nothing. This returning `Ok(())` was
531 // indistinguishable from the still frame below, which is why a pane that never got a
532 // picture looked exactly like one that did not need a new one.
533 assert_eq!(drawn, Drawn::Cannot(Maps::Unmeasured));
534 }
535
536 #[test]
537 fn both_gates_come_back_as_one_answer_that_names_which_of_them_refused() {
538 // #656. The allowlist is a fact about the program at the other end and the pixel size
539 // is a fact about its window, and the bug was that only the first reached the layout:
540 // inside tmux the outer terminal passes the allowlist while the winsize pixel fields
541 // are zero, so the tree paid 36 columns for a picture the draw then refused to make.
542 //
543 // One predicate now answers both, and it says *which* — because taking the pane away
544 // silently is the same failure in the other direction.
545 let screen = screen();
546 assert_eq!(screen.mapping(Some((9, 19))), Maps::Can);
547 assert_eq!(screen.mapping(None), Maps::Unmeasured);
548 assert!(Maps::Can.can());
549 assert!(!Maps::Unmeasured.can() && !Maps::Unread.can());
550
551 // Two reasons and two sentences: a reader inside tmux has something to act on, and a
552 // reader on a terminal that will never read the protocol does not.
553 let unmeasured = Maps::Unmeasured.why().unwrap();
554 assert!(unmeasured.contains("pixel size"), "{unmeasured}");
555 assert_ne!(unmeasured, Maps::Unread.why().unwrap());
556 assert_eq!(Maps::Can.why(), None);
557 // A view nobody has told anything to has not been told this either.
558 assert_eq!(Maps::default(), Maps::Unread);
559 }
560
561 #[test]
562 fn a_still_frame_says_the_map_is_up_rather_than_saying_nothing_at_all() {
563 // The distinction [`Drawn`] exists for. Both of these wrote no bytes, and until they
564 // answered they were the same `Ok(())`: one is the picture already being right, the
565 // other is there being no picture at all.
566 let mut screen = screen();
567 let view = view();
568 let now = Instant::now();
569 assert_eq!(screen.show(&view, pane(), now).unwrap(), Drawn::Map);
570 let first = screen.sink().len();
571 assert_eq!(screen.show(&view, pane(), now).unwrap(), Drawn::Map);
572 assert_eq!(
573 screen.sink().len(),
574 first,
575 "the map was redrawn for nothing"
576 );
577
578 // And the third answer, which is neither: the terminal could draw one and the tree has
579 // nothing to divide into rectangles. Not a fault in anything, so the caller must not
580 // read it as the pane declining.
581 let empty = View::new(Tree::new("/scan"));
582 assert_eq!(screen.show(&empty, pane(), now).unwrap(), Drawn::Nothing);
583 }
584
585 #[test]
586 fn the_pane_gives_way_to_the_tree_rather_than_the_other_way_round() {
587 assert_eq!(Pane::width_in(MIN_WIDTH - 1), None);
588 // Wide enough for both, and never more than half — the tree is the interface and
589 // this is an enhancement to it.
590 let wide = Pane::width_in(200).unwrap();
591 assert!(wide <= 56, "{wide} columns of a 200-column terminal");
592 assert!(Pane::width_in(MIN_WIDTH).unwrap() <= MIN_WIDTH / 2);
593 }
594
595 #[test]
596 fn a_map_is_drawn_once_and_not_again_until_the_picture_changes() {
597 let mut screen = screen();
598 let mut view = view();
599 let now = Instant::now();
600 screen.show(&view, pane(), now).unwrap();
601 let first = screen.sink().len();
602 assert!(first > 1000, "nothing was drawn");
603
604 // A frame in which nothing happened. The whole reason this is affordable at 10 fps.
605 screen.show(&view, pane(), now).unwrap();
606 assert_eq!(
607 screen.sink().len(),
608 first,
609 "the map was redrawn for nothing"
610 );
611
612 // The reader's own hand, which is never throttled: the same instant, and it redraws.
613 view.apply(Action::Cursor(Motion::Down));
614 screen.show(&view, pane(), now).unwrap();
615 assert!(screen.sink().len() > first, "steering did not redraw");
616 }
617
618 #[test]
619 fn a_price_landing_waits_for_the_map_to_settle_and_the_cursor_never_does() {
620 let mut tree = Tree::new("/scan");
621 tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
622 tree.insert(hit("/scan/pua/target", Size::Unmeasured, 0));
623 let mut view = View::new(tree);
624 view.allow_maps(Maps::Can);
625 view.sync();
626 let mut screen = screen();
627 let now = Instant::now();
628 screen.show(&view, pane(), now).unwrap();
629 let first = screen.sink().len();
630
631 // 16,013 of these arrive over a minute during a breakdown. Answering each one costs
632 // a megabyte to say something the reader cannot read at that rate anyway.
633 view.priced(
634 std::path::Path::new("/scan/pua/target"),
635 Size::Measured(4096),
636 );
637 view.sync();
638 screen
639 .show(&view, pane(), now + Duration::from_millis(30))
640 .unwrap();
641 assert_eq!(screen.sink().len(), first, "an arrival redrew immediately");
642
643 screen.show(&view, pane(), now + SETTLE).unwrap();
644 assert!(screen.sink().len() > first, "the map never caught up");
645 }
646
647 #[test]
648 fn the_map_of_a_directory_that_has_been_deleted_comes_down_without_waiting_to_settle() {
649 // [`SETTLE`] holds back *redraws*, and taking the picture down is not one. The
650 // difference is the whole of what the pane is for: a map that is 250 ms late is a map
651 // nobody notices, and a map of a directory that is no longer on the disk is a picture
652 // of what was about to be deleted, still up after it has gone.
653 let mut tree = Tree::new("/scan");
654 tree.insert(priced("/scan/only/node_modules", 8 * 1024 * 1024));
655 let mut view = View::new(tree);
656 view.allow_maps(Maps::Can);
657 view.sync();
658 let mut screen = screen();
659 let now = Instant::now();
660 view.animate(now);
661 screen.show(&view, pane(), now).unwrap();
662 let before = screen.sink().len();
663
664 view.removed(
665 std::path::Path::new("/scan/only/node_modules"),
666 8 * 1024 * 1024,
667 true,
668 );
669 let later = now + crate::tui::moving::DIM;
670 view.animate(later);
671
672 // Well inside the settle, which is what an arrival would be made to wait for.
673 assert!(crate::tui::moving::DIM < SETTLE);
674 screen.show(&view, pane(), later).unwrap();
675 let said = written(&screen);
676 assert!(
677 said.len() > before && said.ends_with("d=I,i=1976622,q=2\x1b\\"),
678 "the map outlived the directory it was of"
679 );
680 }
681
682 #[test]
683 fn an_arrival_outside_the_mapped_directory_is_not_a_redraw() {
684 // The property that makes this affordable at all, and the one a fingerprint taken
685 // over the whole view rather than over the mapped subtree would lose: during a
686 // breakdown 16,013 prices land, and the reader is looking at one directory. A
687 // megabyte spent redrawing a picture that did not change is the cost this whole pane
688 // is arguing with.
689 let mut tree = Tree::new("/scan");
690 tree.insert(priced("/scan/here/one/node_modules", 4 * 1024 * 1024));
691 tree.insert(priced("/scan/here/two/node_modules", 2 * 1024 * 1024));
692 tree.insert(priced("/scan/there/target", 1024));
693 let mut view = View::new(tree);
694 view.allow_maps(Maps::Can);
695 view.sync();
696 view.apply(Action::Cursor(Motion::Down));
697 let here = view
698 .tree()
699 .find(std::path::Path::new("/scan/here"))
700 .unwrap();
701 assert_eq!(tiles::focus(&view), Some(here));
702
703 let mut screen = screen();
704 let now = Instant::now();
705 screen.show(&view, pane(), now).unwrap();
706 let first = screen.sink().len();
707
708 // Big enough to sort above the mapped directory, so the row the cursor is on lands at
709 // a different index: rows are named by `NodeId` and never by where they happen to be
710 // this frame, here as everywhere else.
711 view.found(priced("/scan/there/huge/node_modules", 64 * 1024 * 1024));
712 view.sync();
713 assert_eq!(
714 tiles::focus(&view),
715 Some(here),
716 "the map moved off its own directory"
717 );
718
719 screen.show(&view, pane(), now + SETTLE).unwrap();
720 assert_eq!(
721 screen.sink().len(),
722 first,
723 "a claim landing somewhere else redrew a picture that did not change"
724 );
725 }
726
727 #[test]
728 fn a_mark_is_the_readers_own_hand_rather_than_an_arrival_and_is_not_made_to_wait() {
729 let mut screen = screen();
730 let mut view = view();
731 let now = Instant::now();
732 view.apply(Action::Cursor(Motion::Down));
733 screen.show(&view, pane(), now).unwrap();
734 let before = screen.sink().len();
735
736 // A mark moves no bytes and no claims — it turns a rectangle aqua — so nothing the
737 // tree reports would say the picture changed. It is still the reader's own hand, and
738 // [`SETTLE`] is for the arrivals nobody asked for.
739 view.apply(Action::Mark);
740 screen.show(&view, pane(), now).unwrap();
741 assert!(
742 screen.sink().len() > before,
743 "a mark waited for a settle that is not for it"
744 );
745 }
746
747 #[test]
748 fn a_filter_redraws_the_map_it_narrows_at_once() {
749 let mut screen = screen();
750 let mut view = view();
751 let now = Instant::now();
752 screen.show(&view, pane(), now).unwrap();
753 let before = screen.sink().len();
754
755 // Half the rectangles stop existing and the tree behind them never moved. The reader
756 // typed this, so it is answered on the next frame.
757 view.apply(Action::OpenFilter);
758 for character in "target".chars() {
759 view.apply(Action::Type(character));
760 }
761 view.apply(Action::Submit);
762 screen.show(&view, pane(), now).unwrap();
763 assert!(
764 screen.sink().len() > before,
765 "the map went on showing what the filter took away"
766 );
767 }
768
769 #[test]
770 fn a_claim_the_view_hides_arriving_under_the_mapped_directory_is_not_a_redraw() {
771 // The lens-blind half of the tree's own stamp, and the case a run meets from its
772 // first frame: `default` hides the gitignored tier, so tier-two claims stream in
773 // under the very directory the map is of while changing not one rectangle. Answering
774 // each of those is a megabyte down the pty to redraw the picture already on it.
775 let mut tree = Tree::new("/scan");
776 tree.insert(priced("/scan/here/one/node_modules", 4 * 1024 * 1024));
777 tree.insert(priced("/scan/here/two/node_modules", 2 * 1024 * 1024));
778 let mut view = View::new(tree);
779 view.allow_maps(Maps::Can);
780 view.sync();
781 view.apply(Action::Cursor(Motion::Down));
782 let here = view
783 .tree()
784 .find(std::path::Path::new("/scan/here"))
785 .unwrap();
786 assert_eq!(tiles::focus(&view), Some(here));
787
788 let mut screen = screen();
789 let now = Instant::now();
790 screen.show(&view, pane(), now).unwrap();
791 let first = screen.sink().len();
792 let was = view.roll(here);
793
794 // Inside the mapped directory, and enormous — but the view a run opens on does not
795 // show the gitignored tier, so the map has nothing to say about it.
796 let mut unseen = gitignored("/scan/here/three/vendor");
797 unseen.size = Size::Measured(64 * 1024 * 1024);
798 view.found(unseen);
799 view.sync();
800 assert_eq!(
801 view.roll(here),
802 was,
803 "the fixture no longer makes the point — the claim has to be invisible"
804 );
805
806 screen.show(&view, pane(), now + SETTLE).unwrap();
807 assert_eq!(
808 screen.sink().len(),
809 first,
810 "a claim the view hides redrew a map that cannot draw it"
811 );
812
813 // …and the moment the reader widens the view to include it, it is a new picture.
814 view.apply(Action::CycleTiers);
815 screen.show(&view, pane(), now + SETTLE).unwrap();
816 assert!(
817 screen.sink().len() > first,
818 "the map never caught up with the view widening"
819 );
820 }
821
822 #[test]
823 fn narrowing_the_view_by_kind_redraws_the_map_it_narrows() {
824 // The lens is more than its `/` pattern: what [`View::roll`] counts is the tier and
825 // kind axes as well, so a rectangle's area is theirs too. A fingerprint that watched
826 // only the pattern would leave `b` on a map of dependencies.
827 let mut tree = Tree::new("/scan");
828 tree.insert(priced("/scan/a/node_modules", 8 * 1024 * 1024));
829 tree.insert(priced("/scan/b/target", 2 * 1024 * 1024));
830 let mut view = View::new(tree);
831 view.allow_maps(Maps::Can);
832 view.sync();
833 let mut screen = screen();
834 let now = Instant::now();
835 screen.show(&view, pane(), now).unwrap();
836 let before = screen.sink().len();
837
838 view.apply(Action::ToggleKind(crate::rules::Kind::Build));
839 screen.show(&view, pane(), now).unwrap();
840 assert!(
841 screen.sink().len() > before,
842 "the map went on drawing what the view stopped showing"
843 );
844 }
845
846 #[test]
847 fn a_claim_arriving_as_another_leaves_is_a_new_picture_even_though_the_totals_match() {
848 // The trap in deriving the fingerprint from what the map is made *of*: the obvious
849 // cheap summary — this subtree's bytes, claims and unpriced count — is three numbers
850 // that a deletion and an arrival in the same frame put back exactly where they were.
851 // A false "unchanged" here is a stale picture of a tree that has moved, which on this
852 // tool is a stale picture of what is about to be deleted.
853 let mut tree = Tree::new("/scan");
854 tree.insert(hit("/scan/going/node_modules", Size::Unmeasured, 0));
855 tree.insert(hit("/scan/staying/target", Size::Unmeasured, 0));
856 let mut view = View::new(tree);
857 view.allow_maps(Maps::Can);
858 view.sync();
859 let mut screen = screen();
860 let now = Instant::now();
861 view.animate(now);
862 screen.show(&view, pane(), now).unwrap();
863 let before = screen.sink().len();
864 let totals = view.total();
865
866 view.removed(std::path::Path::new("/scan/going/node_modules"), 0, true);
867 // The drained row leaves the tree here, and the walk finds another in the same frame.
868 let later = now + crate::tui::moving::DIM;
869 view.animate(later);
870 view.found(hit("/scan/arrived/node_modules", Size::Unmeasured, 0));
871 view.sync();
872
873 assert_eq!(
874 view.total(),
875 totals,
876 "the fixture no longer makes the point — the totals have to be identical"
877 );
878 screen.show(&view, pane(), later + SETTLE).unwrap();
879 assert!(
880 screen.sink().len() > before,
881 "the map is still drawing a directory that has been deleted"
882 );
883 }
884
885 #[test]
886 fn cycling_the_sort_is_not_a_new_picture() {
887 // The one input left out of the fingerprint on purpose, so it is asserted rather than
888 // assumed: the map orders its own rectangles by weight with the id breaking ties, so
889 // what order the tree holds its children in is not something the picture can see.
890 let mut screen = screen();
891 let mut view = view();
892 let now = Instant::now();
893 screen.show(&view, pane(), now).unwrap();
894 let before = screen.sink().len();
895
896 view.apply(Action::CycleSort);
897 view.sync();
898 screen.show(&view, pane(), now + SETTLE).unwrap();
899 assert_eq!(
900 screen.sink().len(),
901 before,
902 "re-sorting the tree spent a megabyte on the same picture"
903 );
904 }
905
906 /// A terminal that takes every byte and then refuses to flush them.
907 ///
908 /// The narrowest injection that reaches the finding: `write_all` succeeds, so the image
909 /// really is in the terminal's memory, and only the flush fails.
910 struct Unflushable {
911 written: Vec<u8>,
912 refusing: Arc<AtomicBool>,
913 }
914
915 impl std::io::Write for Unflushable {
916 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
917 self.written.extend_from_slice(buf);
918 Ok(buf.len())
919 }
920
921 fn flush(&mut self) -> std::io::Result<()> {
922 if self.refusing.load(Ordering::SeqCst) {
923 return Err(std::io::Error::other("the terminal stopped listening"));
924 }
925 Ok(())
926 }
927 }
928
929 #[test]
930 fn an_image_the_terminal_took_but_would_not_flush_is_still_taken_back() {
931 // The one failure that would leave a megabyte in somebody's terminal after this
932 // process has gone. `write_all` puts the whole image there and the flush then fails,
933 // so a `show` that armed the cleanup *after* the write would report the error and
934 // leave `restore` with nothing to undo — the image outliving the run that made it,
935 // with nothing alive to notice.
936 let refusing = Arc::new(AtomicBool::new(true));
937 let mut screen = Screen::new(
938 Unflushable {
939 written: Vec::new(),
940 refusing: Arc::clone(&refusing),
941 },
942 true,
943 );
944 let view = view();
945
946 assert!(
947 screen.show(&view, pane(), Instant::now()).is_err(),
948 "the flush was supposed to fail"
949 );
950 let sent = String::from_utf8_lossy(&screen.sink().written).into_owned();
951 assert!(
952 sent.contains("\x1b_Ga=T,"),
953 "the image never reached the terminal, so there is nothing to prove"
954 );
955
956 // The way out, while the terminal is still refusing: the delete is attempted and
957 // reported rather than swallowed, and — the half that matters — it is not written off
958 // as done. This runs twice by design, so a refusal on the first pass has to leave
959 // something for the second.
960 assert!(
961 screen.restore().is_err(),
962 "a refused delete was called done"
963 );
964
965 // The terminal comes back, which is the ordinary shape of a transient write failure
966 // on a pty whose buffer was full for a moment.
967 refusing.store(false, Ordering::SeqCst);
968 screen.restore().unwrap();
969
970 let said = String::from_utf8_lossy(&screen.sink().written).into_owned();
971 assert!(
972 said.ends_with("d=I,i=1976622,q=2\x1b\\"),
973 "the image was left in the terminal: {:?}",
974 &said[said.len() - 60..]
975 );
976 // One in the placement's own prologue, then the two attempts on the way out.
977 assert_eq!(said.matches("a=d,d=I").count(), 3, "unbalanced");
978 // …and once it has genuinely gone, saying so again writes nothing.
979 let settled = screen.sink().written.len();
980 screen.restore().unwrap();
981 assert_eq!(screen.sink().written.len(), settled, "deleted twice");
982 }
983
984 #[test]
985 fn the_image_is_taken_back_on_the_way_out_and_hiding_it_forgets_what_was_up() {
986 let mut screen = screen();
987 let view = view();
988 let now = Instant::now();
989 screen.show(&view, pane(), now).unwrap();
990 screen.restore().unwrap();
991 assert!(
992 written(&screen).ends_with("d=I,i=1976622,q=2\x1b\\"),
993 "left behind"
994 );
995
996 let after = screen.sink().len();
997 // Idempotent, because it runs from two places by design — the ordinary way out and
998 // the guard that owns it being dropped by a `?` or a panic.
999 screen.restore().unwrap();
1000 assert_eq!(screen.sink().len(), after, "taken back twice");
1001
1002 // …and the same picture is drawn again afterwards. A `show` that only compared
1003 // fingerprints would leave the pane blank behind a closed help page.
1004 screen.show(&view, pane(), now).unwrap();
1005 assert!(screen.sink().len() > after, "the map never came back");
1006 }
1007
1008 /// What one map costs, end to end, at the size a real terminal gives.
1009 ///
1010 /// `cargo test --release --lib measure_one_map -- --ignored --nocapture`. Kept as a test
1011 /// rather than written down once, because the number that decides whether this feature
1012 /// can live inside a 100 ms frame is a number the next person has to be able to re-take.
1013 #[test]
1014 #[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
1015 fn measure_one_map() {
1016 // #602's own fixture shape, cut to the part a map ever looks at: the map draws one
1017 // level and what nests inside it, never the 22,765 rows behind it.
1018 let mut tree = Tree::new("/home");
1019 for repo in 0..300 {
1020 for pkg in 0..20_u64 {
1021 tree.insert(priced(
1022 &format!("/home/repos/r{repo}/packages/p{pkg}/node_modules"),
1023 4096 * (pkg + 1),
1024 ));
1025 }
1026 }
1027 for n in 0..8_660 {
1028 tree.insert(priced(&format!("/home/types/p{n}/node_modules"), 1024));
1029 }
1030 for n in 0..1_353 {
1031 tree.insert(hit(
1032 &format!("/home/cache/e{n}/target"),
1033 Size::Unmeasured,
1034 0,
1035 ));
1036 }
1037 let mut view = View::new(tree);
1038 view.allow_maps(Maps::Can);
1039 view.sync();
1040 view.viewport(50);
1041 // As a run that draws one has it, so the still frame below is measured against the
1042 // lens-aware stamp rather than the fallback. See [`View::map_stamp`].
1043 view.allow_maps(Maps::Can);
1044 view.sync();
1045 println!("claims: {}", view.total().claims);
1046
1047 // 44 columns of a 120-column window, 34 rows, at a retina Ghostty's 9×19 px cell.
1048 let pane = Pane {
1049 cells: Rect::new(76, 2, 44, 34),
1050 cell: (9, 19),
1051 };
1052 let (width, height) = pane.pixels().unwrap();
1053 println!("pane: {width}×{height} px");
1054
1055 let started = Instant::now();
1056 let root = tiles::focus(&view).unwrap();
1057 let map = tiles::plan(&view, root, Area::of(f64::from(width), f64::from(height))).unwrap();
1058 println!(
1059 "plan: {:?} -> {} rectangles",
1060 started.elapsed(),
1061 map.tiles.len()
1062 );
1063
1064 let started = Instant::now();
1065 let canvas = paint::paint(&map, width, height);
1066 println!(
1067 "paint: {:?} -> {} px",
1068 started.elapsed(),
1069 canvas.rgb.len() / 3
1070 );
1071
1072 let started = Instant::now();
1073 let bytes = kitty::Image::shown(&canvas, (3, 77), (44, 34));
1074 println!(
1075 "encode: {:?} -> {} bytes down the pty",
1076 started.elapsed(),
1077 bytes.len()
1078 );
1079
1080 // The frame that matters most: the one where nothing happened. A map redrawn ten
1081 // times a second to say nothing is the thing that would sink this.
1082 let mut screen = screen();
1083 let now = Instant::now();
1084 screen.show(&view, pane, now).unwrap();
1085 let started = Instant::now();
1086 for _ in 0..100 {
1087 screen.show(&view, pane, now).unwrap();
1088 }
1089 println!("100 still frames: {:?}", started.elapsed());
1090
1091 // And the one where the reader moved.
1092 let started = Instant::now();
1093 view.apply(Action::Cursor(Motion::Down));
1094 screen.show(&view, pane, now).unwrap();
1095 println!("one steer: {:?}", started.elapsed());
1096 }
1097
1098 #[test]
1099 fn a_view_with_nothing_in_it_takes_the_map_down_rather_than_drawing_an_empty_one() {
1100 let mut screen = screen();
1101 let view = view();
1102 screen.show(&view, pane(), Instant::now()).unwrap();
1103 let before = screen.sink().len();
1104
1105 // The cursor is on the scan root, which is a directory like any other — so the map
1106 // is refused by `plan` having nothing to divide rather than by there being nowhere
1107 // to point at. Both roads end at the image coming down.
1108 let empty = View::new(Tree::new("/scan"));
1109 assert_eq!(tiles::focus(&empty), Some(empty.tree().root()));
1110 screen.show(&empty, pane(), Instant::now()).unwrap();
1111 let said = written(&screen);
1112 assert!(said.len() > before, "the map was left showing a stale tree");
1113 assert!(
1114 said.ends_with("d=I,i=1976622,q=2\x1b\\"),
1115 "{}",
1116 &said[said.len() - 40..]
1117 );
1118 }
1119}