Skip to main content

pristine/tui/treemap/
paint.rs

1//! Turning a [`Map`] into pixels.
2//!
3//! # Why there is a font in here
4//!
5//! A treemap with no labels is a shape, not an answer. "Where are the bytes" is only answered
6//! if the big rectangle says which directory it is, and the terminal cannot help: a graphics
7//! image covers the cells it is placed over, so the text has to be *in* the image. Hence
8//! [`FONT`] — 95 glyphs of 5×7, which is the smallest thing that can spell `node_modules`
9//! legibly at the size a terminal cell gives.
10//!
11//! # Colour is not the identity encoding, and that is deliberate
12//!
13//! Rectangles are told apart by a 2 px surface gap and by their own labels, never by hue. A
14//! treemap's neighbours are arbitrary — any tile can end up beside any other — so a palette
15//! used for identity here would have to clear the all-pairs colour-blindness gate, and no
16//! eight-hue palette does. So hue carries **state** instead, over exactly two slots that do
17//! clear it all-pairs on this surface (blue ↔ aqua, CVD ΔE 19.6): unmarked and marked. Depth
18//! is a lightness step within the hue, and "nobody has measured this" is texture rather than
19//! a third colour — which is also the right encoding for it, because texture is what a
20//! reader reads as *absence of data* rather than as another category.
21
22use super::tiles::{Area, Kind, Map, Tile};
23
24/// One colour, as the protocol wants it.
25type Rgb = [u8; 3];
26
27/// The chart surface — and the gap between two rectangles, which is the same thing.
28const SURFACE: Rgb = [0x1a, 0x1a, 0x19];
29/// A rectangle nobody has marked. Blue, slot 1 of the validated pair.
30const PRICED: [Rgb; 2] = [[0x39, 0x87, 0xe5], [0x6d, 0xa7, 0xec]];
31/// A rectangle the reader has marked. Aqua, slot 2 — the pair clears every all-pairs gate on
32/// this surface, which is what lets state be carried by hue when identity cannot be.
33const MARKED: [Rgb; 2] = [[0x19, 0x9e, 0x70], [0x5e, 0xbb, 0x98]];
34/// What nobody has measured: the diverging pair's neutral, under a hatch.
35const UNKNOWN: Rgb = [0x38, 0x38, 0x35];
36/// The hatch itself, and the marked version of it.
37const HATCH: [Rgb; 2] = [[0x6b, 0x6a, 0x64], [0x19, 0x9e, 0x70]];
38/// The cursor's outline. Not a hue: "you are here" is not a category.
39const HERE: Rgb = [0xff, 0xff, 0xff];
40/// A directory's name.
41const INK: Rgb = [0xff, 0xff, 0xff];
42/// What it is worth.
43const MUTED: Rgb = [0xc3, 0xc2, 0xb7];
44/// Under every glyph, one pixel down and right.
45///
46/// White on the blue is 3.6:1, which is fine for a heading and thin for a 7 px label. A
47/// shadow costs one more blit and takes the contrast the text is actually read against out of
48/// the palette's hands entirely — which matters here because the fill under a label is
49/// whatever the map put there.
50const SHADOW: Rgb = [0x0b, 0x0b, 0x0a];
51
52/// Pixels per glyph, and the gap after it.
53const GLYPH: (u32, u32) = (5, 7);
54/// How far the next character starts.
55const ADVANCE: u32 = 6;
56/// The blank between a rectangle's edge and its label.
57const PAD: u32 = 4;
58/// The gap between a name and the figure under it.
59const LEADING: u32 = 9;
60/// How thick the cursor's outline is.
61const RING: u32 = 2;
62/// The blanks between a name and the figure beside it, when they share a line.
63const BESIDE: u32 = 2;
64
65/// An image, in the layout the graphics protocol's `f=24` wants: three bytes a pixel, rows
66/// top to bottom, no padding.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Canvas {
69    /// Across, in pixels.
70    pub width: u32,
71    /// Down, in pixels.
72    pub height: u32,
73    /// `width * height * 3` bytes of it.
74    pub rgb: Vec<u8>,
75}
76
77impl Canvas {
78    /// A canvas of nothing but surface.
79    #[must_use]
80    pub fn new(width: u32, height: u32) -> Self {
81        let count = (width as usize) * (height as usize);
82        let mut rgb = Vec::with_capacity(count * 3);
83        for _ in 0..count {
84            rgb.extend_from_slice(&SURFACE);
85        }
86        Self { width, height, rgb }
87    }
88
89    /// Writes one pixel, ignoring anything outside the canvas.
90    fn dot(&mut self, x: u32, y: u32, colour: Rgb) {
91        if x >= self.width || y >= self.height {
92            return;
93        }
94        let at = ((y as usize) * (self.width as usize) + (x as usize)) * 3;
95        self.rgb[at..at + 3].copy_from_slice(&colour);
96    }
97
98    /// Fills a rectangle.
99    fn fill(&mut self, at: Box, colour: Rgb) {
100        for y in at.top..at.bottom {
101            for x in at.left..at.right {
102                self.dot(x, y, colour);
103            }
104        }
105    }
106
107    /// Fills a rectangle with 45° stripes over a flat base.
108    ///
109    /// The texture, and the one place the map admits it does not know something. Six pixels
110    /// apart is close enough to read as a fill at a glance and open enough that a label on
111    /// top of it stays legible.
112    fn hatch(&mut self, at: Box, base: Rgb, ink: Rgb) {
113        self.fill(at, base);
114        for y in at.top..at.bottom {
115            for x in at.left..at.right {
116                if (x + y) % 6 < 2 {
117                    self.dot(x, y, ink);
118                }
119            }
120        }
121    }
122
123    /// Draws a border just inside a rectangle.
124    ///
125    /// Two pixels rather than one, which the first render settled: a hairline of white on a
126    /// mid blue is invisible at the size a terminal cell gives, and an outline nobody can see
127    /// is an answer to "where am I" that is not given.
128    fn outline(&mut self, at: Box, colour: Rgb) {
129        for ring in 0..RING {
130            for x in at.left..at.right {
131                self.dot(x, at.top + ring, colour);
132                self.dot(x, at.bottom.saturating_sub(1 + ring), colour);
133            }
134            for y in at.top..at.bottom {
135                self.dot(at.left + ring, y, colour);
136                self.dot(at.right.saturating_sub(1 + ring), y, colour);
137            }
138        }
139    }
140
141    /// Draws a string, shadowed, and says how wide it came out.
142    ///
143    /// Characters the font does not carry are drawn as `?` rather than skipped: a name with a
144    /// hole in it is harder to recognise than one with a wrong glyph, and a directory whose
145    /// name is not ASCII is somebody's real directory.
146    fn write(&mut self, x: u32, y: u32, said: &str, colour: Rgb) {
147        let mut at = x;
148        for character in said.chars() {
149            let glyph = glyph_of(character);
150            // The whole shadow before any of the ink, so a glyph's own shadow cannot land on
151            // top of the stroke it is under.
152            for (offset, ink) in [((1, 1), SHADOW), ((0, 0), colour)] {
153                for (row, bits) in (0..).zip(glyph) {
154                    for column in 0..GLYPH.0 {
155                        if bits & (1 << (GLYPH.0 - 1 - column)) != 0 {
156                            self.dot(at + column + offset.0, y + row + offset.1, ink);
157                        }
158                    }
159                }
160            }
161            at += ADVANCE;
162        }
163    }
164
165    /// The canvas as a `P6` portable pixmap, which is what the visual check writes out.
166    #[cfg(test)]
167    fn ppm(&self) -> Vec<u8> {
168        let mut out = format!("P6\n{} {}\n255\n", self.width, self.height).into_bytes();
169        out.extend_from_slice(&self.rgb);
170        out
171    }
172}
173
174/// A rectangle in whole pixels, clamped to the canvas — what [`Area`]'s floats become once.
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176struct Box {
177    left: u32,
178    top: u32,
179    right: u32,
180    bottom: u32,
181}
182
183impl Box {
184    /// The pixels an [`Area`] covers, pulled in by one so neighbours are separated by two.
185    ///
186    /// The gap is the separation the palette is *not* asked to provide: a treemap's
187    /// neighbours are arbitrary, so two adjacent rectangles of the same state have to be
188    /// distinguishable, and surface between them does that whatever the colours are.
189    fn of(area: Area) -> Option<Self> {
190        #[expect(
191            clippy::cast_possible_truncation,
192            clippy::cast_sign_loss,
193            reason = "areas are pane-sized and non-negative by construction; the max(0.0) \
194                      below is what makes the sign loss unreachable rather than merely \
195                      unlikely"
196        )]
197        let (left, top, right, bottom) = (
198            area.x.max(0.0).round() as u32 + 1,
199            area.y.max(0.0).round() as u32 + 1,
200            (area.x + area.w).max(0.0).round() as u32,
201            (area.y + area.h).max(0.0).round() as u32,
202        );
203        (right > left && bottom > top).then_some(Self {
204            left,
205            top,
206            right,
207            bottom,
208        })
209    }
210
211    fn width(self) -> u32 {
212        self.right - self.left
213    }
214
215    fn height(self) -> u32 {
216        self.bottom - self.top
217    }
218}
219
220/// Paints a map at this pixel size.
221#[must_use]
222pub fn paint(map: &Map, width: u32, height: u32) -> Canvas {
223    let mut canvas = Canvas::new(width, height);
224    for tile in &map.tiles {
225        draw(&mut canvas, tile);
226    }
227    canvas
228}
229
230/// One rectangle: its fill, its outline if the cursor is on it, and whatever of its label fits.
231fn draw(canvas: &mut Canvas, tile: &Tile) {
232    let Some(at) = Box::of(tile.area) else {
233        return;
234    };
235    // Alternating rather than "outer and inner", so every nesting boundary is a change of
236    // step whatever depth it is at. The border and the caption say where a rectangle begins;
237    // this is what makes it legible at a glance that it began at all.
238    let step = (tile.depth + 1) % 2;
239    match tile.kind {
240        Kind::Priced => canvas.fill(at, if tile.marked { MARKED } else { PRICED }[step]),
241        Kind::Unpriced => canvas.hatch(at, UNKNOWN, HATCH[usize::from(tile.marked)]),
242    }
243    if tile.cursor {
244        canvas.outline(at, HERE);
245    }
246    label(canvas, at, tile);
247}
248
249/// A rectangle's name and what it is worth, as much of them as there is room for.
250///
251/// Three states rather than two, and the middle one is the point: a rectangle too small for
252/// its figure still gets its name, because the name is what makes the *area* readable and the
253/// figure is already in the tree beside it.
254fn label(canvas: &mut Canvas, at: Box, tile: &Tile) {
255    if at.width() <= PAD * 2 || at.height() < GLYPH.1 + PAD {
256        return;
257    }
258    let room = ((at.width() - PAD * 2) / ADVANCE) as usize;
259    let top = at.top + PAD - 1;
260    // A tile with rectangles inside it owns only its caption strip, so its two facts share
261    // one line — and the name is cut to leave room for the figure rather than the figure
262    // being dropped, because a nested tile is a big one and its total is the reason it is.
263    if tile.nested {
264        let worth = tile.worth.chars().count();
265        let name = elide(&tile.name, room.saturating_sub(worth + BESIDE as usize));
266        canvas.write(at.left + PAD, top, &name, INK);
267        let after = (u32::try_from(name.chars().count()).unwrap_or(u32::MAX) + BESIDE) * ADVANCE;
268        if (name.chars().count() + worth + BESIDE as usize) <= room {
269            canvas.write(at.left + PAD + after, top, &tile.worth, MUTED);
270        }
271        return;
272    }
273    canvas.write(at.left + PAD, top, &elide(&tile.name, room), INK);
274    if at.height() >= GLYPH.1 + LEADING + PAD {
275        canvas.write(
276            at.left + PAD,
277            top + LEADING,
278            &elide(&tile.worth, room),
279            MUTED,
280        );
281    }
282}
283
284/// A string cut to `room` characters, saying that it was cut.
285///
286/// The tail is kept rather than the head when a name is a path: `…/node_modules` identifies a
287/// rectangle and `~/repos/some-pro…` does not. A name with no separator in it is cut the
288/// ordinary way round.
289fn elide(said: &str, room: usize) -> String {
290    let count = said.chars().count();
291    if room == 0 {
292        return String::new();
293    }
294    if count <= room {
295        return said.to_owned();
296    }
297    if said.contains('/') {
298        let tail: String = said.chars().skip(count - (room - 1)).collect();
299        return format!("…{tail}");
300    }
301    let head: String = said.chars().take(room - 1).collect();
302    format!("{head}…")
303}
304
305/// The bitmap for one character.
306fn glyph_of(character: char) -> [u8; 7] {
307    if character == '…' {
308        return ELLIPSIS;
309    }
310    if character == '·' {
311        return MIDDOT;
312    }
313    if character == '—' {
314        return DASH;
315    }
316    let code = character as u32;
317    if (0x20..0x7f).contains(&code) {
318        return FONT[(code - 0x20) as usize];
319    }
320    FONT[('?' as u32 - 0x20) as usize]
321}
322
323/// `…`, and `·`: the two glyphs outside the ASCII run, both of them ones pristine's own
324/// captions are written with rather than ones a directory name might contain.
325const ELLIPSIS: [u8; 7] = [0, 0, 0, 0, 0, 0, 0b10101];
326/// `·`.
327const MIDDOT: [u8; 7] = [0, 0, 0, 0b00100, 0, 0, 0];
328/// `—`, which the captions are written with.
329const DASH: [u8; 7] = [0, 0, 0, 0b11111, 0, 0, 0];
330
331/// A 5×7 bitmap font, `' '` through `'~'`. One byte a row, the low five bits, left to right.
332///
333/// Authored rather than pulled in, because a font crate is a dependency and 665 bytes is not.
334/// Checked by eye — see `the_font_is_legible`, which paints the whole of it.
335#[rustfmt::skip]
336const FONT: [[u8; 7]; 95] = [
337    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // ' '
338    [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], // '!'
339    [0x0a, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00], // '"'
340    [0x0a, 0x0a, 0x1f, 0x0a, 0x1f, 0x0a, 0x0a], // '#'
341    [0x04, 0x0f, 0x14, 0x0e, 0x05, 0x1e, 0x04], // '$'
342    [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03], // '%'
343    [0x08, 0x14, 0x14, 0x08, 0x15, 0x12, 0x0d], // '&'
344    [0x04, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00], // '\''
345    [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], // '('
346    [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], // ')'
347    [0x00, 0x04, 0x15, 0x0e, 0x15, 0x04, 0x00], // '*'
348    [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], // '+'
349    [0x00, 0x00, 0x00, 0x00, 0x0c, 0x04, 0x08], // ','
350    [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], // '-'
351    [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c], // '.'
352    [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x00], // '/'
353    [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], // '0'
354    [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], // '1'
355    [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], // '2'
356    [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], // '3'
357    [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], // '4'
358    [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], // '5'
359    [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], // '6'
360    [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], // '7'
361    [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], // '8'
362    [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], // '9'
363    [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00], // ':'
364    [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x04, 0x08], // ';'
365    [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02], // '<'
366    [0x00, 0x00, 0x1f, 0x00, 0x1f, 0x00, 0x00], // '='
367    [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08], // '>'
368    [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], // '?'
369    [0x0e, 0x11, 0x01, 0x0d, 0x15, 0x15, 0x0e], // '@'
370    [0x04, 0x0a, 0x11, 0x11, 0x1f, 0x11, 0x11], // 'A'
371    [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], // 'B'
372    [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e], // 'C'
373    [0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c], // 'D'
374    [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], // 'E'
375    [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], // 'F'
376    [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f], // 'G'
377    [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], // 'H'
378    [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], // 'I'
379    [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], // 'J'
380    [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], // 'K'
381    [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], // 'L'
382    [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], // 'M'
383    [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], // 'N'
384    [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], // 'O'
385    [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], // 'P'
386    [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], // 'Q'
387    [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], // 'R'
388    [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e], // 'S'
389    [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], // 'T'
390    [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], // 'U'
391    [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], // 'V'
392    [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], // 'W'
393    [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], // 'X'
394    [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], // 'Y'
395    [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], // 'Z'
396    [0x0e, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0e], // '['
397    [0x00, 0x10, 0x08, 0x04, 0x02, 0x01, 0x00], // '\\'
398    [0x0e, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0e], // ']'
399    [0x04, 0x0a, 0x11, 0x00, 0x00, 0x00, 0x00], // '^'
400    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f], // '_'
401    [0x08, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00], // '`'
402    [0x00, 0x00, 0x0e, 0x01, 0x0f, 0x11, 0x0f], // 'a'
403    [0x10, 0x10, 0x1e, 0x11, 0x11, 0x11, 0x1e], // 'b'
404    [0x00, 0x00, 0x0e, 0x11, 0x10, 0x11, 0x0e], // 'c'
405    [0x01, 0x01, 0x0f, 0x11, 0x11, 0x11, 0x0f], // 'd'
406    [0x00, 0x00, 0x0e, 0x11, 0x1f, 0x10, 0x0e], // 'e'
407    [0x06, 0x09, 0x08, 0x1c, 0x08, 0x08, 0x08], // 'f'
408    [0x00, 0x00, 0x0f, 0x11, 0x0f, 0x01, 0x0e], // 'g'
409    [0x10, 0x10, 0x1e, 0x11, 0x11, 0x11, 0x11], // 'h'
410    [0x04, 0x00, 0x0c, 0x04, 0x04, 0x04, 0x0e], // 'i'
411    [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0c], // 'j'
412    [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12], // 'k'
413    [0x0c, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], // 'l'
414    [0x00, 0x00, 0x1a, 0x15, 0x15, 0x15, 0x15], // 'm'
415    [0x00, 0x00, 0x1e, 0x11, 0x11, 0x11, 0x11], // 'n'
416    [0x00, 0x00, 0x0e, 0x11, 0x11, 0x11, 0x0e], // 'o'
417    [0x00, 0x00, 0x1e, 0x11, 0x1e, 0x10, 0x10], // 'p'
418    [0x00, 0x00, 0x0f, 0x11, 0x0f, 0x01, 0x01], // 'q'
419    [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10], // 'r'
420    [0x00, 0x00, 0x0f, 0x10, 0x0e, 0x01, 0x1e], // 's'
421    [0x08, 0x08, 0x1c, 0x08, 0x08, 0x09, 0x06], // 't'
422    [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0d], // 'u'
423    [0x00, 0x00, 0x11, 0x11, 0x11, 0x0a, 0x04], // 'v'
424    [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0a], // 'w'
425    [0x00, 0x00, 0x11, 0x0a, 0x04, 0x0a, 0x11], // 'x'
426    [0x00, 0x00, 0x11, 0x11, 0x0f, 0x01, 0x0e], // 'y'
427    [0x00, 0x00, 0x1f, 0x02, 0x04, 0x08, 0x1f], // 'z'
428    [0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02], // '{'
429    [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], // '|'
430    [0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08], // '}'
431    [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00], // '~'
432];
433
434#[cfg(test)]
435mod tests {
436    use super::{Canvas, HATCH, HERE, MARKED, PRICED, SURFACE, UNKNOWN, elide, paint};
437    use crate::fixture::{hit, priced};
438    use crate::size::Size;
439    use crate::tree::Tree;
440    use crate::tui::keymap::{Action, Motion};
441    use crate::tui::state::View;
442    use crate::tui::treemap::tiles::{Area, plan};
443
444    /// What colour the canvas has at a point.
445    fn at(canvas: &Canvas, x: u32, y: u32) -> [u8; 3] {
446        let index = ((y as usize) * (canvas.width as usize) + (x as usize)) * 3;
447        [
448            canvas.rgb[index],
449            canvas.rgb[index + 1],
450            canvas.rgb[index + 2],
451        ]
452    }
453
454    /// Whether a colour is anywhere in the canvas.
455    fn anywhere(canvas: &Canvas, colour: [u8; 3]) -> bool {
456        canvas.rgb.chunks_exact(3).any(|pixel| pixel == colour)
457    }
458
459    fn view() -> View {
460        let mut tree = Tree::new("/scan");
461        tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
462        tree.insert(priced("/scan/pua/target", 2 * 1024 * 1024));
463        View::new(tree)
464    }
465
466    #[test]
467    fn a_canvas_starts_as_surface_and_is_the_size_it_was_asked_for() {
468        let canvas = Canvas::new(7, 3);
469        assert_eq!(canvas.rgb.len(), 7 * 3 * 3);
470        assert_eq!(at(&canvas, 6, 2), SURFACE);
471    }
472
473    #[test]
474    fn a_priced_map_is_filled_and_an_unpriced_one_is_hatched() {
475        let view = view();
476        let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
477        let canvas = paint(&map, 320, 200);
478        assert!(anywhere(&canvas, PRICED[0]), "nothing was filled");
479        assert!(!anywhere(&canvas, UNKNOWN), "something was hatched");
480
481        let mut tree = Tree::new("/scan");
482        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 0));
483        let unpriced = View::new(tree);
484        let map = plan(&unpriced, unpriced.tree().root(), Area::of(320.0, 200.0)).unwrap();
485        let canvas = paint(&map, 320, 200);
486        // Texture rather than a third colour: a reader reads a hatch as "no data" and a
487        // colour as "another category", and this is the first of those.
488        assert!(anywhere(&canvas, UNKNOWN), "the unknown was not drawn");
489        assert!(anywhere(&canvas, HATCH[0]), "the unknown was not hatched");
490        assert!(
491            !anywhere(&canvas, PRICED[0]),
492            "an unpriced claim was filled"
493        );
494    }
495
496    #[test]
497    fn a_marked_subtree_changes_hue_and_the_cursor_gets_an_outline() {
498        let mut view = view();
499        view.apply(Action::Cursor(Motion::Down));
500        view.apply(Action::Mark);
501        let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
502        let canvas = paint(&map, 320, 200);
503
504        assert!(anywhere(&canvas, MARKED[0]), "a mark did not show");
505        assert!(anywhere(&canvas, PRICED[0]), "everything showed as marked");
506        assert!(anywhere(&canvas, HERE), "the cursor is nowhere on the map");
507    }
508
509    #[test]
510    fn two_rectangles_never_touch() {
511        // The separation the palette is not asked to provide. Every column of the map has
512        // surface in it somewhere between the two top-level rectangles.
513        let view = view();
514        let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
515        let canvas = paint(&map, 320, 200);
516        let seam =
517            (0..canvas.width).find(|&x| (0..canvas.height).all(|y| at(&canvas, x, y) == SURFACE));
518        assert!(
519            seam.is_some(),
520            "the two rectangles are flush against each other"
521        );
522    }
523
524    #[test]
525    fn a_rectangle_too_small_for_its_figure_still_gets_its_name() {
526        // The middle state, and the reason there are three: the name is what makes an area
527        // readable, and the figure is already on the row in the tree beside it.
528        let mut canvas = Canvas::new(200, 14);
529        canvas.write(4, 3, "node_modules", HERE);
530        assert!(anywhere(&canvas, HERE), "the name was not drawn");
531    }
532
533    #[test]
534    fn a_name_that_does_not_fit_keeps_the_end_that_identifies_it() {
535        assert_eq!(elide("node_modules", 20), "node_modules");
536        // A path is cut at the front: `…/node_modules` names a rectangle and
537        // `~/repos/some-pro…` does not.
538        assert_eq!(elide("repos/nx/node_modules", 9), "…_modules");
539        assert_eq!(elide("node_modules", 6), "node_…");
540        assert_eq!(elide("anything", 0), "");
541    }
542
543    /// Paints the whole font and every state the map has, to be looked at.
544    ///
545    /// `cargo test --lib treemap::paint::tests::the_spike_looks_like -- --ignored`, then open
546    /// `target/treemap-spike.ppm`. Ignored because its only assertion is a human's.
547    #[test]
548    #[ignore = "writes a file for a human to look at"]
549    fn the_spike_looks_like_this() {
550        let mut tree = Tree::new("/repos");
551        for (path, bytes) in [
552            ("/repos/nx/node_modules", 41_u64 * 1024 * 1024 * 1024),
553            ("/repos/nx/.nx/cache", 22 * 1024 * 1024 * 1024),
554            (
555                "/repos/nx/packages/graph/node_modules",
556                8 * 1024 * 1024 * 1024,
557            ),
558            ("/repos/nx/packages/nx/node_modules", 6 * 1024 * 1024 * 1024),
559            ("/repos/pua/target", 11 * 1024 * 1024 * 1024),
560            ("/repos/pristine/target", 4 * 1024 * 1024 * 1024),
561            ("/repos/brain/node_modules", 3 * 1024 * 1024 * 1024),
562            ("/repos/dotfiles/.venv", 900 * 1024 * 1024),
563            ("/repos/scratch/build", 400 * 1024 * 1024),
564        ] {
565            tree.insert(priced(path, bytes));
566        }
567        for path in ["/repos/archived/node_modules", "/repos/vendor/target"] {
568            tree.insert(hit(path, Size::Unmeasured, 0));
569        }
570        let mut view = View::new(tree);
571        view.apply(Action::Cursor(Motion::Down));
572        view.apply(Action::Mark);
573        view.apply(Action::Cursor(Motion::Down));
574
575        // The pane a real terminal gives: 44 columns of a 120-column window, 34 rows, at the
576        // 9×19 px cell a retina Ghostty reports. Rendered at the shape it ships in, because a
577        // treemap that reads well square and badly in a tall pane is a treemap that reads
578        // badly.
579        let (width, height) = (44 * 9_u32, 34 * 19_u32);
580        let map = plan(
581            &view,
582            view.tree().root(),
583            Area::of(f64::from(width), f64::from(height - 90)),
584        )
585        .unwrap();
586        let mut canvas = paint(&map, width, height);
587        // The font, underneath, so a garbled glyph is caught by looking rather than by a
588        // reader hitting it in a directory name.
589        let rows = [
590            " !\"#$%&'()*+,-./0123456789:;<=>?",
591            "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_",
592            "`abcdefghijklmnopqrstuvwxyz{|}~…",
593        ];
594        for (nth, row) in (0..).zip(rows) {
595            canvas.write(6, height - 80 + nth * 12, row, HERE);
596        }
597        canvas.write(6, height - 36, &map.caption, HERE);
598        std::fs::write("../../target/treemap-spike.ppm", canvas.ppm()).unwrap();
599    }
600}