use super::tiles::{Area, Kind, Map, Tile};
type Rgb = [u8; 3];
const SURFACE: Rgb = [0x1a, 0x1a, 0x19];
const PRICED: [Rgb; 2] = [[0x39, 0x87, 0xe5], [0x6d, 0xa7, 0xec]];
const MARKED: [Rgb; 2] = [[0x19, 0x9e, 0x70], [0x5e, 0xbb, 0x98]];
const UNKNOWN: Rgb = [0x38, 0x38, 0x35];
const HATCH: [Rgb; 2] = [[0x6b, 0x6a, 0x64], [0x19, 0x9e, 0x70]];
const HERE: Rgb = [0xff, 0xff, 0xff];
const INK: Rgb = [0xff, 0xff, 0xff];
const MUTED: Rgb = [0xc3, 0xc2, 0xb7];
const SHADOW: Rgb = [0x0b, 0x0b, 0x0a];
const GLYPH: (u32, u32) = (5, 7);
const ADVANCE: u32 = 6;
const PAD: u32 = 4;
const LEADING: u32 = 9;
const RING: u32 = 2;
const BESIDE: u32 = 2;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Canvas {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>,
}
impl Canvas {
#[must_use]
pub fn new(width: u32, height: u32) -> Self {
let count = (width as usize) * (height as usize);
let mut rgb = Vec::with_capacity(count * 3);
for _ in 0..count {
rgb.extend_from_slice(&SURFACE);
}
Self { width, height, rgb }
}
fn dot(&mut self, x: u32, y: u32, colour: Rgb) {
if x >= self.width || y >= self.height {
return;
}
let at = ((y as usize) * (self.width as usize) + (x as usize)) * 3;
self.rgb[at..at + 3].copy_from_slice(&colour);
}
fn fill(&mut self, at: Box, colour: Rgb) {
for y in at.top..at.bottom {
for x in at.left..at.right {
self.dot(x, y, colour);
}
}
}
fn hatch(&mut self, at: Box, base: Rgb, ink: Rgb) {
self.fill(at, base);
for y in at.top..at.bottom {
for x in at.left..at.right {
if (x + y) % 6 < 2 {
self.dot(x, y, ink);
}
}
}
}
fn outline(&mut self, at: Box, colour: Rgb) {
for ring in 0..RING {
for x in at.left..at.right {
self.dot(x, at.top + ring, colour);
self.dot(x, at.bottom.saturating_sub(1 + ring), colour);
}
for y in at.top..at.bottom {
self.dot(at.left + ring, y, colour);
self.dot(at.right.saturating_sub(1 + ring), y, colour);
}
}
}
fn write(&mut self, x: u32, y: u32, said: &str, colour: Rgb) {
let mut at = x;
for character in said.chars() {
let glyph = glyph_of(character);
for (offset, ink) in [((1, 1), SHADOW), ((0, 0), colour)] {
for (row, bits) in (0..).zip(glyph) {
for column in 0..GLYPH.0 {
if bits & (1 << (GLYPH.0 - 1 - column)) != 0 {
self.dot(at + column + offset.0, y + row + offset.1, ink);
}
}
}
}
at += ADVANCE;
}
}
#[cfg(test)]
fn ppm(&self) -> Vec<u8> {
let mut out = format!("P6\n{} {}\n255\n", self.width, self.height).into_bytes();
out.extend_from_slice(&self.rgb);
out
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Box {
left: u32,
top: u32,
right: u32,
bottom: u32,
}
impl Box {
fn of(area: Area) -> Option<Self> {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "areas are pane-sized and non-negative by construction; the max(0.0) \
below is what makes the sign loss unreachable rather than merely \
unlikely"
)]
let (left, top, right, bottom) = (
area.x.max(0.0).round() as u32 + 1,
area.y.max(0.0).round() as u32 + 1,
(area.x + area.w).max(0.0).round() as u32,
(area.y + area.h).max(0.0).round() as u32,
);
(right > left && bottom > top).then_some(Self {
left,
top,
right,
bottom,
})
}
fn width(self) -> u32 {
self.right - self.left
}
fn height(self) -> u32 {
self.bottom - self.top
}
}
#[must_use]
pub fn paint(map: &Map, width: u32, height: u32) -> Canvas {
let mut canvas = Canvas::new(width, height);
for tile in &map.tiles {
draw(&mut canvas, tile);
}
canvas
}
fn draw(canvas: &mut Canvas, tile: &Tile) {
let Some(at) = Box::of(tile.area) else {
return;
};
let step = (tile.depth + 1) % 2;
match tile.kind {
Kind::Priced => canvas.fill(at, if tile.marked { MARKED } else { PRICED }[step]),
Kind::Unpriced => canvas.hatch(at, UNKNOWN, HATCH[usize::from(tile.marked)]),
}
if tile.cursor {
canvas.outline(at, HERE);
}
label(canvas, at, tile);
}
fn label(canvas: &mut Canvas, at: Box, tile: &Tile) {
if at.width() <= PAD * 2 || at.height() < GLYPH.1 + PAD {
return;
}
let room = ((at.width() - PAD * 2) / ADVANCE) as usize;
let top = at.top + PAD - 1;
if tile.nested {
let worth = tile.worth.chars().count();
let name = elide(&tile.name, room.saturating_sub(worth + BESIDE as usize));
canvas.write(at.left + PAD, top, &name, INK);
let after = (u32::try_from(name.chars().count()).unwrap_or(u32::MAX) + BESIDE) * ADVANCE;
if (name.chars().count() + worth + BESIDE as usize) <= room {
canvas.write(at.left + PAD + after, top, &tile.worth, MUTED);
}
return;
}
canvas.write(at.left + PAD, top, &elide(&tile.name, room), INK);
if at.height() >= GLYPH.1 + LEADING + PAD {
canvas.write(
at.left + PAD,
top + LEADING,
&elide(&tile.worth, room),
MUTED,
);
}
}
fn elide(said: &str, room: usize) -> String {
let count = said.chars().count();
if room == 0 {
return String::new();
}
if count <= room {
return said.to_owned();
}
if said.contains('/') {
let tail: String = said.chars().skip(count - (room - 1)).collect();
return format!("…{tail}");
}
let head: String = said.chars().take(room - 1).collect();
format!("{head}…")
}
fn glyph_of(character: char) -> [u8; 7] {
if character == '…' {
return ELLIPSIS;
}
if character == '·' {
return MIDDOT;
}
if character == '—' {
return DASH;
}
let code = character as u32;
if (0x20..0x7f).contains(&code) {
return FONT[(code - 0x20) as usize];
}
FONT[('?' as u32 - 0x20) as usize]
}
const ELLIPSIS: [u8; 7] = [0, 0, 0, 0, 0, 0, 0b10101];
const MIDDOT: [u8; 7] = [0, 0, 0, 0b00100, 0, 0, 0];
const DASH: [u8; 7] = [0, 0, 0, 0b11111, 0, 0, 0];
#[rustfmt::skip]
const FONT: [[u8; 7]; 95] = [
[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], ];
#[cfg(test)]
mod tests {
use super::{Canvas, HATCH, HERE, MARKED, PRICED, SURFACE, UNKNOWN, elide, paint};
use crate::fixture::{hit, priced};
use crate::size::Size;
use crate::tree::Tree;
use crate::tui::keymap::{Action, Motion};
use crate::tui::state::View;
use crate::tui::treemap::tiles::{Area, plan};
fn at(canvas: &Canvas, x: u32, y: u32) -> [u8; 3] {
let index = ((y as usize) * (canvas.width as usize) + (x as usize)) * 3;
[
canvas.rgb[index],
canvas.rgb[index + 1],
canvas.rgb[index + 2],
]
}
fn anywhere(canvas: &Canvas, colour: [u8; 3]) -> bool {
canvas.rgb.chunks_exact(3).any(|pixel| pixel == colour)
}
fn view() -> View {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
tree.insert(priced("/scan/pua/target", 2 * 1024 * 1024));
View::new(tree)
}
#[test]
fn a_canvas_starts_as_surface_and_is_the_size_it_was_asked_for() {
let canvas = Canvas::new(7, 3);
assert_eq!(canvas.rgb.len(), 7 * 3 * 3);
assert_eq!(at(&canvas, 6, 2), SURFACE);
}
#[test]
fn a_priced_map_is_filled_and_an_unpriced_one_is_hatched() {
let view = view();
let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
let canvas = paint(&map, 320, 200);
assert!(anywhere(&canvas, PRICED[0]), "nothing was filled");
assert!(!anywhere(&canvas, UNKNOWN), "something was hatched");
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 0));
let unpriced = View::new(tree);
let map = plan(&unpriced, unpriced.tree().root(), Area::of(320.0, 200.0)).unwrap();
let canvas = paint(&map, 320, 200);
assert!(anywhere(&canvas, UNKNOWN), "the unknown was not drawn");
assert!(anywhere(&canvas, HATCH[0]), "the unknown was not hatched");
assert!(
!anywhere(&canvas, PRICED[0]),
"an unpriced claim was filled"
);
}
#[test]
fn a_marked_subtree_changes_hue_and_the_cursor_gets_an_outline() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
let canvas = paint(&map, 320, 200);
assert!(anywhere(&canvas, MARKED[0]), "a mark did not show");
assert!(anywhere(&canvas, PRICED[0]), "everything showed as marked");
assert!(anywhere(&canvas, HERE), "the cursor is nowhere on the map");
}
#[test]
fn two_rectangles_never_touch() {
let view = view();
let map = plan(&view, view.tree().root(), Area::of(320.0, 200.0)).unwrap();
let canvas = paint(&map, 320, 200);
let seam =
(0..canvas.width).find(|&x| (0..canvas.height).all(|y| at(&canvas, x, y) == SURFACE));
assert!(
seam.is_some(),
"the two rectangles are flush against each other"
);
}
#[test]
fn a_rectangle_too_small_for_its_figure_still_gets_its_name() {
let mut canvas = Canvas::new(200, 14);
canvas.write(4, 3, "node_modules", HERE);
assert!(anywhere(&canvas, HERE), "the name was not drawn");
}
#[test]
fn a_name_that_does_not_fit_keeps_the_end_that_identifies_it() {
assert_eq!(elide("node_modules", 20), "node_modules");
assert_eq!(elide("repos/nx/node_modules", 9), "…_modules");
assert_eq!(elide("node_modules", 6), "node_…");
assert_eq!(elide("anything", 0), "");
}
#[test]
#[ignore = "writes a file for a human to look at"]
fn the_spike_looks_like_this() {
let mut tree = Tree::new("/repos");
for (path, bytes) in [
("/repos/nx/node_modules", 41_u64 * 1024 * 1024 * 1024),
("/repos/nx/.nx/cache", 22 * 1024 * 1024 * 1024),
(
"/repos/nx/packages/graph/node_modules",
8 * 1024 * 1024 * 1024,
),
("/repos/nx/packages/nx/node_modules", 6 * 1024 * 1024 * 1024),
("/repos/pua/target", 11 * 1024 * 1024 * 1024),
("/repos/pristine/target", 4 * 1024 * 1024 * 1024),
("/repos/brain/node_modules", 3 * 1024 * 1024 * 1024),
("/repos/dotfiles/.venv", 900 * 1024 * 1024),
("/repos/scratch/build", 400 * 1024 * 1024),
] {
tree.insert(priced(path, bytes));
}
for path in ["/repos/archived/node_modules", "/repos/vendor/target"] {
tree.insert(hit(path, Size::Unmeasured, 0));
}
let mut view = View::new(tree);
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
view.apply(Action::Cursor(Motion::Down));
let (width, height) = (44 * 9_u32, 34 * 19_u32);
let map = plan(
&view,
view.tree().root(),
Area::of(f64::from(width), f64::from(height - 90)),
)
.unwrap();
let mut canvas = paint(&map, width, height);
let rows = [
" !\"#$%&'()*+,-./0123456789:;<=>?",
"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_",
"`abcdefghijklmnopqrstuvwxyz{|}~…",
];
for (nth, row) in (0..).zip(rows) {
canvas.write(6, height - 80 + nth * 12, row, HERE);
}
canvas.write(6, height - 36, &map.caption, HERE);
std::fs::write("../../target/treemap-spike.ppm", canvas.ppm()).unwrap();
}
}