1use super::tiles::{Area, Kind, Map, Tile};
23
24type Rgb = [u8; 3];
26
27const SURFACE: Rgb = [0x1a, 0x1a, 0x19];
29const PRICED: [Rgb; 2] = [[0x39, 0x87, 0xe5], [0x6d, 0xa7, 0xec]];
31const MARKED: [Rgb; 2] = [[0x19, 0x9e, 0x70], [0x5e, 0xbb, 0x98]];
34const UNKNOWN: Rgb = [0x38, 0x38, 0x35];
36const HATCH: [Rgb; 2] = [[0x6b, 0x6a, 0x64], [0x19, 0x9e, 0x70]];
38const HERE: Rgb = [0xff, 0xff, 0xff];
40const INK: Rgb = [0xff, 0xff, 0xff];
42const MUTED: Rgb = [0xc3, 0xc2, 0xb7];
44const SHADOW: Rgb = [0x0b, 0x0b, 0x0a];
51
52const GLYPH: (u32, u32) = (5, 7);
54const ADVANCE: u32 = 6;
56const PAD: u32 = 4;
58const LEADING: u32 = 9;
60const RING: u32 = 2;
62const BESIDE: u32 = 2;
64
65#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Canvas {
69 pub width: u32,
71 pub height: u32,
73 pub rgb: Vec<u8>,
75}
76
77impl Canvas {
78 #[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 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 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 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 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 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 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 #[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#[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 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#[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
230fn draw(canvas: &mut Canvas, tile: &Tile) {
232 let Some(at) = Box::of(tile.area) else {
233 return;
234 };
235 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
249fn 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 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
284fn 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
305fn 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
323const ELLIPSIS: [u8; 7] = [0, 0, 0, 0, 0, 0, 0b10101];
326const MIDDOT: [u8; 7] = [0, 0, 0, 0b00100, 0, 0, 0];
328const DASH: [u8; 7] = [0, 0, 0, 0b11111, 0, 0, 0];
330
331#[rustfmt::skip]
336const FONT: [[u8; 7]; 95] = [
337 [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], [0x0a, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00], [0x0a, 0x0a, 0x1f, 0x0a, 0x1f, 0x0a, 0x0a], [0x04, 0x0f, 0x14, 0x0e, 0x05, 0x1e, 0x04], [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03], [0x08, 0x14, 0x14, 0x08, 0x15, 0x12, 0x0d], [0x04, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00], [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], [0x00, 0x04, 0x15, 0x0e, 0x15, 0x04, 0x00], [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], [0x00, 0x00, 0x00, 0x00, 0x0c, 0x04, 0x08], [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c], [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x00], [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00], [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x04, 0x08], [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02], [0x00, 0x00, 0x1f, 0x00, 0x1f, 0x00, 0x00], [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08], [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], [0x0e, 0x11, 0x01, 0x0d, 0x15, 0x15, 0x0e], [0x04, 0x0a, 0x11, 0x11, 0x1f, 0x11, 0x11], [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e], [0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c], [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f], [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e], [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], [0x0e, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0e], [0x00, 0x10, 0x08, 0x04, 0x02, 0x01, 0x00], [0x0e, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0e], [0x04, 0x0a, 0x11, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f], [0x08, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00], [0x00, 0x00, 0x0e, 0x01, 0x0f, 0x11, 0x0f], [0x10, 0x10, 0x1e, 0x11, 0x11, 0x11, 0x1e], [0x00, 0x00, 0x0e, 0x11, 0x10, 0x11, 0x0e], [0x01, 0x01, 0x0f, 0x11, 0x11, 0x11, 0x0f], [0x00, 0x00, 0x0e, 0x11, 0x1f, 0x10, 0x0e], [0x06, 0x09, 0x08, 0x1c, 0x08, 0x08, 0x08], [0x00, 0x00, 0x0f, 0x11, 0x0f, 0x01, 0x0e], [0x10, 0x10, 0x1e, 0x11, 0x11, 0x11, 0x11], [0x04, 0x00, 0x0c, 0x04, 0x04, 0x04, 0x0e], [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0c], [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12], [0x0c, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], [0x00, 0x00, 0x1a, 0x15, 0x15, 0x15, 0x15], [0x00, 0x00, 0x1e, 0x11, 0x11, 0x11, 0x11], [0x00, 0x00, 0x0e, 0x11, 0x11, 0x11, 0x0e], [0x00, 0x00, 0x1e, 0x11, 0x1e, 0x10, 0x10], [0x00, 0x00, 0x0f, 0x11, 0x0f, 0x01, 0x01], [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10], [0x00, 0x00, 0x0f, 0x10, 0x0e, 0x01, 0x1e], [0x08, 0x08, 0x1c, 0x08, 0x08, 0x09, 0x06], [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0d], [0x00, 0x00, 0x11, 0x11, 0x11, 0x0a, 0x04], [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0a], [0x00, 0x00, 0x11, 0x0a, 0x04, 0x0a, 0x11], [0x00, 0x00, 0x11, 0x11, 0x0f, 0x01, 0x0e], [0x00, 0x00, 0x1f, 0x02, 0x04, 0x08, 0x1f], [0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02], [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], [0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08], [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00], ];
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 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 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 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 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 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 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 #[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 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 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}