Skip to main content

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}