From 9173ce0260e2669ccedc824610555be25cc4559e Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:21:17 +0800 Subject: [PATCH 01/13] The treemap lays out what fits instead of everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A treemap that draws every file it can subdivide is a mosaic: on a real drive most of the pixels are specks too small to identify, and finding out what any of them is costs a hover, one block at a time. That is the whole of the "visual overload" complaint, and no depth setting fixes it — the setting the issue asked for decides how much of the tree is drawn, not whether what is drawn can be read. Three changes, all in the layout: Culling is by side, not area. `min_area_px: 3.0` accepted a 40×0.1 sliver because 4 px² cleared the floor; `min_side_px` requires both dimensions, so what survives is something a person can actually see. Culled children keep eating their share of the parent, as before — that contract is what makes the parent's area honest, and the test that pinned it still does. A directory reserves a strip at the top of its interior for its label, and its children tile what is left. The strip is taken from the *children's* frame and never from the directory's own rect: that rect has to stay a function of its own frame alone, or a depth cap that stops recursion early would shift the rects that survive it, and the UI switches depth live. The test that pins that prefix property runs with a strip on. Depth became adaptive. A directory subdivides only while its interior can still hold a legible child — padding, strip and padding again come out of every level, so a branch runs out of room and stops on its own. Big folders go deep because they have the pixels; small ones stay a plate. max_depth is now only an upper bound on top of that, which leaves room for the explicit depth preference to land on top of it without fighting it. The rect carries the text too. Names come from `tree.name(id)` at the DTO boundary rather than through `TreemapRect`, which stays `Copy` and geometry-only for the layout's own tests; one string per visible rect is also far cheaper than the round trip per node it would otherwise take to label a block. --- crates/core/src/treemap.rs | 194 ++++++++++++++++++++++++++++++++++--- src-tauri/src/scan.rs | 23 ++++- 2 files changed, 203 insertions(+), 14 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..a51fbfc 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -2,7 +2,8 @@ //! //! Rects are emitted parents-before-children: forward iteration is painter's //! order for drawing, reverse is deepest-first for hit-testing. Children -//! below `min_area_px` are culled but still consume their share of space. +//! below `min_side_px` are culled but still consume their share of space, and +//! a directory only subdivides while its own interior can hold a legible one. use crate::category::categorize; use crate::entry::EntryFlags; @@ -16,8 +17,16 @@ pub struct Viewport { #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { - pub min_area_px: f32, + /// Legibility floor: a child is drawn only if it is at least this wide + /// *and* this tall. An area test alone lets a 40×0.1 sliver through, which + /// is how the map ends up as a mosaic of specks nothing can be read from. + /// Culled children still consume their share of the parent. + pub min_side_px: f32, pub padding_px: f32, + /// Vertical strip a directory reserves at the top of its interior for its + /// label; zero reserves nothing. Only the *children's* frame shrinks — + /// see `emit` for why a directory's own rect must not depend on this. + pub label_px: f32, pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, @@ -26,8 +35,9 @@ pub struct TreemapOptions { impl Default for TreemapOptions { fn default() -> Self { TreemapOptions { - min_area_px: 1.0, + min_side_px: 1.0, padding_px: 1.0, + label_px: 0.0, max_depth: 32, hide_system: false, } @@ -161,6 +171,15 @@ impl Frame { h: self.h - 2.0 * pad, } } + + /// Takes `pad` off the top edge alone: the strip a label sits in. + fn below(&self, pad: f64) -> Frame { + Frame { + y: self.y + pad, + h: self.h - pad, + ..*self + } + } } fn emit( @@ -186,11 +205,26 @@ fn emit( if !node.is_dir() || depth >= opts.max_depth { return; } + // Everything below changes only the *children's* frame. The rect pushed + // above must stay a function of this directory's own frame and nothing + // else, or a cap that stops recursion early would move the rects that + // survive it — the UI switches depth live and relies on them holding + // still. let inner = frame.inset(opts.padding_px as f64); if inner.w <= 0.0 || inner.h <= 0.0 { return; } - lay_children(tree, id, inner, depth + 1, opts, visible, out); + let body = inner.below(opts.label_px as f64); + // Adaptive depth: subdivide only where a child could still be legible. + // Going one level deeper costs padding, a label strip and the padding + // again, so a branch runs out of room on its own and stops — which is + // what keeps a big folder from dissolving into specks, and a small one + // from showing levels nobody can see. `max_depth` is only an upper bound + // on top of that. + if body.w < opts.min_side_px as f64 || body.h < opts.min_side_px as f64 { + return; + } + lay_children(tree, id, body, depth + 1, opts, visible, out); } fn lay_children( @@ -214,7 +248,7 @@ fn lay_children( let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); let scale = frame.area() / total; - let min_area = opts.min_area_px as f64; + let min_side = opts.min_side_px as f64; let mut remaining = frame; let mut i = 0; @@ -248,7 +282,7 @@ fn lay_children( sum, &mut remaining, depth, - min_area, + min_side, opts, visible, out, @@ -271,7 +305,7 @@ fn lay_row( row_area: f64, remaining: &mut Frame, depth: u8, - min_area: f64, + min_side: f64, opts: &TreemapOptions, visible: Option<&[u64]>, out: &mut Vec, @@ -303,7 +337,7 @@ fn lay_row( } }; offset += len; - if frame.area() >= min_area { + if frame.w >= min_side && frame.h >= min_side { emit(tree, id, frame, depth, opts, visible, out); } } @@ -350,10 +384,13 @@ mod tests { builder.finish() } + /// Nothing culled, nothing reserved: the pure squarified geometry the + /// exact-area assertions below are written against. fn no_padding() -> TreemapOptions { TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 0.0, + label_px: 0.0, max_depth: 32, hide_system: false, } @@ -485,8 +522,9 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 2.0, + label_px: 0.0, max_depth: 32, hide_system: false, }; @@ -502,11 +540,13 @@ mod tests { #[test] fn tiny_children_are_culled_without_inflating_the_rest() { - // 1,000,000 vs 1: in 100×100 the small file gets ~0.01 px² < 1 px². + // 1,000,000 vs 1: in 100×100 the small file gets a 100×0.0001 sliver, + // too thin to be legible at any width. let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); let opts = TreemapOptions { - min_area_px: 1.0, + min_side_px: 1.0, padding_px: 0.0, + label_px: 0.0, max_depth: 32, hide_system: false, }; @@ -518,6 +558,133 @@ mod tests { assert!((area(&big) - 10_000.0 * (1_000_000.0 / 1_000_001.0)).abs() < 1.0); } + /// The floor is a side, not an area: 10,000 vs 1 in 100×100 gives the small + /// file a 100×0.01 slice worth a whole square pixel, which the old area + /// test happily drew as an invisible line. + #[test] + fn a_sliver_is_culled_even_though_its_area_clears_the_floor() { + let tree = flat_tree(&[("big", 10_000), ("thin", 1)]); + let opts = TreemapOptions { + min_side_px: 2.0, + padding_px: 0.0, + label_px: 0.0, + max_depth: 32, + hide_system: false, + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + assert!(rects.iter().all(|r| r.id != 2), "sliver must be culled"); + assert!((area(&rect_of(&rects, 1)) - 10_000.0 * (10_000.0 / 10_001.0)).abs() < 1.0); + } + + /// Each level takes its strip out of its own children's frame, so a nested + /// directory starts one strip lower than its parent did. That compounding + /// is what makes depth cost pixels — and why it stops on its own. + #[test] + fn every_level_takes_its_label_strip_from_its_children() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("d2", entry(2, 1, DIR, 0)); + b.push("f", entry(3, 2, FILE, 100)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &labelled(10.0)); + + // The root's own rect is the whole viewport: the strip is taken below + // it, not out of it. + assert!((rect_of(&rects, 0).y).abs() < 0.01); + assert!((rect_of(&rects, 0).h - 100.0).abs() < 0.01); + for (id, top, height) in [(1u32, 10.0, 90.0), (2, 20.0, 80.0), (3, 30.0, 70.0)] { + let r = rect_of(&rects, id); + assert!((r.y - top).abs() < 0.01, "id {id} starts a strip lower"); + assert!( + (r.h - height).abs() < 0.01, + "id {id} lost a strip off its height" + ); + assert!((r.w - 100.0).abs() < 0.01, "a strip is vertical"); + } + } + + /// Depth changes stay live because a capped layout is a prefix of a deeper + /// one. The strip is the one addition that could break it — it has to + /// shrink frames the cap then hides anyway, never the rect a shallower + /// layout keeps. + #[test] + fn a_label_strip_does_not_move_the_rects_a_cap_leaves_behind() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("big", entry(2, 0, FILE, 400)); + b.push("d2", entry(3, 1, DIR, 0)); + b.push("f", entry(4, 1, FILE, 200)); + b.push("g", entry(5, 3, FILE, 100)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let capped = layout(&tree, 0, vp, &capped_at(2, 10.0)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8, 10.0)) + .into_iter() + .filter(|r| r.depth <= 2) + .collect(); + + assert_eq!(capped, deep); + assert!(capped.iter().any(|r| r.id == 3), "depth-2 dir is emitted"); + assert!(capped.iter().all(|r| r.id != 5), "depth-3 file is not"); + } + + /// Depth follows the pixels available: the same subtree expands when it has + /// room and stops when it does not, with `max_depth` nowhere near either. + #[test] + fn a_directory_stops_expanding_when_its_interior_is_too_small() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("wide", entry(1, 0, FILE, 800)); + b.push("mid", entry(2, 0, DIR, 0)); + b.push("deep", entry(3, 2, FILE, 200)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let opts = TreemapOptions { + min_side_px: 25.0, + ..labelled(10.0) + }; + + // A 40px-tall viewport leaves `mid` 30px, and its own strip takes 10: + // the 20px left cannot hold a child, so it stays a plain plate. + let cramped = layout(&tree, 0, Viewport { w: 200.0, h: 40.0 }, &opts); + assert!(cramped.iter().any(|r| r.id == 2), "the dir itself is drawn"); + assert!( + cramped.iter().all(|r| r.id != 3), + "its interior cannot hold a legible child" + ); + + // The same tree given more height expands, and nobody raised a cap. + let roomy = layout(&tree, 0, Viewport { w: 200.0, h: 100.0 }, &opts); + assert!(roomy.iter().any(|r| r.id == 3), "now there is room"); + assert!(opts.max_depth > 1, "the cap never came into it"); + } + + fn labelled(label_px: f32) -> TreemapOptions { + TreemapOptions { + label_px, + ..no_padding() + } + } + + fn capped_at(max_depth: u8, label_px: f32) -> TreemapOptions { + TreemapOptions { + max_depth, + label_px, + ..no_padding() + } + } + #[test] fn zero_size_children_emit_nothing_and_nothing_is_nan() { let tree = flat_tree(&[("empty", 0), ("real", 10)]); @@ -542,8 +709,9 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 0.0, + label_px: 0.0, max_depth: 1, hide_system: false, }; diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 1f4f8a2..d6c24e6 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -168,6 +168,9 @@ pub struct TreemapRectDto { depth: u8, is_dir: bool, category: u8, + /// What the block is called; the canvas draws it inside the rect. + name: String, + size: u64, } #[derive(Clone, Serialize)] @@ -338,6 +341,17 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } +/// Legibility floor for a treemap rect, in CSS pixels: below this on either +/// side the block is not drawn, so the map never fills with specks. Mirrored +/// as `TREEMAP_MIN_SIDE_PX` in `ui/src/lib/prefs.ts`. +const TREEMAP_MIN_SIDE_PX: f32 = 6.0; + +/// The strip a directory reserves at the top of its interior for its label — +/// and therefore the height the UI has to draw that label in. Mirrored as +/// `TREEMAP_LABEL_PX` in `ui/src/lib/prefs.ts`: the two have to agree or the +/// text lands on the children instead of above them. +const TREEMAP_LABEL_PX: f32 = 15.0; + #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, @@ -358,8 +372,9 @@ pub fn get_treemap( return Ok(Vec::new()); } let opts = TreemapOptions { - min_area_px: 3.0, + min_side_px: TREEMAP_MIN_SIDE_PX, padding_px: 1.0, + label_px: TREEMAP_LABEL_PX, max_depth: 24, hide_system, }; @@ -371,6 +386,10 @@ pub fn get_treemap( Some(o) => treemap::layout_with_filter(tree, root_id, viewport, &opts, &o.bytes), None => treemap::layout(tree, root_id, viewport, &opts), }; + // The label's text rides along with the geometry: a rect the user can see + // is a rect they can read, and asking per node would be one round trip per + // rectangle. The culling above is what keeps the payload honest — what is + // sent is roughly what fits on screen, not the whole subtree. Ok(rects .into_iter() .map(|r| TreemapRectDto { @@ -382,6 +401,8 @@ pub fn get_treemap( depth: r.depth, is_dir: r.is_dir, category: r.category, + name: tree.name(r.id).to_string(), + size: tree.node(r.id).size, }) .collect()) } From ce8195115f1bb124f203348a79426042551fc490 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:21:23 +0800 Subject: [PATCH 02/13] Treemap blocks say what they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas drew coloured rectangles and nothing else, so the map answered "where did the space go" and only answered "what is this" through a tooltip, one block at a time. Now every block that can hold a line of text carries its name, and the sizes ride along: folded into a directory's label at the top of its plate, centred under a file's name in the block itself. This is what SpaceSniffer does, and it is why its map reads at a glance instead of needing to be interrogated. Nesting is drawn as a shade rather than a texture. The old grain said "there is something in here too small to draw", which is accurate and also impossible to tell apart from a block that is simply empty; a fill that steps away from the theme's plate colour once per level instead says how deep you are, on the plate itself, and reads the same light or dark — the step flips direction with the theme. The grain tile and the `--color-plate-grain` token it needed are gone. Text picks its own ink: file blocks keep fixed category colours whatever the theme, so choosing a theme token for the text under them would be choosing it for the wrong surface. Blocks too short to hold their label's strip are left unlabelled rather than drawn half outside themselves — with a six-pixel legibility floor those are the plates the layout already stopped subdividing, and a label hanging out of its own block reads as a label for whatever is beneath it. --- ui/src/components/Treemap.tsx | 134 ++++++++++++++++++++++++++-------- ui/src/index.css | 4 +- ui/src/lib/api.ts | 3 + ui/src/lib/palette.ts | 54 +++++++++++++- 4 files changed, 161 insertions(+), 34 deletions(-) diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..4cf4c50 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -25,34 +25,98 @@ import { } from "../lib/api"; import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; -import { PALETTE, canvasColors } from "../lib/palette"; +import { PALETTE, canvasColors, plateShade, textOn } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; -// Grain tile for directory plates. By the layout's contract, culled children -// still consume their share of space, so every bare plate pixel is real bytes -// too small to draw — the grain makes that read as "many small files" instead -// of dead space. Drawn tiles paint over it, so it shows only where content -// was culled. -const GRAIN_PITCH = 4; - -let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; +// Mirrors TREEMAP_LABEL_PX in src-tauri/src/scan.rs: the strip the layout +// leaves free at the top of every directory, and therefore where its label has +// to sit. If the two disagree the text lands on the children. +const LABEL_STRIP_PX = 15; +/** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ +const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; +const LABEL_SIZE_PX = 11; +/** Narrower than this and a name is not worth truncating into. */ +const LABEL_MIN_W_PX = 34; +const LABEL_PAD_PX = 4; + +/** + * Shortens `text` to `maxW`, with an ellipsis when it had to cut. Binary + * search rather than a walk in from the end: this runs for every block on + * every bake, and a bake runs on each scan tick. + */ +function fitText( + ctx: CanvasRenderingContext2D, + text: string, + maxW: number, +): string { + if (ctx.measureText(text).width <= maxW) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (ctx.measureText(`${text.slice(0, mid)}…`).width <= maxW) lo = mid; + else hi = mid - 1; + } + return lo > 0 ? `${text.slice(0, lo)}…` : ""; +} -function getGrainTile(plate: string, grain: string): HTMLCanvasElement { - const key = `${plate}|${grain}`; - if (grainTile?.key === key) return grainTile.canvas; - const c = document.createElement("canvas"); - c.width = GRAIN_PITCH; - c.height = GRAIN_PITCH; - const ctx = c.getContext("2d")!; - ctx.fillStyle = plate; - ctx.fillRect(0, 0, GRAIN_PITCH, GRAIN_PITCH); - ctx.fillStyle = grain; - ctx.fillRect(1, 1, 1, 1); - grainTile = { key, canvas: c }; - return c; +/** + * Names, drawn into the blocks themselves. A map of coloured rectangles tells + * you where the space went; it does not tell you what any of it is without + * hovering each one, and hovering every block is the work this saves. + * + * Directories are labelled in the strip the layout reserved for them, files in + * the middle of their block — a file has no strip, and its whole rect is free. + */ +function drawLabels( + ctx: CanvasRenderingContext2D, + rects: TreemapRect[], + dpr: number, + plate: string, +) { + ctx.font = `${LABEL_SIZE_PX * dpr}px ${LABEL_FONT}`; + ctx.textBaseline = "middle"; + + const minW = LABEL_MIN_W_PX * dpr; + const pad = LABEL_PAD_PX * dpr; + const strip = LABEL_STRIP_PX * dpr; + + ctx.textAlign = "left"; + for (const r of rects) { + if (!r.isDir) continue; + const s = snap(r, dpr, 0); + // A block shorter than the strip has no strip to draw in, and a label half + // outside its own plate reads as a label for whatever is below it. Those + // are plates the layout stopped short of subdividing anyway. + if (s.w < minW || s.h < strip) continue; + ctx.fillStyle = textOn(plateShade(plate, r.depth)); + const text = fitText( + ctx, + `${r.name} · ${formatBytes(r.size)}`, + s.w - 2 * pad, + ); + if (text) ctx.fillText(text, s.x + pad, s.y + strip / 2); + } + + ctx.textAlign = "center"; + for (const r of rects) { + if (r.isDir) continue; + const s = snap(r, dpr, 1); + if (s.w < minW) continue; + ctx.fillStyle = textOn(PALETTE[r.category] ?? PALETTE[10]); + const name = fitText(ctx, r.name, s.w - 2 * pad); + if (!name) continue; + const cx = s.x + s.w / 2; + if (s.h >= 26 * dpr) { + ctx.fillText(name, cx, s.y + s.h / 2 - 6 * dpr); + ctx.fillText(formatBytes(r.size), cx, s.y + s.h / 2 + 8 * dpr); + } else if (s.h >= 13 * dpr) { + ctx.fillText(name, cx, s.y + s.h / 2); + } + } } let highlightSprite: HTMLCanvasElement | null = null; @@ -244,17 +308,25 @@ export function Treemap({ ctx.fillStyle = theme.background; ctx.fillRect(0, 0, off.width, off.height); - ctx.fillStyle = ctx.createPattern( - getGrainTile(theme.plate, theme.plateGrain), - "repeat", - )!; - ctx.beginPath(); + // Directory plates, batched one path per nesting level: each level is a + // step further from the theme's plate colour, so a block tells you how far + // in it sits without needing a texture that has to be explained. + const byDepth: TreemapRect[][] = []; for (const r of rects) { if (!r.isDir) continue; - const s = snap(r, dpr, 0); - if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + (byDepth[r.depth] ??= []).push(r); + } + for (let d = 0; d < byDepth.length; d++) { + const level = byDepth[d]; + if (!level) continue; + ctx.fillStyle = plateShade(theme.plate, d); + ctx.beginPath(); + for (const r of level) { + const s = snap(r, dpr, 0); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); } - ctx.fill(); const buckets: TreemapRect[][] = PALETTE.map(() => []); for (const r of rects) { @@ -279,6 +351,8 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } + drawLabels(ctx, rects, dpr, theme.plate); + if (zoomRafRef.current === 0) blit(); }, [blit]); diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..23e7908 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -9,8 +9,7 @@ --color-raised: #27272a; /* selection, hover on panel surfaces */ --color-edge: #27272a; --color-edge-strong: #3f3f46; - --color-plate: #30323a; /* treemap directory plate */ - --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ + --color-plate: #30323a; /* treemap directory plate; deeper levels shade off it */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -45,7 +44,6 @@ --color-edge: #e4e4e7; --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; - --color-plate-grain: #bfc0c8; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3a7e806..12630e4 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -46,6 +46,9 @@ export interface TreemapRect { depth: number; isDir: boolean; category: number; + /** Drawn inside the block; see `bake` in Treemap.tsx. */ + name: string; + size: number; } export interface Crumb { diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..320cd48 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -21,8 +21,60 @@ export function canvasColors() { return { background: v("--color-app"), plate: v("--color-plate"), - plateGrain: v("--color-plate-grain"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; } + +/** Levels the plate ramp distinguishes before it stops changing. */ +const SHADE_LEVELS = 6; +/** Lightness step per nesting level, in 0..255. Enough to see, not to shout. */ +const SHADE_STEP = 8; + +/** `#rrggbb` or `rgb(...)`. */ +function rgb(color: string): [number, number, number] { + if (color.startsWith("#")) { + const n = Number.parseInt(color.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + const parts = color.match(/\d+/g); + return parts + ? [Number(parts[0]), Number(parts[1]), Number(parts[2])] + : [0, 0, 0]; +} + +function luminance(r: number, g: number, b: number): number { + const lin = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +/** + * The plate fill for nesting level `level`: one step further from the theme's + * plate colour for each level in. Depth has to be readable without a texture, + * because the alternative — a texture that means "something is in here" — has + * to be explained, while a shade does not. The step follows the theme, so a + * light plate darkens inward and a dark one lightens. + */ +export function plateShade(plate: string, level: number): string { + const [r, g, b] = rgb(plate); + const step = luminance(r, g, b) > 0.4 ? -SHADE_STEP : SHADE_STEP; + const lift = Math.min(level, SHADE_LEVELS) * step; + const at = (c: number) => Math.max(0, Math.min(255, Math.round(c + lift))); + return `rgb(${at(r)}, ${at(g)}, ${at(b)})`; +} + +/** + * Ink or paper for text on `fill`. File blocks keep the fixed category colours + * whatever the theme, so this picks literal near-black or near-white rather + * than a theme token — a token would be chosen for the plate, not for the + * colour actually underneath. + */ +export function textOn(fill: string): string { + const [r, g, b] = rgb(fill); + return luminance(r, g, b) > 0.45 + ? "rgba(12, 12, 14, 0.88)" + : "rgba(250, 250, 250, 0.92)"; +} From 0d46acf93aafe6bbbd9395984ac90ecdf114a8c5 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:29:51 +0800 Subject: [PATCH 03/13] Treemap labels become a setting, and the blocks stay blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the look from the previous two commits. The shade ramp, the names written into every block and the strip reserved for them borrowed more from SpaceSniffer than this app wants: mathom's treemap is a map of coloured blocks and that is the thing worth keeping. What survives is the part that was never about looks — the legibility floor and the adaptive subdivision, which decide how much gets drawn rather than how it is painted. Naming the blocks is still worth having, so it moves behind Settings → View as "Show names in treemap", off by default like the extra chrome it is. Off, the layout reserves no strip at all (`label_px` is zero), so the geometry is exactly what it was before labels existed. On, the label strip is the one part of a plate that carries no grain — a name read through the texture is a name you have to squint at — and the plates are otherwise the flat colour and dots they have always been. --- src-tauri/src/scan.rs | 16 ++++-- ui/src/App.tsx | 3 ++ ui/src/components/SettingsMenu.tsx | 16 ++++++ ui/src/components/Toolbar.tsx | 6 +++ ui/src/components/Treemap.tsx | 80 ++++++++++++++++++++---------- ui/src/hooks/useScan.ts | 8 +++ ui/src/index.css | 4 +- ui/src/lib/api.ts | 4 +- ui/src/lib/palette.ts | 29 ++--------- 9 files changed, 110 insertions(+), 56 deletions(-) diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index d6c24e6..b1771b8 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -346,12 +346,15 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu /// as `TREEMAP_MIN_SIDE_PX` in `ui/src/lib/prefs.ts`. const TREEMAP_MIN_SIDE_PX: f32 = 6.0; -/// The strip a directory reserves at the top of its interior for its label — -/// and therefore the height the UI has to draw that label in. Mirrored as -/// `TREEMAP_LABEL_PX` in `ui/src/lib/prefs.ts`: the two have to agree or the -/// text lands on the children instead of above them. +/// The strip a directory reserves at the top of its interior for its label, +/// when labels are on — and therefore the height the UI has to draw that label +/// in. Mirrored as `LABEL_STRIP_PX` in `ui/src/components/Treemap.tsx`: the two +/// have to agree or the text lands on the children instead of above them. const TREEMAP_LABEL_PX: f32 = 15.0; +// The argument list mirrors the UI's query; grouping it into a struct would +// only move the same list one level down. +#[allow(clippy::too_many_arguments)] #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, @@ -361,6 +364,7 @@ pub fn get_treemap( height: f32, hide_system: bool, filter: Option, + labels: bool, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -374,7 +378,9 @@ pub fn get_treemap( let opts = TreemapOptions { min_side_px: TREEMAP_MIN_SIDE_PX, padding_px: 1.0, - label_px: TREEMAP_LABEL_PX, + // Labels off means the strip is not reserved at all, so the geometry + // is exactly what it was before labels existed. + label_px: if labels { TREEMAP_LABEL_PX } else { 0.0 }, max_depth: 24, hide_system, }; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..6e6b38f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -357,6 +357,7 @@ export default function App() { viewRootId={viewRootId} startError={scan.startError} hideSystem={scan.hideSystem} + showLabels={scan.showLabels} filter={scan.filter} typePanelOpen={typePanelOpen} themePref={theme.pref} @@ -364,6 +365,7 @@ export default function App() { onScan={handleScan} onCancel={scan.cancel} onToggleHideSystem={scan.toggleHideSystem} + onToggleShowLabels={scan.toggleShowLabels} onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} @@ -422,6 +424,7 @@ export default function App() { themeRev={theme.themeRev} hideSystem={scan.hideSystem} filter={scan.filter} + labels={scan.showLabels} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/SettingsMenu.tsx b/ui/src/components/SettingsMenu.tsx index 211aa0f..ffbdc68 100644 --- a/ui/src/components/SettingsMenu.tsx +++ b/ui/src/components/SettingsMenu.tsx @@ -15,18 +15,22 @@ const THEME_OPTIONS: { value: ThemePref; label: string }[] = [ interface SettingsMenuProps { hideSystem: boolean; + showLabels: boolean; themePref: ThemePref; accent: AccentName; onToggleHideSystem: () => void; + onToggleShowLabels: () => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } export function SettingsMenu({ hideSystem, + showLabels, themePref, accent, onToggleHideSystem, + onToggleShowLabels, onThemePref, onAccent, }: SettingsMenuProps) { @@ -80,6 +84,18 @@ export function SettingsMenu({ /> Hide system files +
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..d61edf3 100644 --- a/ui/src/components/Toolbar.tsx +++ b/ui/src/components/Toolbar.tsx @@ -13,6 +13,7 @@ interface ToolbarProps { viewRootId: number; startError: string | null; hideSystem: boolean; + showLabels: boolean; filter: string | null; typePanelOpen: boolean; themePref: ThemePref; @@ -20,6 +21,7 @@ interface ToolbarProps { onScan: (path: string) => void; onCancel: () => void; onToggleHideSystem: () => void; + onToggleShowLabels: () => void; onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; @@ -33,6 +35,7 @@ export function Toolbar({ viewRootId, startError, hideSystem, + showLabels, filter, typePanelOpen, themePref, @@ -40,6 +43,7 @@ export function Toolbar({ onScan, onCancel, onToggleHideSystem, + onToggleShowLabels, onToggleTypePanel, onSearchSelect, onApplyFilter, @@ -97,9 +101,11 @@ export function Toolbar({ /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 4cf4c50..e0dc874 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -25,15 +25,40 @@ import { } from "../lib/api"; import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; -import { PALETTE, canvasColors, plateShade, textOn } from "../lib/palette"; +import { PALETTE, canvasColors, textOn } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; +// Grain tile for directory plates. By the layout's contract, culled children +// still consume their share of space, so every bare plate pixel is real bytes +// too small to draw — the grain makes that read as "many small files" instead +// of dead space. Drawn tiles paint over it, so it shows only where content +// was culled. +const GRAIN_PITCH = 4; + +let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; + +function getGrainTile(plate: string, grain: string): HTMLCanvasElement { + const key = `${plate}|${grain}`; + if (grainTile?.key === key) return grainTile.canvas; + const c = document.createElement("canvas"); + c.width = GRAIN_PITCH; + c.height = GRAIN_PITCH; + const ctx = c.getContext("2d")!; + ctx.fillStyle = plate; + ctx.fillRect(0, 0, GRAIN_PITCH, GRAIN_PITCH); + ctx.fillStyle = grain; + ctx.fillRect(1, 1, 1, 1); + grainTile = { key, canvas: c }; + return c; +} + // Mirrors TREEMAP_LABEL_PX in src-tauri/src/scan.rs: the strip the layout -// leaves free at the top of every directory, and therefore where its label has -// to sit. If the two disagree the text lands on the children. +// leaves free at the top of every directory when labels are on, and therefore +// where the label has to sit. If the two disagree the text lands on the +// children. const LABEL_STRIP_PX = 15; /** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; @@ -64,9 +89,11 @@ function fitText( } /** - * Names, drawn into the blocks themselves. A map of coloured rectangles tells - * you where the space went; it does not tell you what any of it is without - * hovering each one, and hovering every block is the work this saves. + * Names, drawn into the blocks themselves, when the view asks for them. A map + * of coloured rectangles tells you where the space went; it does not tell you + * what any of it is without hovering each one, and hovering every block is the + * work this saves. It is off by default because the blocks are also the map, + * and writing in all of them changes what the map reads like. * * Directories are labelled in the strip the layout reserved for them, files in * the middle of their block — a file has no strip, and its whole rect is free. @@ -92,7 +119,12 @@ function drawLabels( // outside its own plate reads as a label for whatever is below it. Those // are plates the layout stopped short of subdividing anyway. if (s.w < minW || s.h < strip) continue; - ctx.fillStyle = textOn(plateShade(plate, r.depth)); + // The strip is the one part of the plate that carries no texture: the + // layout left it empty for exactly this, and a name read through the grain + // is a name you have to squint at. + ctx.fillStyle = plate; + ctx.fillRect(s.x, s.y, s.w, strip); + ctx.fillStyle = textOn(plate); const text = fitText( ctx, `${r.name} · ${formatBytes(r.size)}`, @@ -170,6 +202,8 @@ export interface TreemapProps { hideSystem: boolean; /** Active view filter (search grammar) or null. */ filter: string | null; + /** Draw each block's name and size inside it. Off by default. */ + labels: boolean; selected: number | null; hoveredId: number | null; onSelect: (rect: TreemapRect) => void; @@ -192,6 +226,7 @@ export function Treemap({ themeRev, hideSystem, filter, + labels, selected, hoveredId, onSelect, @@ -230,6 +265,8 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); filterRef.current = filter; + const labelsRef = useRef(labels); + labelsRef.current = labels; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -308,25 +345,17 @@ export function Treemap({ ctx.fillStyle = theme.background; ctx.fillRect(0, 0, off.width, off.height); - // Directory plates, batched one path per nesting level: each level is a - // step further from the theme's plate colour, so a block tells you how far - // in it sits without needing a texture that has to be explained. - const byDepth: TreemapRect[][] = []; + ctx.fillStyle = ctx.createPattern( + getGrainTile(theme.plate, theme.plateGrain), + "repeat", + )!; + ctx.beginPath(); for (const r of rects) { if (!r.isDir) continue; - (byDepth[r.depth] ??= []).push(r); - } - for (let d = 0; d < byDepth.length; d++) { - const level = byDepth[d]; - if (!level) continue; - ctx.fillStyle = plateShade(theme.plate, d); - ctx.beginPath(); - for (const r of level) { - const s = snap(r, dpr, 0); - if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); - } - ctx.fill(); + const s = snap(r, dpr, 0); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } + ctx.fill(); const buckets: TreemapRect[][] = PALETTE.map(() => []); for (const r of rects) { @@ -351,7 +380,7 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } - drawLabels(ctx, rects, dpr, theme.plate); + if (labelsRef.current) drawLabels(ctx, rects, dpr, theme.plate); if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -401,6 +430,7 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, + labelsRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; @@ -497,7 +527,7 @@ export function Treemap({ useEffect(() => { void fetchLayout(); - }, [hideSystem, filter, fetchLayout]); + }, [hideSystem, filter, labels, fetchLayout]); const prevStateRef = useRef(undefined); useEffect(() => { diff --git a/ui/src/hooks/useScan.ts b/ui/src/hooks/useScan.ts index cd4dde9..41cadf7 100644 --- a/ui/src/hooks/useScan.ts +++ b/ui/src/hooks/useScan.ts @@ -26,6 +26,8 @@ export interface ScanController { expanded: ReadonlySet; sort: Sort; hideSystem: boolean; + /** Draw each treemap block's name and size inside it. */ + showLabels: boolean; /** Active view filter (search grammar) or null; applies post-scan only. */ filter: string | null; startError: string | null; @@ -37,6 +39,7 @@ export interface ScanController { expandMany: (ids: number[]) => void; changeSort: (key: SortKey) => void; toggleHideSystem: () => void; + toggleShowLabels: () => void; setFilter: (query: string | null) => void; pathOf: (id: number) => Promise; } @@ -49,6 +52,7 @@ export function useScan(): ScanController { const [expanded, setExpanded] = useState>(new Set([0])); const [sort, setSort] = useState({ key: "size", desc: true }); const [hideSystem, setHideSystem] = useState(true); + const [showLabels, setShowLabels] = useState(false); const [filter, setFilterState] = useState(null); const [startError, setStartError] = useState(null); @@ -248,6 +252,8 @@ export function useScan(): ScanController { const toggleHideSystem = useCallback(() => setHideSystem((v) => !v), []); + const toggleShowLabels = useCallback(() => setShowLabels((v) => !v), []); + const setFilter = useCallback((query: string | null) => { const next = query && query.trim() !== "" ? query : null; // Eager ref update: a same-tick reveal must fetch with the new filter, not the stale one. @@ -274,6 +280,7 @@ export function useScan(): ScanController { expanded, sort, hideSystem, + showLabels, filter, startError, scanning: snapshot?.state === "scanning", @@ -283,6 +290,7 @@ export function useScan(): ScanController { expandMany, changeSort, toggleHideSystem, + toggleShowLabels, setFilter, pathOf, }; diff --git a/ui/src/index.css b/ui/src/index.css index 23e7908..0a77bfb 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -9,7 +9,8 @@ --color-raised: #27272a; /* selection, hover on panel surfaces */ --color-edge: #27272a; --color-edge-strong: #3f3f46; - --color-plate: #30323a; /* treemap directory plate; deeper levels shade off it */ + --color-plate: #30323a; /* treemap directory plate */ + --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -44,6 +45,7 @@ --color-edge: #e4e4e7; --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; + --color-plate-grain: #bfc0c8; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 12630e4..5779d8f 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -46,7 +46,7 @@ export interface TreemapRect { depth: number; isDir: boolean; category: number; - /** Drawn inside the block; see `bake` in Treemap.tsx. */ + /** Drawn inside the block when the view asks for labels. */ name: string; size: number; } @@ -162,6 +162,7 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, + labels: boolean, ) => invoke("get_treemap", { generation, @@ -170,6 +171,7 @@ export const api = { height, hideSystem, filter, + labels, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 320cd48..53eb86a 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -21,16 +21,12 @@ export function canvasColors() { return { background: v("--color-app"), plate: v("--color-plate"), + plateGrain: v("--color-plate-grain"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; } -/** Levels the plate ramp distinguishes before it stops changing. */ -const SHADE_LEVELS = 6; -/** Lightness step per nesting level, in 0..255. Enough to see, not to shout. */ -const SHADE_STEP = 8; - /** `#rrggbb` or `rgb(...)`. */ function rgb(color: string): [number, number, number] { if (color.startsWith("#")) { @@ -52,25 +48,10 @@ function luminance(r: number, g: number, b: number): number { } /** - * The plate fill for nesting level `level`: one step further from the theme's - * plate colour for each level in. Depth has to be readable without a texture, - * because the alternative — a texture that means "something is in here" — has - * to be explained, while a shade does not. The step follows the theme, so a - * light plate darkens inward and a dark one lightens. - */ -export function plateShade(plate: string, level: number): string { - const [r, g, b] = rgb(plate); - const step = luminance(r, g, b) > 0.4 ? -SHADE_STEP : SHADE_STEP; - const lift = Math.min(level, SHADE_LEVELS) * step; - const at = (c: number) => Math.max(0, Math.min(255, Math.round(c + lift))); - return `rgb(${at(r)}, ${at(g)}, ${at(b)})`; -} - -/** - * Ink or paper for text on `fill`. File blocks keep the fixed category colours - * whatever the theme, so this picks literal near-black or near-white rather - * than a theme token — a token would be chosen for the plate, not for the - * colour actually underneath. + * Ink or paper for text on `fill`, used by the optional block labels. File + * blocks keep the fixed category colours whatever the theme, so this picks + * literal near-black or near-white rather than a theme token — a token would + * be chosen for the plate, not for the colour actually underneath. */ export function textOn(fill: string): string { const [r, g, b] = rgb(fill); From 4fe0895d106d3098c59623a2797483504de8904a Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:07:38 +0800 Subject: [PATCH 04/13] The treemap floor raises small blocks instead of culling them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `min_side_px` was read as a cut-off line: a child under it was skipped, but its share of the frame stayed reserved, so a directory that was mostly small files came out as a patch of flat nothing. SpaceSniffer does the opposite, and it is the right way round — the small stuff is drawn at one uniform minimum size so the directory tiles edge to edge, and only when even that does not fit does the smallest go. Nobody opens this thing to look at 4KB files anyway. So the layout now floors every child at `min_side²`, sheds the tail until the floored weights fit the frame, and re-normalizes the survivors into exactly what is left. Dropping is what pays for the floor, and because the survivors renormalize, the space the dropped ones held flows into the blocks that remain rather than opening a hole. A row can still come out too thin to read — one file holding 99.985% of a block squeezes the rest into a 0.09px column, which hit-testing wins for the whole height of it, since it walks deepest-first. Laying out into a scratch row list before emitting anything lets the fit check the result and shed from the first row that gives out. `keep` only ever shrinks and a lone block fills the frame, so it terminates. The rendering side is untouched: still flat plates and grain, labels still behind the setting. Measured on /usr/share (337k nodes): 1826 rects in 4ms, where strictly-proportional layout gives 86754. --- crates/core/src/treemap.rs | 514 +++++++++++++++++++++++++++++-------- src-tauri/src/scan.rs | 12 +- 2 files changed, 414 insertions(+), 112 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index a51fbfc..7d74c7e 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -1,9 +1,15 @@ //! Squarified treemap layout (Bruls / Huizing / van Wijk, 2000). //! //! Rects are emitted parents-before-children: forward iteration is painter's -//! order for drawing, reverse is deepest-first for hit-testing. Children -//! below `min_side_px` are culled but still consume their share of space, and -//! a directory only subdivides while its own interior can hold a legible one. +//! order for drawing, reverse is deepest-first for hit-testing. +//! +//! `TreemapOptions::min_side_px` is a floor, not a cut-off line. A child too +//! small to earn its proportional share is raised to one minimum block and +//! drawn anyway, so a directory tiles edge to edge instead of opening holes +//! nobody can attribute to anything. What still does not fit at that size is +//! dropped — smallest first — and the survivors re-normalize into the space, +//! so dropping leaves no hole either. A directory subdivides only while its +//! own interior can hold a legible child. use crate::category::categorize; use crate::entry::EntryFlags; @@ -17,10 +23,13 @@ pub struct Viewport { #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { - /// Legibility floor: a child is drawn only if it is at least this wide - /// *and* this tall. An area test alone lets a 40×0.1 sliver through, which - /// is how the map ends up as a mosaic of specks nothing can be read from. - /// Culled children still consume their share of the parent. + /// Legibility floor, in pixels: a child is sized to be at least this wide + /// *and* this tall. A child whose proportional share falls short is raised + /// to one minimum block rather than culled, so the parent has no hole in + /// it; an area test alone would let a 40×0.1 sliver through and read as + /// blank. Children that no longer fit even at this size are dropped, + /// smallest first, and the rest spread into their space. Zero lays the + /// children out strictly proportionally. pub min_side_px: f32, pub padding_px: f32, /// Vertical strip a directory reserves at the top of its interior for its @@ -209,7 +218,9 @@ fn emit( // above must stay a function of this directory's own frame and nothing // else, or a cap that stops recursion early would move the rects that // survive it — the UI switches depth live and relies on them holding - // still. + // still. The floor, the dropping and the re-normalizing in `lay_children` + // are functions of this directory alone for the same reason: never of + // `max_depth`, and never of anything global. let inner = frame.inset(opts.padding_px as f64); if inner.w <= 0.0 || inner.h <= 0.0 { return; @@ -247,23 +258,123 @@ fn lay_children( items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); - let scale = frame.area() / total; + let area = frame.area(); + let scale = area / total; let min_side = opts.min_side_px as f64; + let rows = if min_side <= 0.0 { + // No floor to honour: strictly proportional, nothing to refit. + let weights: Vec = items.iter().map(|&(_, s)| s as f64 * scale).collect(); + squarify(&weights, frame) + } else { + // The floor is a floor, not a cut-off line. A child too small to earn + // its proportional share is raised to one minimum block and drawn + // anyway, so the directory fills edge to edge. What does not fit at + // that size is dropped — smallest first — and the survivors spread + // into the space that frees, so dropping leaves no hole either. That + // is the whole trade: draw the small stuff when there is room for it, + // and when there is not, the user is here for the big blocks anyway. + // + // `scale` stays the proportions-only scale throughout. Raising the + // weights and then scaling *those* up overshoots the frame, and the + // clamp that triggers is exactly the gap this exists to remove. + let weights: Vec = items + .iter() + .map(|&(_, s)| (s as f64 * scale).max(min_side * min_side)) + .collect(); + fit(&weights, frame, min_side) + }; + + for row in &rows { + for (k, f) in row.frames.iter().enumerate() { + emit(tree, items[row.start + k].0, *f, depth, opts, visible, out); + } + } +} + +/// One squarified row: consecutive items starting at `start`, laid out as a +/// slab. Every frame in a row shares the row's short side. +struct Row { + start: usize, + frames: Vec, +} + +impl Row { + fn end(&self) -> usize { + self.start + self.frames.len() + } +} + +/// Packs a floored weight list into `frame`: shed from the tail until the +/// survivors tile it in legible blocks. Every pass re-normalizes, so whatever +/// the dropped items were holding flows back into the ones that remain. +fn fit(weights: &[f64], frame: Frame, min_side: f64) -> Vec { + let area = frame.area(); + let mut keep = weights.len(); + loop { + let mut sum: f64 = weights[..keep].iter().sum(); + while keep > 1 && sum > area * (1.0 + 1e-9) { + keep -= 1; + sum -= weights[keep]; + } + let norm = area / sum; + let fitted: Vec = weights[..keep].iter().map(|w| w * norm).collect(); + let rows = squarify(&fitted, frame); + match first_unfit(&rows, keep, min_side) { + // The very first row is unfit, so no later one can be better: the + // run is cut too finely for this frame to tile legibly at all. + // Shed a slice of the tail and try a coarser cut — dropping + // everything but the biggest child would be the wrong answer for a + // directory full of equals. + Some(0) if keep > 1 => keep -= (keep / 16).max(1), + // A later row gives out: that row and the tail behind it are what + // the floor cannot afford. + Some(at) if keep > 1 => keep = at, + // `keep == 1` lays one block across the whole frame, which cannot + // be unfit — so this is the clean case, and the only way out. + _ => return rows, + } + } +} + +/// The first item index the run could not place legibly: a frame with a side +/// under the floor, or an item the frame ran out before reaching. `None` when +/// the whole run is clean. +fn first_unfit(rows: &[Row], keep: usize, min_side: f64) -> Option { + for row in rows { + // A row's frames all carry its short side, so this covers that too. + if row.frames.iter().any(|f| f.w.min(f.h) < min_side) { + return Some(row.start); + } + } + let covered = rows.last().map_or(0, Row::end); + (covered < keep).then_some(covered) +} + +fn worst_aspect(sum: f64, max: f64, min: f64, side: f64) -> f64 { + let s2 = sum * sum; + let w2 = side * side; + (w2 * max / s2).max(s2 / (w2 * min)) +} + +/// Greedy squarification: packs `weights` into rows of near-square items, +/// largest first. Pure geometry — `frame` is left alone and nothing is +/// emitted, so a caller can look the result over and lay the items out again. +fn squarify(weights: &[f64], frame: Frame) -> Vec { + let mut rows = Vec::new(); let mut remaining = frame; let mut i = 0; - while i < items.len() { + while i < weights.len() { if remaining.w <= 0.0 || remaining.h <= 0.0 { - return; + break; } let side = remaining.w.min(remaining.h); - let first = items[i].1 as f64 * scale; - let (mut sum, mut max, mut min) = (first, first, first); + let (mut sum, mut max, mut min) = (weights[i], weights[i], weights[i]); let mut worst = worst_aspect(sum, max, min, side); let mut j = i + 1; - while j < items.len() { - let a = items[j].1 as f64 * scale; + while j < weights.len() { + let a = weights[j]; let candidate = worst_aspect(sum + a, max.max(a), min.min(a), side); if candidate > worst { break; @@ -275,80 +386,48 @@ fn lay_children( j += 1; } - lay_row( - tree, - &items[i..j], - scale, - sum, - &mut remaining, - depth, - min_side, - opts, - visible, - out, - ); - i = j; - } -} - -fn worst_aspect(sum: f64, max: f64, min: f64, side: f64) -> f64 { - let s2 = sum * sum; - let w2 = side * side; - (w2 * max / s2).max(s2 / (w2 * min)) -} + let horizontal = remaining.w < remaining.h; // the row spans the short side + let thickness = (sum / side).min(if horizontal { remaining.h } else { remaining.w }); + + let mut frames = Vec::with_capacity(j - i); + let mut offset = 0.0; + for (k, &a) in weights[i..j].iter().enumerate() { + // The last item takes what is left rather than its own share, so + // rounding cannot strand a hairline of frame at the far edge. + let len = if k + 1 == j - i { + (side - offset).max(0.0) + } else { + a / thickness + }; + frames.push(if horizontal { + Frame { + x: remaining.x + offset, + y: remaining.y, + w: len, + h: thickness, + } + } else { + Frame { + x: remaining.x, + y: remaining.y + offset, + w: thickness, + h: len, + } + }); + offset += len; + } + rows.push(Row { start: i, frames }); -#[allow(clippy::too_many_arguments)] -fn lay_row( - tree: &Tree, - row: &[(NodeId, u64)], - scale: f64, - row_area: f64, - remaining: &mut Frame, - depth: u8, - min_side: f64, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let horizontal = remaining.w < remaining.h; // row spans the full width - let side = if horizontal { remaining.w } else { remaining.h }; - let thickness = (row_area / side).min(if horizontal { remaining.h } else { remaining.w }); - - let mut offset = 0.0; - for (k, &(id, size)) in row.iter().enumerate() { - let len = if k == row.len() - 1 { - side - offset - } else { - (size as f64 * scale) / thickness - }; - let frame = if horizontal { - Frame { - x: remaining.x + offset, - y: remaining.y, - w: len, - h: thickness, - } + if horizontal { + remaining.y += thickness; + remaining.h -= thickness; } else { - Frame { - x: remaining.x, - y: remaining.y + offset, - w: thickness, - h: len, - } - }; - offset += len; - if frame.w >= min_side && frame.h >= min_side { - emit(tree, id, frame, depth, opts, visible, out); + remaining.x += thickness; + remaining.w -= thickness; } + i = j; } - - if horizontal { - remaining.y += thickness; - remaining.h -= thickness; - } else { - remaining.x += thickness; - remaining.w -= thickness; - } + rows } #[cfg(test)] @@ -404,6 +483,92 @@ mod tests { r.w as f64 * r.h as f64 } + /// A flat directory of `n` files, all the same size. + fn equal_files(n: u32, size: u64) -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + for i in 0..n { + b.push("f", entry(i + 1, 0, FILE, size)); + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + /// A flat directory of one `big` file and `n` one-byte specks. + fn speck_tree(big: u64, n: u32) -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("big", entry(1, 0, FILE, big)); + for i in 0..n { + b.push("speck", entry(2 + i, 0, FILE, 1)); + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + /// A three-level sample holding both dominant files and speck swarms, so + /// the floor has work to do at every level. + fn mixed_tree() -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("media", entry(1, 0, DIR, 0)); + b.push("movie", entry(2, 1, FILE, 400_000)); + b.push("clips", entry(3, 1, DIR, 0)); + b.push("readme", entry(4, 0, FILE, 500)); + b.push("src", entry(5, 0, DIR, 0)); + let mut next = 6u32; + for _ in 0..10 { + b.push("clip", entry(next, 3, FILE, 1_000)); + next += 1; + } + for _ in 0..200 { + b.push("dud", entry(next, 3, FILE, 1)); + next += 1; + } + for _ in 0..40 { + b.push("mod", entry(next, 5, FILE, 300)); + next += 1; + } + for _ in 0..600 { + b.push("crumb", entry(next, 5, FILE, 1)); + next += 1; + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + /// The area a directory hands to its children: its own rect less the + /// padding and the label strip, exactly as `emit` computes it. + fn body_area(dir: &TreemapRect, opts: &TreemapOptions) -> f64 { + let pad = opts.padding_px as f64; + let w = (dir.w as f64 - 2.0 * pad).max(0.0); + let h = (dir.h as f64 - 2.0 * pad - opts.label_px as f64).max(0.0); + w * h + } + + /// Total area `dir`'s direct children were laid into. Containment is what + /// identifies them — it has to be, since two directories at the same depth + /// are both `depth + 1` away from the same root. + fn children_area(rects: &[TreemapRect], dir: &TreemapRect, opts: &TreemapOptions) -> f64 { + let pad = opts.padding_px; + let (x0, y0) = (dir.x + pad, dir.y + pad + opts.label_px); + let (x1, y1) = (dir.x + dir.w - pad, dir.y + dir.h - pad); + rects + .iter() + .filter(|r| { + r.depth == dir.depth + 1 + && r.x >= x0 - 0.01 + && r.y >= y0 - 0.01 + && r.x + r.w <= x1 + 0.01 + && r.y + r.h <= y1 + 0.01 + }) + .map(area) + .sum() + } + #[test] fn areas_are_proportional_to_sizes() { let tree = flat_tree(&[("a", 500), ("b", 300), ("c", 200)]); @@ -538,43 +703,178 @@ mod tests { assert!((f.h - (dir.h - 4.0)).abs() < 0.01); } + /// 1,000,000 vs 1 in 100×100: the small file's share is a 100×0.0001 + /// sliver, and it cannot buy a 1×1 block either — so it goes. Note where + /// its space ends up: with the others, not left blank where it was. #[test] - fn tiny_children_are_culled_without_inflating_the_rest() { - // 1,000,000 vs 1: in 100×100 the small file gets a 100×0.0001 sliver, - // too thin to be legible at any width. - let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); + fn a_child_below_the_floor_loses_its_share_to_the_rest() { + let tree = speck_tree(1_000_000, 1); let opts = TreemapOptions { min_side_px: 1.0, - padding_px: 0.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); - assert!(rects.iter().all(|r| r.id != 2), "tiny rect must be culled"); - let big = rect_of(&rects, 1); - // big keeps its proportional share; the sliver is just not drawn - assert!((area(&big) - 10_000.0 * (1_000_000.0 / 1_000_001.0)).abs() < 1.0); + assert!(rects.iter().all(|r| r.id != 2), "tiny rect must be dropped"); + // Not its proportional share — which would leave the sliver's width + // blank — but the whole block. + assert!((area(&rect_of(&rects, 1)) - 10_000.0).abs() < 0.01); } - /// The floor is a side, not an area: 10,000 vs 1 in 100×100 gives the small - /// file a 100×0.01 slice worth a whole square pixel, which the old area - /// test happily drew as an invisible line. + /// The floor is a side, not an area: 10,000 vs 1 in 100×100 gives the + /// small file a 100×0.01 slice worth a whole square pixel, which an area + /// test passes and nobody can see. #[test] - fn a_sliver_is_culled_even_though_its_area_clears_the_floor() { - let tree = flat_tree(&[("big", 10_000), ("thin", 1)]); + fn a_sliver_cannot_hold_the_floor_and_is_dropped() { + let tree = speck_tree(10_000, 1); let opts = TreemapOptions { min_side_px: 2.0, - padding_px: 0.0, - label_px: 0.0, + ..no_padding() + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + assert!(rects.iter().all(|r| r.id != 2), "sliver must be dropped"); + assert!((area(&rect_of(&rects, 1)) - 10_000.0).abs() < 0.01); + } + + /// Positively: children whose proportional share is under the floor are + /// drawn at the floor anyway, and what they cover is the whole block. A + /// culling layout would have left this 100×100 empty. + #[test] + fn small_children_are_raised_to_the_floor_and_fill_the_block() { + // 300 equal files: 33px² each, under the 6px (36px²) floor. + let tree = equal_files(300, 1); + let opts = TreemapOptions { + min_side_px: 6.0, + ..no_padding() + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + let root = rect_of(&rects, 0); + let drawn = rects.iter().filter(|r| r.depth == 1).count(); + // A 100×100 frame holds at most (100/6)² ≈ 277 of these. The run has + // to stay in that neighbourhood: shedding the tail until one block is + // left would also fill the frame, and would be useless. + assert!(drawn >= 100, "the block is tiled, not one plate: {drawn}"); + assert!( + (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + "the children cover the block" + ); + for r in rects.iter().filter(|r| r.depth == 1) { + assert!( + r.w.min(r.h) >= 6.0, + "id {} came out {}×{}, under the floor", + r.id, + r.w, + r.h + ); + } + } + + /// "All one size": siblings equally far under the floor come out equally + /// sized, whatever their byte counts. + #[test] + fn floored_siblings_all_get_the_same_size() { + let tree = equal_files(300, 1); + let opts = TreemapOptions { + min_side_px: 6.0, + ..no_padding() + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + let areas: Vec = rects.iter().filter(|r| r.depth == 1).map(area).collect(); + assert!(areas.len() > 1); + let small = areas.iter().copied().fold(f64::MAX, f64::min); + let large = areas.iter().copied().fold(f64::MIN, f64::max); + assert!(small / large > 0.99, "sizes spread {small} to {large}"); + } + + /// The same rule on a padded, labelled frame: the child that cannot buy a + /// block is gone, and the one that is left covers the body exactly — the + /// padding and the strip are all that shows through. + #[test] + fn a_dropped_tail_leaves_no_gap() { + let tree = speck_tree(1_000_000, 1); + let opts = TreemapOptions { + min_side_px: 6.0, + padding_px: 2.0, + label_px: 5.0, + ..no_padding() + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + let root = rect_of(&rects, 0); + assert!(rects.iter().all(|r| r.id != 2), "tiny cannot buy a block"); + assert!( + (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + "the survivor takes the dropped child's share too" + ); + } + + /// One file holding 99.985% of a 600×400 block leaves the rest able to buy + /// exactly one minimum block between them. Laying them out anyway squeezes + /// the remainder into a 0.09px column — and hit-testing walks deepest + /// first, so that column would answer for the whole 400px of its height. + #[test] + fn a_degenerate_tail_is_dropped_instead_of_drawn_as_a_sliver() { + let tree = speck_tree(999_850, 150); + let opts = TreemapOptions { + min_side_px: 6.0, + ..no_padding() + }; + let rects = layout(&tree, 0, Viewport { w: 600.0, h: 400.0 }, &opts); + + for r in &rects { + assert!( + r.w.min(r.h) >= 6.0, + "id {} is {}×{}, under the floor", + r.id, + r.w, + r.h + ); + } + let root = rect_of(&rects, 0); + assert!( + (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + "dropping the tail does not open a hole" + ); + } + + /// The whole rule as one invariant, under the shipped options: at every + /// depth, every directory's children exactly cover the frame it hands + /// down. A gap anywhere is the bug this exists to prevent. + #[test] + fn production_options_leave_no_gaps_at_any_depth() { + let opts = TreemapOptions { + min_side_px: 6.0, + padding_px: 1.0, + label_px: 15.0, max_depth: 32, hide_system: false, }; - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + let tree = mixed_tree(); + let rects = layout(&tree, 0, Viewport { w: 900.0, h: 600.0 }, &opts); - assert!(rects.iter().all(|r| r.id != 2), "sliver must be culled"); - assert!((area(&rect_of(&rects, 1)) - 10_000.0 * (10_000.0 / 10_001.0)).abs() < 1.0); + let dirs: Vec = rects.iter().copied().filter(|r| r.is_dir).collect(); + assert!(dirs.len() >= 4, "the sample really does nest"); + assert!( + rects.iter().any(|r| r.depth == 3), + "and runs deep enough for the floor to bite" + ); + for dir in &dirs { + let kids = children_area(&rects, dir, &opts); + // No children means the directory stayed a plate: its body could + // not hold a legible block, so it never subdivided. + if kids == 0.0 { + continue; + } + let body = body_area(dir, &opts); + assert!( + (kids - body).abs() < 0.001 * body, + "dir {} covers {kids} of its {body}px² body", + dir.id + ); + } } /// Each level takes its strip out of its own children's frame, so a nested diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index b1771b8..34bf82d 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -341,9 +341,11 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } -/// Legibility floor for a treemap rect, in CSS pixels: below this on either -/// side the block is not drawn, so the map never fills with specks. Mirrored -/// as `TREEMAP_MIN_SIDE_PX` in `ui/src/lib/prefs.ts`. +/// Legibility floor for a treemap rect, in CSS pixels. A child whose share of +/// its directory falls under this is drawn at this size anyway — a directory +/// has to tile edge to edge, or the small stuff reads as a hole rather than as +/// small stuff. What no longer fits at this size is dropped smallest first, so +/// the map still never turns into a mosaic of specks. const TREEMAP_MIN_SIDE_PX: f32 = 6.0; /// The strip a directory reserves at the top of its interior for its label, @@ -394,8 +396,8 @@ pub fn get_treemap( }; // The label's text rides along with the geometry: a rect the user can see // is a rect they can read, and asking per node would be one round trip per - // rectangle. The culling above is what keeps the payload honest — what is - // sent is roughly what fits on screen, not the whole subtree. + // rectangle. The floor in `opts` is what keeps the payload honest — what + // is sent is roughly what fits on screen, not the whole subtree. Ok(rects .into_iter() .map(|r| TreemapRectDto { From df7f108e139ba2f9667741173b1b2b2a8e6e9829 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:39:31 +0800 Subject: [PATCH 05/13] A level of detail has to be worth taking, and labels must not move it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the last round got wrong, both visible. **Folding folders nested into meshes.** A directory subdivided as long as its interior could hold one minimum block, so a folder of packages became a folder of packages of specks — every level spending padding to cut the next one finer, until the map was a field of 6px blocks nobody could read or point at. A level is now taken only when the *biggest* child would land well clear of the floor (twice it, on a side). A folder whose best child is still a speck says more as one plate. Measured on /usr/share (337k nodes): 3686 rects, down from 6969 with the gate off. **Labels moved the blocks.** Turning the mode on reserved a strip at the top of every directory for its name, so the geometry with labels on was not the geometry with them off. `label_px` is gone from the layout entirely and `get_treemap` no longer takes a `labels` flag: there is now no way for the geometry to know, so toggling is a repaint rather than a refetch. Names are painted into blocks that have room for them, and only into *solid* ones — a block the layout subdivided is covered by its children, and a name centred in it would land on top of them. Which leaves the seam. A solid folder plate had nothing to say where it ended: the grain under it is one continuous pattern and the parent's plate fills the gaps, so thirty equal folders drew as a single flat field — "show one folder" showed nothing. Solid plates now get the 1px seam the file blocks always had, painted in the background colour. Plates that have children get nothing, or every subdivided directory would ring itself in a hairline frame. Shedding is proportional now rather than "drop everything the failing row was holding". A row is fit or unfit only as part of a whole layout — the same run of equal blocks tiles legibly at some counts and not others — so stopping at the first failure left a directory of equals as one plate. A slice at a time converges from any starting point and drops the smallest first, which is the order this was always meant to drop in. --- crates/core/src/treemap.rs | 243 +++++++++++++++++----------------- src-tauri/src/scan.rs | 13 +- ui/src/components/Treemap.tsx | 104 +++++++++------ ui/src/lib/api.ts | 2 - 4 files changed, 187 insertions(+), 175 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index 7d74c7e..e88e0b4 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -8,8 +8,13 @@ //! drawn anyway, so a directory tiles edge to edge instead of opening holes //! nobody can attribute to anything. What still does not fit at that size is //! dropped — smallest first — and the survivors re-normalize into the space, -//! so dropping leaves no hole either. A directory subdivides only while its -//! own interior can hold a legible child. +//! so dropping leaves no hole either. +//! +//! How deep that goes is decided by the pixels, not by a number: a directory +//! subdivides only while its interior can hold a legible child *and* its +//! biggest child would land well clear of the floor. Without the second half, +//! folders nest into folders of specks — each level spending padding to cut +//! the next one finer — and the map ends up a mesh nothing can be read from. use crate::category::categorize; use crate::entry::EntryFlags; @@ -32,26 +37,21 @@ pub struct TreemapOptions { /// children out strictly proportionally. pub min_side_px: f32, pub padding_px: f32, - /// Vertical strip a directory reserves at the top of its interior for its - /// label; zero reserves nothing. Only the *children's* frame shrinks — - /// see `emit` for why a directory's own rect must not depend on this. - pub label_px: f32, pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, } -impl Default for TreemapOptions { - fn default() -> Self { - TreemapOptions { - min_side_px: 1.0, - padding_px: 1.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, - } - } -} +/// How much clear of the floor a directory's *biggest* child has to land +/// before the directory is worth subdividing at all — as a multiple of +/// `min_side_px`, measured on a side. +/// +/// Without this a directory subdivides as long as it can fit one minimum +/// block, so folding folders end up as folders-of-specks-of-specks: every +/// level eats padding and cuts the last one finer, and what you get is a +/// field of blocks too small to read or to point at. A folder whose best +/// child would still be a speck says more as one plate. +const DETAIL_FACTOR: f64 = 2.0; #[derive(Clone, Copy, Debug, PartialEq)] pub struct TreemapRect { @@ -180,15 +180,6 @@ impl Frame { h: self.h - 2.0 * pad, } } - - /// Takes `pad` off the top edge alone: the strip a label sits in. - fn below(&self, pad: f64) -> Frame { - Frame { - y: self.y + pad, - h: self.h - pad, - ..*self - } - } } fn emit( @@ -225,17 +216,15 @@ fn emit( if inner.w <= 0.0 || inner.h <= 0.0 { return; } - let body = inner.below(opts.label_px as f64); - // Adaptive depth: subdivide only where a child could still be legible. - // Going one level deeper costs padding, a label strip and the padding - // again, so a branch runs out of room on its own and stops — which is - // what keeps a big folder from dissolving into specks, and a small one - // from showing levels nobody can see. `max_depth` is only an upper bound + // Adaptive depth, second half: going a level deeper costs padding off + // every side of every grandchild, so a branch runs out of room on its own + // and stops. (`lay_children` applies the first half — whether the biggest + // child here is worth a level at all.) `max_depth` is only an upper bound // on top of that. - if body.w < opts.min_side_px as f64 || body.h < opts.min_side_px as f64 { + if inner.w < opts.min_side_px as f64 || inner.h < opts.min_side_px as f64 { return; } - lay_children(tree, id, body, depth + 1, opts, visible, out); + lay_children(tree, id, inner, depth + 1, opts, visible, out); } fn lay_children( @@ -262,6 +251,16 @@ fn lay_children( let scale = area / total; let min_side = opts.min_side_px as f64; + // Adaptive depth, first half. A level is worth having only if the biggest + // thing in here comes out well clear of the floor: otherwise the + // subdivision is a mosaic of specks that says no more than the plate it + // replaces did, and it says it one folder deeper every time. Items are + // sorted, so the first is the biggest. + let detail = min_side * DETAIL_FACTOR; + if min_side > 0.0 && items[0].1 as f64 * scale < detail * detail { + return; + } + let rows = if min_side <= 0.0 { // No floor to honour: strictly proportional, nothing to refit. let weights: Vec = items.iter().map(|&(_, s)| s as f64 * scale).collect(); @@ -308,6 +307,15 @@ impl Row { /// Packs a floored weight list into `frame`: shed from the tail until the /// survivors tile it in legible blocks. Every pass re-normalizes, so whatever /// the dropped items were holding flows back into the ones that remain. +/// +/// Shedding cannot stop at the first unfit row, tempting as that is: the row +/// that gave out is the *start of the run* that did, and when that run is the +/// whole tail, stopping there leaves a directory of equals showing as one +/// plate. Nor is a row fit or unfit on its own — a run of equal blocks tiles +/// legibly for some row counts and not others, so the whole layout has to be +/// tried again at each size. A slice at a time, therefore, dropping the +/// smallest first as the user would expect. `keep` only shrinks and a lone +/// block fills the frame, so this converges from any starting point. fn fit(weights: &[f64], frame: Frame, min_side: f64) -> Vec { let area = frame.area(); let mut keep = weights.len(); @@ -320,20 +328,12 @@ fn fit(weights: &[f64], frame: Frame, min_side: f64) -> Vec { let norm = area / sum; let fitted: Vec = weights[..keep].iter().map(|w| w * norm).collect(); let rows = squarify(&fitted, frame); - match first_unfit(&rows, keep, min_side) { - // The very first row is unfit, so no later one can be better: the - // run is cut too finely for this frame to tile legibly at all. - // Shed a slice of the tail and try a coarser cut — dropping - // everything but the biggest child would be the wrong answer for a - // directory full of equals. - Some(0) if keep > 1 => keep -= (keep / 16).max(1), - // A later row gives out: that row and the tail behind it are what - // the floor cannot afford. - Some(at) if keep > 1 => keep = at, - // `keep == 1` lays one block across the whole frame, which cannot - // be unfit — so this is the clean case, and the only way out. - _ => return rows, + // A lone block fills the frame and cannot be unfit, so a clean run + // here is also the only way out of the loop. + if keep == 1 || first_unfit(&rows, keep, min_side).is_none() { + return rows; } + keep -= (keep / 16).max(1); } } @@ -469,7 +469,6 @@ mod tests { TreemapOptions { min_side_px: 0.0, padding_px: 0.0, - label_px: 0.0, max_depth: 32, hide_system: false, } @@ -540,12 +539,12 @@ mod tests { builder.finish() } - /// The area a directory hands to its children: its own rect less the - /// padding and the label strip, exactly as `emit` computes it. + /// The area a directory hands to its children: its own rect inset by the + /// padding, exactly as `emit` computes it. fn body_area(dir: &TreemapRect, opts: &TreemapOptions) -> f64 { let pad = opts.padding_px as f64; let w = (dir.w as f64 - 2.0 * pad).max(0.0); - let h = (dir.h as f64 - 2.0 * pad - opts.label_px as f64).max(0.0); + let h = (dir.h as f64 - 2.0 * pad).max(0.0); w * h } @@ -554,7 +553,7 @@ mod tests { /// are both `depth + 1` away from the same root. fn children_area(rects: &[TreemapRect], dir: &TreemapRect, opts: &TreemapOptions) -> f64 { let pad = opts.padding_px; - let (x0, y0) = (dir.x + pad, dir.y + pad + opts.label_px); + let (x0, y0) = (dir.x + pad, dir.y + pad); let (x1, y1) = (dir.x + dir.w - pad, dir.y + dir.h - pad); rects .iter() @@ -689,7 +688,6 @@ mod tests { let opts = TreemapOptions { min_side_px: 0.0, padding_px: 2.0, - label_px: 0.0, max_depth: 32, hide_system: false, }; @@ -739,23 +737,21 @@ mod tests { /// Positively: children whose proportional share is under the floor are /// drawn at the floor anyway, and what they cover is the whole block. A - /// culling layout would have left this 100×100 empty. + /// culling layout would have left their corner of it flat and empty. #[test] fn small_children_are_raised_to_the_floor_and_fill_the_block() { - // 300 equal files: 33px² each, under the 6px (36px²) floor. - let tree = equal_files(300, 1); + // 300 bytes for `big`, one each for sixty specks: 111px² each here, + // under the 6px (36px²) floor, so the floor is what sizes them. + let tree = speck_tree(300, 60); let opts = TreemapOptions { min_side_px: 6.0, ..no_padding() }; - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + let rects = layout(&tree, 0, Viewport { w: 200.0, h: 200.0 }, &opts); let root = rect_of(&rects, 0); let drawn = rects.iter().filter(|r| r.depth == 1).count(); - // A 100×100 frame holds at most (100/6)² ≈ 277 of these. The run has - // to stay in that neighbourhood: shedding the tail until one block is - // left would also fill the frame, and would be useless. - assert!(drawn >= 100, "the block is tiled, not one plate: {drawn}"); + assert!(drawn > 1, "the specks are drawn, not dropped: {drawn}"); assert!( (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, "the children cover the block" @@ -775,15 +771,20 @@ mod tests { /// sized, whatever their byte counts. #[test] fn floored_siblings_all_get_the_same_size() { - let tree = equal_files(300, 1); + let tree = speck_tree(300, 60); let opts = TreemapOptions { min_side_px: 6.0, ..no_padding() }; - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + let rects = layout(&tree, 0, Viewport { w: 200.0, h: 200.0 }, &opts); - let areas: Vec = rects.iter().filter(|r| r.depth == 1).map(area).collect(); - assert!(areas.len() > 1); + // Everything but `big` is a speck, and they are all the same speck. + let areas: Vec = rects + .iter() + .filter(|r| r.depth == 1 && r.id != 1) + .map(area) + .collect(); + assert!(areas.len() > 1, "specks were drawn"); let small = areas.iter().copied().fold(f64::MAX, f64::min); let large = areas.iter().copied().fold(f64::MIN, f64::max); assert!(small / large > 0.99, "sizes spread {small} to {large}"); @@ -798,7 +799,6 @@ mod tests { let opts = TreemapOptions { min_side_px: 6.0, padding_px: 2.0, - label_px: 5.0, ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -848,7 +848,6 @@ mod tests { let opts = TreemapOptions { min_side_px: 6.0, padding_px: 1.0, - label_px: 15.0, max_depth: 32, hide_system: false, }; @@ -877,43 +876,13 @@ mod tests { } } - /// Each level takes its strip out of its own children's frame, so a nested - /// directory starts one strip lower than its parent did. That compounding - /// is what makes depth cost pixels — and why it stops on its own. - #[test] - fn every_level_takes_its_label_strip_from_its_children() { - let mut b = EntryBatch::default(); - b.push("root", entry(0, 0, DIR, 0)); - b.push("d1", entry(1, 0, DIR, 0)); - b.push("d2", entry(2, 1, DIR, 0)); - b.push("f", entry(3, 2, FILE, 100)); - let mut builder = TreeBuilder::new(); - builder.add_batch(&b); - let tree = builder.finish(); - - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &labelled(10.0)); - - // The root's own rect is the whole viewport: the strip is taken below - // it, not out of it. - assert!((rect_of(&rects, 0).y).abs() < 0.01); - assert!((rect_of(&rects, 0).h - 100.0).abs() < 0.01); - for (id, top, height) in [(1u32, 10.0, 90.0), (2, 20.0, 80.0), (3, 30.0, 70.0)] { - let r = rect_of(&rects, id); - assert!((r.y - top).abs() < 0.01, "id {id} starts a strip lower"); - assert!( - (r.h - height).abs() < 0.01, - "id {id} lost a strip off its height" - ); - assert!((r.w - 100.0).abs() < 0.01, "a strip is vertical"); - } - } - /// Depth changes stay live because a capped layout is a prefix of a deeper - /// one. The strip is the one addition that could break it — it has to - /// shrink frames the cap then hides anyway, never the rect a shallower - /// layout keeps. + /// one: the UI re-renders from the same rect list rather than re-fetching, + /// so a rect a shallower cap drew has to hold still when a deeper one adds + /// to it. Everything a level decides has to be local to that directory for + /// this to survive the floor. #[test] - fn a_label_strip_does_not_move_the_rects_a_cap_leaves_behind() { + fn a_depth_cap_only_hides_the_rects_it_stops_short_of() { let mut b = EntryBatch::default(); b.push("root", entry(0, 0, DIR, 0)); b.push("d1", entry(1, 0, DIR, 0)); @@ -926,8 +895,8 @@ mod tests { let tree = builder.finish(); let vp = Viewport { w: 400.0, h: 300.0 }; - let capped = layout(&tree, 0, vp, &capped_at(2, 10.0)); - let deep: Vec = layout(&tree, 0, vp, &capped_at(8, 10.0)) + let capped = layout(&tree, 0, vp, &capped_at(2)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8)) .into_iter() .filter(|r| r.depth <= 2) .collect(); @@ -937,10 +906,11 @@ mod tests { assert!(capped.iter().all(|r| r.id != 5), "depth-3 file is not"); } - /// Depth follows the pixels available: the same subtree expands when it has - /// room and stops when it does not, with `max_depth` nowhere near either. + /// Adaptive depth, one half: the same subtree expands when its biggest + /// child is worth a level and stops when it is not — with `max_depth` + /// nowhere near either, and the level's own cost priced in. #[test] - fn a_directory_stops_expanding_when_its_interior_is_too_small() { + fn a_level_that_would_only_show_a_speck_is_not_taken() { let mut b = EntryBatch::default(); b.push("root", entry(0, 0, DIR, 0)); b.push("wide", entry(1, 0, FILE, 800)); @@ -952,35 +922,69 @@ mod tests { let opts = TreemapOptions { min_side_px: 25.0, - ..labelled(10.0) + ..no_padding() }; - // A 40px-tall viewport leaves `mid` 30px, and its own strip takes 10: - // the 20px left cannot hold a child, so it stays a plain plate. + // A 40px-tall viewport leaves `mid` a 40×40 body. Its child is worth + // 1600px² there, against the 2500px² a 25px floor at twice over asks + // for, so the level would show one speck: `mid` stays a plain plate. let cramped = layout(&tree, 0, Viewport { w: 200.0, h: 40.0 }, &opts); assert!(cramped.iter().any(|r| r.id == 2), "the dir itself is drawn"); assert!( cramped.iter().all(|r| r.id != 3), - "its interior cannot hold a legible child" + "a speck of a level is not worth the descent" ); - // The same tree given more height expands, and nobody raised a cap. + // Same tree, taller: the body is 40×100, the child is worth 4000px², + // and the descent happens. Nobody raised a cap. let roomy = layout(&tree, 0, Viewport { w: 200.0, h: 100.0 }, &opts); - assert!(roomy.iter().any(|r| r.id == 3), "now there is room"); + assert!(roomy.iter().any(|r| r.id == 3), "now the level is worth it"); assert!(opts.max_depth > 1, "the cap never came into it"); } - fn labelled(label_px: f32) -> TreemapOptions { - TreemapOptions { - label_px, + /// Adaptive depth, the other half: a body too thin for even one minimum + /// block ends the descent there, whatever is inside it. + #[test] + fn a_body_thinner_than_one_block_is_not_subdivided() { + let tree = flat_tree(&[("a", 100), ("b", 100)]); + let opts = TreemapOptions { + min_side_px: 25.0, ..no_padding() - } + }; + + // 1000 wide and 20 tall: a child could have 500×20, and 20 is under + // the floor, so the root stays a plate. + let rects = layout(&tree, 0, Viewport { w: 1000.0, h: 20.0 }, &opts); + + assert_eq!(rects.len(), 1, "only the root"); + } + + /// The nesting complaint, as a test. A folder of many equal small files + /// has nothing to show one level down — every child would come out a + /// speck — so it stays one plate instead of dissolving into a mesh, and + /// so does every folder under it. Give the same folder real room and the + /// same children are worth drawing. + #[test] + fn a_folder_of_equals_stays_a_plate_until_a_child_is_worth_drawing() { + let tree = equal_files(300, 1); + let opts = TreemapOptions { + min_side_px: 6.0, + ..no_padding() + }; + + let tight = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + assert_eq!(tight.len(), 1, "one plate, not 300 blocks"); + + let roomy = layout(&tree, 0, Viewport { w: 600.0, h: 600.0 }, &opts); + assert!( + roomy.len() > 100, + "at 1200px² a child the floor is not the point any more" + ); } - fn capped_at(max_depth: u8, label_px: f32) -> TreemapOptions { + fn capped_at(max_depth: u8) -> TreemapOptions { TreemapOptions { max_depth, - label_px, ..no_padding() } } @@ -1011,7 +1015,6 @@ mod tests { let opts = TreemapOptions { min_side_px: 0.0, padding_px: 0.0, - label_px: 0.0, max_depth: 1, hide_system: false, }; diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 34bf82d..41888b5 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -348,12 +348,6 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu /// the map still never turns into a mosaic of specks. const TREEMAP_MIN_SIDE_PX: f32 = 6.0; -/// The strip a directory reserves at the top of its interior for its label, -/// when labels are on — and therefore the height the UI has to draw that label -/// in. Mirrored as `LABEL_STRIP_PX` in `ui/src/components/Treemap.tsx`: the two -/// have to agree or the text lands on the children instead of above them. -const TREEMAP_LABEL_PX: f32 = 15.0; - // The argument list mirrors the UI's query; grouping it into a struct would // only move the same list one level down. #[allow(clippy::too_many_arguments)] @@ -366,7 +360,6 @@ pub fn get_treemap( height: f32, hide_system: bool, filter: Option, - labels: bool, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -380,9 +373,9 @@ pub fn get_treemap( let opts = TreemapOptions { min_side_px: TREEMAP_MIN_SIDE_PX, padding_px: 1.0, - // Labels off means the strip is not reserved at all, so the geometry - // is exactly what it was before labels existed. - label_px: if labels { TREEMAP_LABEL_PX } else { 0.0 }, + // The labels the UI can draw are a *view* of this geometry, never an + // input to it: nothing here knows whether they are on, so switching + // them cannot move a single block. max_depth: 24, hide_system, }; diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index e0dc874..75889be 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -55,16 +55,21 @@ function getGrainTile(plate: string, grain: string): HTMLCanvasElement { return c; } -// Mirrors TREEMAP_LABEL_PX in src-tauri/src/scan.rs: the strip the layout -// leaves free at the top of every directory when labels are on, and therefore -// where the label has to sit. If the two disagree the text lands on the -// children. -const LABEL_STRIP_PX = 15; /** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; const LABEL_SIZE_PX = 11; -/** Narrower than this and a name is not worth truncating into. */ -const LABEL_MIN_W_PX = 34; +/** + * Only blocks with room to say something useful get a name. These are what + * "useful" costs: narrower and the name is cut down to two letters, shorter + * and the size has nowhere to go but over the edge. Deliberately generous — + * the point of the mode is the handful of blocks that dominate the map, and + * a wall of captions reads as noise. + */ +const LABEL_MIN_W_PX = 46; +/** Taller than this and the size gets a line of its own under the name. */ +const LABEL_TWO_LINE_H_PX = 30; +/** …and shorter than this and it is not worth writing at all. */ +const LABEL_MIN_H_PX = 18; const LABEL_PAD_PX = 4; /** @@ -95,57 +100,43 @@ function fitText( * work this saves. It is off by default because the blocks are also the map, * and writing in all of them changes what the map reads like. * - * Directories are labelled in the strip the layout reserved for them, files in - * the middle of their block — a file has no strip, and its whole rect is free. + * A pure overlay: it reads the rects the layout already produced and writes + * text into the boxes, so the geometry with this on is the geometry with it + * off, to the pixel. That rules out reserving a strip for a directory's name — + * which is why only *solid* blocks get one. A block the layout subdivided is + * covered by its children, so a name centred in it would land on top of them; + * its children carry the names instead, and a directory you can see into does + * not need to say its own. */ function drawLabels( ctx: CanvasRenderingContext2D, rects: TreemapRect[], dpr: number, - plate: string, ) { ctx.font = `${LABEL_SIZE_PX * dpr}px ${LABEL_FONT}`; ctx.textBaseline = "middle"; + ctx.textAlign = "center"; const minW = LABEL_MIN_W_PX * dpr; const pad = LABEL_PAD_PX * dpr; - const strip = LABEL_STRIP_PX * dpr; - - ctx.textAlign = "left"; - for (const r of rects) { - if (!r.isDir) continue; - const s = snap(r, dpr, 0); - // A block shorter than the strip has no strip to draw in, and a label half - // outside its own plate reads as a label for whatever is below it. Those - // are plates the layout stopped short of subdividing anyway. - if (s.w < minW || s.h < strip) continue; - // The strip is the one part of the plate that carries no texture: the - // layout left it empty for exactly this, and a name read through the grain - // is a name you have to squint at. - ctx.fillStyle = plate; - ctx.fillRect(s.x, s.y, s.w, strip); - ctx.fillStyle = textOn(plate); - const text = fitText( - ctx, - `${r.name} · ${formatBytes(r.size)}`, - s.w - 2 * pad, - ); - if (text) ctx.fillText(text, s.x + pad, s.y + strip / 2); - } - ctx.textAlign = "center"; - for (const r of rects) { - if (r.isDir) continue; + for (let i = 0; i < rects.length; i++) { + const r = rects[i]; + // Rects come out parents before children, so a parent's first child is the + // very next entry: a deeper neighbour means the layout subdivided this one. + if ((rects[i + 1]?.depth ?? 0) > r.depth) continue; const s = snap(r, dpr, 1); - if (s.w < minW) continue; - ctx.fillStyle = textOn(PALETTE[r.category] ?? PALETTE[10]); + if (s.w < minW || s.h < LABEL_MIN_H_PX * dpr) continue; + ctx.fillStyle = r.isDir + ? textOn(canvasColors().plate) + : textOn(PALETTE[r.category] ?? PALETTE[10]); const name = fitText(ctx, r.name, s.w - 2 * pad); if (!name) continue; const cx = s.x + s.w / 2; - if (s.h >= 26 * dpr) { + if (s.h >= LABEL_TWO_LINE_H_PX * dpr) { ctx.fillText(name, cx, s.y + s.h / 2 - 6 * dpr); ctx.fillText(formatBytes(r.size), cx, s.y + s.h / 2 + 8 * dpr); - } else if (s.h >= 13 * dpr) { + } else { ctx.fillText(name, cx, s.y + s.h / 2); } } @@ -357,6 +348,28 @@ export function Treemap({ } ctx.fill(); + // A plate with no children is the final answer for the space it covers, + // and it needs a seam — the grain under it is one continuous pattern, so + // thirty folders of equal size would otherwise draw as a single flat + // field with nothing to say where one ends. Painting the seam means + // painting the background over it: the parent's plate is underneath, and + // a gap alone would just show more of the same texture. Plates that *do* + // have children are only backing for them and get nothing, or every + // subdivided directory would ring itself in a hairline frame. + ctx.fillStyle = theme.background; + ctx.beginPath(); + for (let i = 0; i < rects.length; i++) { + const r = rects[i]; + // Rects come out parents before children, so the next entry is a child + // exactly when it is deeper — the test for "subdivided". + if (!r.isDir || (rects[i + 1]?.depth ?? 0) > r.depth) continue; + const s = snap(r, dpr, 1); + if (s.w <= 0 || s.h <= 0) continue; + ctx.rect(s.x + s.w, s.y, dpr, s.h + dpr); + ctx.rect(s.x, s.y + s.h, s.w, dpr); + } + ctx.fill(); + const buckets: TreemapRect[][] = PALETTE.map(() => []); for (const r of rects) { if (!r.isDir) buckets[r.category]?.push(r); @@ -380,7 +393,7 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } - if (labelsRef.current) drawLabels(ctx, rects, dpr, theme.plate); + if (labelsRef.current) drawLabels(ctx, rects, dpr); if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -430,7 +443,6 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, - labelsRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; @@ -527,7 +539,13 @@ export function Treemap({ useEffect(() => { void fetchLayout(); - }, [hideSystem, filter, labels, fetchLayout]); + }, [hideSystem, filter, fetchLayout]); + + useEffect(() => { + // Labels are painted over the baked layout, never into it: toggling them + // is a repaint of the same rects, not a new query. + bake(); + }, [labels, bake]); const prevStateRef = useRef(undefined); useEffect(() => { diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 5779d8f..ac41948 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -162,7 +162,6 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, - labels: boolean, ) => invoke("get_treemap", { generation, @@ -171,7 +170,6 @@ export const api = { height, hideSystem, filter, - labels, }), getTypeStats: ( generation: number, From aecda403c863d0a7c8bc3424d9176eef2b1741ab Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:26:43 +0800 Subject: [PATCH 06/13] The layout can be asked to open one directory, and folds very wide ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to what decides whether a directory becomes a plate. A directory with more than 200 direct children is one now, by default. At that width its blocks stop being things you can point at and become a texture, and a plate you can click says more. Counted from the children that would actually be drawn, not from `tree.children`, so a filter or hide_system can bring a directory back under the limit — and counted before the sort, so the directories this is meant to keep off the map never pay to order their children. `layout_with_force` takes one id and opens it, plus every ancestor between it and the layout root. One value carries the whole accordion: asking for a directory outside the open one leaves the old one off the chain, so it closes; asking for one inside keeps it on, so it stays. Where the override is consulted matters more than what it does. Both gates return before a single child is placed, so it never reaches `items`, `scale`, `fit` or a frame — opening a directory adds rects under it and moves nothing. A forced layout is a *superset* of the unforced one, which is what lets the map expand in place without a jump, and it is pinned as that rather than as the weaker prefix property. The recursion bound is deliberately not lifted: it is the only brake on a tree whose depth is unbounded, and a click that lands 24 levels down should not be what removes it. The two fixtures that trip the new limit were resized to stay under it — their subject is the legibility rules, and 210 and 640 children were incidental to that. --- crates/core/src/treemap.rs | 425 ++++++++++++++++++++++++++++++++++--- 1 file changed, 400 insertions(+), 25 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index e88e0b4..d79b90e 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -53,6 +53,17 @@ pub struct TreemapOptions { /// child would still be a speck says more as one plate. const DETAIL_FACTOR: f64 = 2.0; +/// How many direct children a directory may have before the layout folds it +/// into one plate by default. +/// +/// Counting visible children, not descendants: what the layout has to place at +/// this level is its own children, and a folder of three folders holding a +/// thousand files each is three blocks here, not a thousand. Well past this +/// the blocks stop being things you can point at — even at 6px, a plate this +/// subdivided is a texture — and a plate you can click is the better answer. +/// Clicking is the ask that overrides it. +const MAX_DIRECT_CHILDREN: usize = 200; + #[derive(Clone, Copy, Debug, PartialEq)] pub struct TreemapRect { pub id: NodeId, @@ -72,7 +83,7 @@ pub fn layout( viewport: Viewport, opts: &TreemapOptions, ) -> Vec { - layout_impl(tree, root, viewport, opts, None) + layout_impl(tree, root, viewport, opts, None, &[]) } /// Layout under a view filter: per-node effective bytes (0 = omit), as @@ -85,7 +96,42 @@ pub fn layout_with_filter( opts: &TreemapOptions, bytes: &[u64], ) -> Vec { - layout_impl(tree, root, viewport, opts, Some(bytes)) + layout_impl(tree, root, viewport, opts, Some(bytes), &[]) +} + +/// Layout with a directory forced open — the one the user asked to see inside +/// of, whatever the legibility rules made of it. +/// +/// `force_open` names the *deepest* directory to open, and the chain from it +/// up to `root` opens with it. One value therefore carries the whole +/// accordion: asking for a directory outside the open one leaves the old one +/// off the chain (so it closes), and asking for one inside keeps it on (so it +/// stays). An id that is not in this subtree, or no longer names a live +/// directory, is not an error — it simply opens nothing. +pub fn layout_with_force( + tree: &Tree, + root: NodeId, + viewport: Viewport, + opts: &TreemapOptions, + filter: Option<&[u64]>, + force_open: Option, +) -> Vec { + let mut chain = Vec::new(); + if let Some(mut cur) = force_open.filter(|&id| tree.is_live(id) && tree.node(id).is_dir()) { + while cur != root { + chain.push(cur); + match tree.node(cur).parent() { + // Walked off the top without meeting the root: the node is in + // another branch, so there is nothing here to open. (The tree + // root can report itself as its own parent, hence the second + // arm — without it this would spin.) + Some(parent) if parent != cur => cur = parent, + _ => return layout_impl(tree, root, viewport, opts, filter, &[]), + } + } + chain.push(root); + } + layout_impl(tree, root, viewport, opts, filter, &chain) } fn layout_impl( @@ -94,6 +140,7 @@ fn layout_impl( viewport: Viewport, opts: &TreemapOptions, filter: Option<&[u64]>, + open: &[NodeId], ) -> Vec { let mut out = Vec::new(); if tree.is_empty() || (root as usize) >= tree.len() || viewport.w <= 0.0 || viewport.h <= 0.0 { @@ -116,7 +163,7 @@ fn layout_impl( } None => None, }; - emit(tree, root, frame, 0, opts, visible, &mut out); + emit(tree, root, frame, 0, opts, visible, open, &mut out); out } @@ -182,6 +229,10 @@ impl Frame { } } +// `open` is threaded down the recursion rather than carried in a context +// struct: it is one slice, and every function here already passes the same six +// things along. +#[allow(clippy::too_many_arguments)] fn emit( tree: &Tree, id: NodeId, @@ -189,6 +240,7 @@ fn emit( depth: u8, opts: &TreemapOptions, visible: Option<&[u64]>, + open: &[NodeId], out: &mut Vec, ) { let node = tree.node(id); @@ -202,6 +254,12 @@ fn emit( is_dir: node.is_dir(), category: categorize(tree.name(id), node.is_dir()) as u8, }); + // `open` — the directories the user asked to see inside of — is consulted + // only at `lay_children`'s two gates, both of which return before a single + // child is placed. It never reaches `items`, `scale`, `fit` or a frame, so + // opening a directory adds rects under it and moves nothing: a forced + // layout is a *superset* of the unforced one, not merely a different one. + // That is what lets the map expand in place without a jump. if !node.is_dir() || depth >= opts.max_depth { return; } @@ -220,13 +278,15 @@ fn emit( // every side of every grandchild, so a branch runs out of room on its own // and stops. (`lay_children` applies the first half — whether the biggest // child here is worth a level at all.) `max_depth` is only an upper bound - // on top of that. + // on top of that, and it is the one bound forcing does *not* lift: it is + // the only brake on recursion, and the tree's depth is unbounded. if inner.w < opts.min_side_px as f64 || inner.h < opts.min_side_px as f64 { return; } - lay_children(tree, id, inner, depth + 1, opts, visible, out); + lay_children(tree, id, inner, depth + 1, opts, visible, open, out); } +#[allow(clippy::too_many_arguments)] fn lay_children( tree: &Tree, dir: NodeId, @@ -234,8 +294,10 @@ fn lay_children( depth: u8, opts: &TreemapOptions, visible: Option<&[u64]>, + open: &[NodeId], out: &mut Vec, ) { + let forced = open.contains(&dir); let mut items: Vec<(NodeId, u64)> = tree .children(dir) .map(|c| (c, effective_size(tree, c, visible))) @@ -244,22 +306,37 @@ fn lay_children( if items.is_empty() { return; } - items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - - let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); - let area = frame.area(); - let scale = area / total; let min_side = opts.min_side_px as f64; - - // Adaptive depth, first half. A level is worth having only if the biggest - // thing in here comes out well clear of the floor: otherwise the - // subdivision is a mosaic of specks that says no more than the plate it - // replaces did, and it says it one folder deeper every time. Items are - // sorted, so the first is the biggest. - let detail = min_side * DETAIL_FACTOR; - if min_side > 0.0 && items[0].1 as f64 * scale < detail * detail { - return; + let mut total = 0.0f64; + let mut biggest = 0u64; + for &(_, size) in &items { + total += size as f64; + biggest = biggest.max(size); } + let scale = frame.area() / total; + + // Adaptive depth, first half — the two reasons a level is not worth + // drawing. Both are policy rather than geometry, hence the `min_side > 0` + // guard: zero means "no floor, lay it out as it is", which is what the + // exact-geometry tests ask for and what makes those reasons moot. Neither + // applies to a directory the user opened by hand; that ask outranks them. + if !forced && min_side > 0.0 { + // Counted from the visible children rather than `tree.children`, + // because under a filter or hide_system what matters is how many + // blocks would actually be drawn. Ahead of the sort, so a directory + // this is meant to keep off the map never pays to order it. + if items.len() > MAX_DIRECT_CHILDREN { + return; + } + // The biggest thing in here would come out a speck: the subdivision + // says no more than the plate it replaces did, and says it one folder + // deeper every time. + let detail = min_side * DETAIL_FACTOR; + if biggest as f64 * scale < detail * detail { + return; + } + } + items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); let rows = if min_side <= 0.0 { // No floor to honour: strictly proportional, nothing to refit. @@ -286,7 +363,16 @@ fn lay_children( for row in &rows { for (k, f) in row.frames.iter().enumerate() { - emit(tree, items[row.start + k].0, *f, depth, opts, visible, out); + emit( + tree, + items[row.start + k].0, + *f, + depth, + opts, + visible, + open, + out, + ); } } } @@ -522,7 +608,7 @@ mod tests { b.push("clip", entry(next, 3, FILE, 1_000)); next += 1; } - for _ in 0..200 { + for _ in 0..150 { b.push("dud", entry(next, 3, FILE, 1)); next += 1; } @@ -530,7 +616,7 @@ mod tests { b.push("mod", entry(next, 5, FILE, 300)); next += 1; } - for _ in 0..600 { + for _ in 0..150 { b.push("crumb", entry(next, 5, FILE, 1)); next += 1; } @@ -942,6 +1028,267 @@ mod tests { assert!(opts.max_depth > 1, "the cap never came into it"); } + /// The child limit, from both sides of it. The viewport is far too big for + /// the legibility gate to be what folds either one — 200 equal children in + /// 600×600 is 1800px² each — so only the count can be doing it. + #[test] + fn more_direct_children_than_the_limit_stay_a_plate() { + let vp = Viewport { w: 600.0, h: 600.0 }; + let opts = floored(6.0); + + let under = layout(&equal_files(MAX_DIRECT_CHILDREN as u32, 1), 0, vp, &opts); + assert!(under.len() > 1, "the limit itself is still drawn"); + + let over = layout( + &equal_files(MAX_DIRECT_CHILDREN as u32 + 1, 1), + 0, + vp, + &opts, + ); + assert_eq!(over.len(), 1, "one plate once it is over"); + } + + /// What the limit counts is children that would be *drawn*. Hiding the + /// system files takes this directory from 250 of them to 50, which is back + /// under the limit, so it opens again. + #[test] + fn the_child_limit_counts_visible_direct_children() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + let mut next = 1u32; + for _ in 0..50 { + b.push("keep", entry(next, 0, FILE, 1000)); + next += 1; + } + for _ in 0..200 { + b.push("sys", entry(next, 0, EntryFlags::SYSTEM, 1000)); + next += 1; + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let vp = Viewport { w: 600.0, h: 600.0 }; + assert_eq!( + layout(&tree, 0, vp, &floored(6.0)).len(), + 1, + "250 of them is over the limit" + ); + let hidden = TreemapOptions { + hide_system: true, + ..floored(6.0) + }; + assert!( + layout(&tree, 0, vp, &hidden).len() > 1, + "50 visible ones is not" + ); + } + + /// Descendants are not children. Three folders holding thousands of files + /// between them is three blocks at this level, and the limit has nothing + /// to say about it. + #[test] + fn the_child_limit_counts_children_not_descendants() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + for d in 0..3u32 { + b.push("dir", entry(1 + d, 0, DIR, 0)); + } + let mut next = 4u32; + for d in 0..3u32 { + for _ in 0..700 { + b.push("f", entry(next, 1 + d, FILE, 1)); + next += 1; + } + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let rects = layout(&tree, 0, Viewport { w: 600.0, h: 600.0 }, &floored(6.0)); + + assert_eq!( + rects.iter().filter(|r| r.depth == 1).count(), + 3, + "three folders, whatever is inside them" + ); + } + + /// The ask: a plate the legibility rules made is still openable by hand. + #[test] + fn forcing_a_plate_open_reveals_what_the_floor_hid() { + let tree = legibility_gated_plate(200); + let vp = Viewport { w: 400.0, h: 400.0 }; + let opts = floored(6.0); + + let base = layout(&tree, 0, vp, &opts); + assert!( + base.iter().all(|r| r.depth < 2), + "the plate really is a plate" + ); + + let forced = layout_with_force(&tree, 0, vp, &opts, None, Some(2)); + assert!( + forced.iter().any(|r| r.depth == 2), + "and it opens when asked" + ); + } + + /// Opening a directory must not *move* anything, or the map would jump the + /// moment it was clicked. A forced layout is a superset of the unforced + /// one: same rects, same frames, plus whatever the override revealed. + /// (That the revealed children sit inside their parent is a separate + /// question — `parents_are_emitted_before_children_and_contain_them`.) + #[test] + fn forcing_a_plate_open_moves_no_rect_it_does_not_reveal() { + let tree = legibility_gated_plate(200); + let vp = Viewport { w: 400.0, h: 400.0 }; + let opts = floored(6.0); + + let base = layout(&tree, 0, vp, &opts); + let forced = layout_with_force(&tree, 0, vp, &opts, None, Some(2)); + + // Both legs matter: without the first, an override that did nothing at + // all would satisfy the comparison. + assert!( + base.iter().all(|r| r.depth < 2), + "the plate really is a plate" + ); + assert!(forced.len() > base.len(), "and forcing really does open it"); + + let seen: std::collections::HashSet = base.iter().map(|r| r.id).collect(); + let shared: Vec = forced + .iter() + .copied() + .filter(|r| seen.contains(&r.id)) + .collect(); + assert_eq!(base, shared, "the rects that were already there held still"); + } + + /// The accordion, at the layout level: the node asked for opens, and so + /// does everything between it and the root that was hiding it. Without + /// that, asking for a folder inside a folded folder would open nothing. + #[test] + fn forcing_an_inside_node_opens_the_ancestors_it_hides_behind() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("outer", entry(1, 0, DIR, 0)); + b.push("inner", entry(2, 1, DIR, 0)); + let mut next = 3u32; + for _ in 0..8 { + b.push("leaf", entry(next, 2, FILE, 1000)); + next += 1; + } + for _ in 0..200 { + b.push("f", entry(next, 1, FILE, 1)); + next += 1; + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let vp = Viewport { w: 600.0, h: 600.0 }; + let opts = floored(6.0); + let base = layout(&tree, 0, vp, &opts); + assert!(base.iter().all(|r| r.depth < 2), "outer is folded"); + + let forced = layout_with_force(&tree, 0, vp, &opts, None, Some(2)); + assert!(forced.iter().any(|r| r.depth == 2), "outer opened too"); + assert!(forced.iter().any(|r| r.depth == 3), "and inner with it"); + } + + /// A node in another branch is not ours to open. + #[test] + fn forcing_a_node_outside_the_layout_root_changes_nothing() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("a", entry(1, 0, DIR, 0)); + b.push("af", entry(2, 1, FILE, 500)); + b.push("b", entry(3, 0, DIR, 0)); + b.push("bf", entry(4, 3, FILE, 500)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let vp = Viewport { w: 300.0, h: 300.0 }; + let opts = floored(6.0); + let plain = layout(&tree, 1, vp, &opts); + let forced = layout_with_force(&tree, 1, vp, &opts, None, Some(3)); + + assert_eq!(plain, forced, "another branch is not in this layout"); + } + + /// Opening something that was already open is not an event. `mid` fills + /// the viewport on its own here, so it clears the legibility gate without + /// anyone asking — the override has to leave a layout it agrees with + /// exactly as it found it. + #[test] + fn forcing_an_already_open_directory_changes_nothing() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("mid", entry(1, 0, DIR, 0)); + b.push("a", entry(2, 1, FILE, 400)); + b.push("b", entry(3, 1, FILE, 400)); + b.push("c", entry(4, 1, FILE, 200)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let vp = Viewport { w: 400.0, h: 400.0 }; + let opts = floored(6.0); + let base = layout(&tree, 0, vp, &opts); + assert!( + base.iter().any(|r| r.depth == 2), + "mid subdivides on its own" + ); + + let forced = layout_with_force(&tree, 0, vp, &opts, None, Some(1)); + + assert_eq!(base, forced); + } + + /// Opening by hand overrides the legibility rules, not the floor: the + /// revealed children are still blocks someone can see and click. + #[test] + fn forcing_still_honours_the_floor() { + let tree = legibility_gated_plate(400); + let vp = Viewport { w: 400.0, h: 400.0 }; + let opts = floored(6.0); + + let rects = layout_with_force(&tree, 0, vp, &opts, None, Some(2)); + + assert!(rects.len() > 2, "the plate opened"); + for r in &rects { + assert!( + r.w.min(r.h) >= 6.0, + "id {} is {}×{}, under the floor", + r.id, + r.w, + r.h + ); + } + } + + /// A filter can zero out everything inside a folder. Opening it then finds + /// nothing to draw — which is a plate, not a panic. + #[test] + fn forcing_a_directory_with_nothing_visible_stays_a_plate() { + let tree = legibility_gated_plate(200); + let mut bytes = vec![0u64; tree.len()]; + bytes[0] = 8000; + bytes[1] = 8000; + // Every leaf of `mid` is filtered out, so `mid` has no visible children. + bytes[2] = 0; + + let vp = Viewport { w: 400.0, h: 400.0 }; + let opts = floored(6.0); + let plain = layout_with_filter(&tree, 0, vp, &opts, &bytes); + let forced = layout_with_force(&tree, 0, vp, &opts, Some(&bytes), Some(2)); + + assert_eq!(plain, forced); + assert!(forced.iter().all(|r| r.id != 2 || r.is_dir)); + } + /// Adaptive depth, the other half: a body too thin for even one minimum /// block ends the descent there, whatever is inside it. #[test] @@ -966,22 +1313,50 @@ mod tests { /// same children are worth drawing. #[test] fn a_folder_of_equals_stays_a_plate_until_a_child_is_worth_drawing() { - let tree = equal_files(300, 1); + // 150 files is under the child limit, so what is being tested here is + // the legibility gate and nothing else. + let tree = equal_files(150, 1); let opts = TreemapOptions { min_side_px: 6.0, ..no_padding() }; let tight = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); - assert_eq!(tight.len(), 1, "one plate, not 300 blocks"); + assert_eq!(tight.len(), 1, "one plate, not 150 blocks"); let roomy = layout(&tree, 0, Viewport { w: 600.0, h: 600.0 }, &opts); assert!( roomy.len() > 100, - "at 1200px² a child the floor is not the point any more" + "at 2400px² a child the floor is not the point any more" ); } + /// The shipped floor, without padding — the cheapest options that still + /// have the legibility rules switched on. + fn floored(min_side: f32) -> TreemapOptions { + TreemapOptions { + min_side_px: min_side, + ..no_padding() + } + } + + /// A root holding one big file and one directory of `n` one-byte children, + /// sized so that directory is a plate for the *legibility* reason — its + /// biggest child would come out around 20px² — and not because of the + /// child count. + fn legibility_gated_plate(n: u32) -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("wide", entry(1, 0, FILE, 8000)); + b.push("mid", entry(2, 0, DIR, 0)); + for i in 0..n { + b.push("leaf", entry(3 + i, 2, FILE, 1)); + } + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + fn capped_at(max_depth: u8) -> TreemapOptions { TreemapOptions { max_depth, From f51185d0958179d4fff9002d5876c5e867378c30 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:35:23 +0800 Subject: [PATCH 07/13] Folders get a colour, very wide ones fold, and a plate opens on click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things that only made sense together. A folder plate was the app's own background grey, so a folder the layout stopped short of subdividing read as nothing there at all — which made "show one folder" show an absence. Plates are now `#3b4a6b`, the one dark category colour and the only one that is not a file type, so a container is never mistaken for its contents. It does not move with the theme, which is why the seam between two plates is now a shade of the plate rather than the app background: that painted a white hairline through the map in the light theme. `--color-plate` and `--color-plate-grain` are gone, both sides in one commit — a `ctx.fillStyle = ""` left behind by a deleted variable is silently ignored rather than an error. Single click on a plate opens it in place, through `layout_with_force`. Everything else clicks as it did: a subdivided folder still zooms, a file still only selects. The double click that used to do nothing is now what zooms a plate, so the click has to wait out the double-click window — and only that click. A click on a plate is deferred behind a 300ms timer; a click on anything else acts on the spot. And when opening in place would show nothing — the biggest thing inside still under twice the minimum block — the folder is zoomed instead, with the override kept so it arrives open rather than as one big plate. That is the line the layout itself draws between "worth a level" and "not", so it is the same judgement, applied to an ask that outranks it. --- src-tauri/src/scan.rs | 11 +- ui/src/App.tsx | 4 +- ui/src/components/Treemap.tsx | 192 ++++++++++++++++++++++++++++++---- ui/src/index.css | 4 - ui/src/lib/api.ts | 2 + ui/src/lib/palette.ts | 24 ++++- 6 files changed, 205 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 41888b5..2f38085 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -360,6 +360,7 @@ pub fn get_treemap( height: f32, hide_system: bool, filter: Option, + force_open: Option, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -383,9 +384,15 @@ pub fn get_treemap( w: width, h: height, }; + // `force_open` names a directory the user asked to see inside of. One that + // is not in this subtree, or no longer names a live directory, opens + // nothing rather than erroring: a stale id is what an accordion looks like + // from the other side of a delete, and it should close by itself. let rects = match overlay_for(&session, tree, filter.as_deref(), hide_system) { - Some(o) => treemap::layout_with_filter(tree, root_id, viewport, &opts, &o.bytes), - None => treemap::layout(tree, root_id, viewport, &opts), + Some(o) => { + treemap::layout_with_force(tree, root_id, viewport, &opts, Some(&o.bytes), force_open) + } + None => treemap::layout_with_force(tree, root_id, viewport, &opts, None, force_open), }; // The label's text rides along with the geometry: a rect the user can see // is a rect they can read, and asking per node would be one round trip per diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6e6b38f..35edfe8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -123,8 +123,10 @@ export default function App() { const handleTreemapSelect = useCallback( (rect: TreemapRect) => { + // Selecting only. Zooming is the treemap's own decision now — a click on + // a folder it drew as a plate opens that folder instead, and comes back + // through `onNavigate` if it turns out to want the whole view. select(rect.id); - if (rect.isDir) setViewRootId(rect.id); if (generation === 0) return; api .getAncestors(generation, rect.id) diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 75889be..396141a 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -25,11 +25,25 @@ import { } from "../lib/api"; import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; -import { PALETTE, canvasColors, textOn } from "../lib/palette"; +import { + FOLDER_GRAIN, + FOLDER_PLATE, + FOLDER_SEAM, + PALETTE, + canvasColors, + textOn, +} from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; +/** + * How long a click on a plate waits to find out whether it was half of a + * double click. Chromium reports the second click at the platform's interval, + * which defaults to 500ms — too long to hold every open for, so this is the + * compromise: a slow double click opens first and then zooms. + */ +const DOUBLE_CLICK_MS = 300; // Grain tile for directory plates. By the layout's contract, culled children // still consume their share of space, so every bare plate pixel is real bytes @@ -127,9 +141,9 @@ function drawLabels( if ((rects[i + 1]?.depth ?? 0) > r.depth) continue; const s = snap(r, dpr, 1); if (s.w < minW || s.h < LABEL_MIN_H_PX * dpr) continue; - ctx.fillStyle = r.isDir - ? textOn(canvasColors().plate) - : textOn(PALETTE[r.category] ?? PALETTE[10]); + ctx.fillStyle = textOn( + r.isDir ? FOLDER_PLATE : (PALETTE[r.category] ?? PALETTE[10]), + ); const name = fitText(ctx, r.name, s.w - 2 * pad); if (!name) continue; const cx = s.x + s.w / 2; @@ -175,6 +189,42 @@ function snap(r: TreemapRect, dpr: number, gap: number): Snapped { return { x: x0, y: y0, w: x1 - x0 - gap, h: y1 - y0 - gap }; } +/** Rects with no children of their own — the ones a click can open. */ +function solidPlates(rects: TreemapRect[]): Set { + const plates = new Set(); + for (let i = 0; i < rects.length; i++) { + const r = rects[i]; + // Rects come out parents before children, so the next entry is a child + // exactly when it is deeper. + if (r.isDir && !((rects[i + 1]?.depth ?? 0) > r.depth)) plates.add(r.id); + } + return plates; +} + +/** + * How big the biggest thing inside an opened folder has to come out to be + * worth showing in place. This is the layout's own legibility line — twice the + * minimum block — not a new number: below it the layout would have refused to + * subdivide for the same reason, and drawing it anyway is the mosaic of specks + * this whole corner of the map exists to avoid. + */ +const AUTO_ZOOM_PX = 12; + +/** + * Did opening `id` reveal anything worth looking at? + * + * `rects[i + 1]` is the folder's biggest child: rows are laid out in + * descending size and the list is pre-order, so the first child emitted is the + * largest. No child at all is not "too small" — there is nothing to zoom to. + */ +function revealedTooSmall(rects: TreemapRect[], id: number): boolean { + const at = rects.findIndex((r) => r.id === id); + if (at < 0) return false; + const first = rects[at + 1]; + if (!first || first.depth <= rects[at].depth) return false; + return Math.min(first.w, first.h) < AUTO_ZOOM_PX; +} + interface TooltipData { name: string; size: string; @@ -245,6 +295,15 @@ export function Treemap({ const tooltipSeqRef = useRef(0); const tooltipTimerRef = useRef(0); const lastMouseRef = useRef({ x: 0, y: 0 }); + /** Ids of the plates the layout did not subdivide — the ones a click can + * open. Rebuilt with each response, same one-pass test `drawLabels` uses. */ + const platesRef = useRef>(new Set()); + /** The one directory the user opened by hand. Mirrors `forceOpenId`. */ + const forceOpenRef = useRef(null); + /** A click waiting out the double-click window, and its sequence number. */ + const pendingRef = useRef<{ id: number; collapse: boolean } | null>(null); + const pendingSeqRef = useRef(0); + const pendingTimerRef = useRef(0); const generationRef = useRef(generation); generationRef.current = generation; @@ -262,6 +321,8 @@ export function Treemap({ const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); const [hasRects, setHasRects] = useState(false); + const [forceOpenId, setForceOpenId] = useState(null); + forceOpenRef.current = forceOpenId; const drawOverlay = useCallback(() => { const overlay = overlayRef.current; @@ -337,7 +398,7 @@ export function Treemap({ ctx.fillRect(0, 0, off.width, off.height); ctx.fillStyle = ctx.createPattern( - getGrainTile(theme.plate, theme.plateGrain), + getGrainTile(FOLDER_PLATE, FOLDER_GRAIN), "repeat", )!; ctx.beginPath(); @@ -351,12 +412,12 @@ export function Treemap({ // A plate with no children is the final answer for the space it covers, // and it needs a seam — the grain under it is one continuous pattern, so // thirty folders of equal size would otherwise draw as a single flat - // field with nothing to say where one ends. Painting the seam means - // painting the background over it: the parent's plate is underneath, and - // a gap alone would just show more of the same texture. Plates that *do* - // have children are only backing for them and get nothing, or every - // subdivided directory would ring itself in a hairline frame. - ctx.fillStyle = theme.background; + // field with nothing to say where one ends. The seam has to be painted: + // the parent's plate is underneath, and a gap alone would just show more + // of the same texture. Plates that *do* have children are only backing + // for them and get nothing, or every subdivided directory would ring + // itself in a hairline frame. + ctx.fillStyle = FOLDER_SEAM; ctx.beginPath(); for (let i = 0; i < rects.length; i++) { const r = rects[i]; @@ -443,19 +504,67 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, + forceOpenRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; byIdRef.current = new Map(rects.map((r) => [r.id, r])); + platesRef.current = solidPlates(rects); hitFrozenRef.current = false; setHasRects(rects.length > 0); bake(); if (crumbsRootRef.current !== forRoot) refreshCrumbs(); + // The folder the user opened is open — but if the biggest thing in it + // still came out too small to read, opening it in place achieved + // nothing. Zoom it instead: at that point the only useful thing to do + // with it is fill the view and look inside properly. + const opened = forceOpenRef.current; + if ( + opened !== null && + opened !== forRoot && + revealedTooSmall(rects, opened) + ) { + // Through the prop, not `drillTo`: that is declared below this one and + // calls it back, so depending on it here would be a cycle. App's + // handler is a stable useCallback either way. + onNavigate(opened); + } } catch (e) { reportUnlessStale("loading treemap", e); if (seq === fetchSeqRef.current) hitFrozenRef.current = false; } - }, [bake, refreshCrumbs]); + }, [bake, refreshCrumbs, onNavigate]); + + /** + * A click on a plate is ambiguous until the double-click window closes: it + * could be "open this" or the first half of "zoom into this". So that one + * action waits, and only that one — everything else acts on the spot, and + * `handleDoubleClick` cancels what is waiting. `pendingRef` doubles as the + * flag: it is non-null exactly while a click has yet to be acted on. + */ + const arm = useCallback((id: number, collapse: boolean) => { + window.clearTimeout(pendingTimerRef.current); + const seq = ++pendingSeqRef.current; + const generation = generationRef.current; + const forRoot = rootIdRef.current; + pendingRef.current = { id, collapse }; + pendingTimerRef.current = window.setTimeout(() => { + if (seq !== pendingSeqRef.current) return; + // A scan tick re-fetching underneath us is not a reason to drop the + // click — the id is what the action applies to, and those are stable + // within a generation. Leaving the tree or starting a new scan is. + if (generation !== generationRef.current) return; + if (forRoot !== rootIdRef.current) return; + if (!byIdRef.current.has(id)) return; + setForceOpenId(collapse ? null : id); + }, DOUBLE_CLICK_MS); + }, []); + + const cancelPending = useCallback(() => { + pendingRef.current = null; + window.clearTimeout(pendingTimerRef.current); + pendingSeqRef.current++; + }, []); const drillTo = useCallback( (id: number) => { @@ -465,6 +574,12 @@ export function Treemap({ hitFrozenRef.current = true; setTooltip(null); mouseOverRef.current = null; + cancelPending(); + // The open folder follows the view only into itself. Zooming into it + // should show what is inside — without this it would arrive as the root + // and be folded back into one plate by the same rule that folded it + // here. Anywhere else, the accordion is about a view you have left. + setForceOpenId((open) => (open === id ? open : null)); drawOverlay(); // clear rings: they describe the view being left const base = baseRef.current; @@ -500,7 +615,7 @@ export function Treemap({ void fetchLayout(); }, - [blit, drawOverlay, fetchLayout], + [blit, drawOverlay, fetchLayout, cancelPending], ); useEffect(() => { @@ -511,6 +626,7 @@ export function Treemap({ rootIdRef.current = 0; rectsRef.current = []; byIdRef.current = new Map(); + platesRef.current = new Set(); setHasRects(false); setCrumbs([]); setTooltip(null); @@ -519,10 +635,16 @@ export function Treemap({ crumbsRootRef.current = null; crumbIdsRef.current = new Set(); offscreenRef.current = null; + // Ids belong to a tree, and this is a different one. Holding an open + // folder across scans would open whatever now happens to have that id. + cancelPending(); + setForceOpenId(null); const base = baseRef.current; if (base) base.getContext("2d")!.clearRect(0, 0, base.width, base.height); if (generation !== 0) void fetchLayout(); - }, [generation, fetchLayout]); + }, [generation, fetchLayout, cancelPending]); + + useEffect(() => cancelPending, [cancelPending]); useEffect(() => { drawOverlay(); @@ -541,6 +663,14 @@ export function Treemap({ void fetchLayout(); }, [hideSystem, filter, fetchLayout]); + useEffect(() => { + // The open folder is a layout input, not a paint-time one: only the + // backend can decide what a directory hides. Read through the ref inside + // `fetchLayout`, keyed on the value here — the callback's identity has to + // stay put, or this cascades into the size effect and double-fetches. + void fetchLayout(); + }, [forceOpenId, fetchLayout]); + useEffect(() => { // Labels are painted over the baked layout, never into it: toggling them // is a repaint of the same rects, not a new query. @@ -702,9 +832,26 @@ export function Treemap({ (e: React.MouseEvent) => { const bounds = containerRef.current!.getBoundingClientRect(); const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); - if (hit) onSelect(hit); + if (!hit) return; + // The folder that is open is the one case a click closes: it has + // children now, so nothing below would offer to. + if (hit.id === forceOpenRef.current) { + arm(hit.id, true); + return; + } + onSelect(hit); + // A plate has nothing to zoom into without opening first, so the click + // opens it — unless it turns out to be too small to be worth showing in + // place, which `fetchLayout` answers by zooming instead. + if (hit.isDir && platesRef.current.has(hit.id)) { + arm(hit.id, false); + return; + } + // A directory the layout did choose to subdivide: single click zooms, as + // it always has. + if (hit.isDir) onNavigate(hit.id); }, - [hitTest, onSelect], + [hitTest, onSelect, onNavigate, arm], ); const handleContextMenu = useCallback( @@ -725,12 +872,17 @@ export function Treemap({ const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - if (zoomRafRef.current !== 0) return; const bounds = containerRef.current!.getBoundingClientRect(); - const region = regionAt(e.clientX - bounds.left, e.clientY - bounds.top); - if (region) onNavigate(region.id); + const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); + if (!hit?.isDir || hit.id === rootIdRef.current) return; + // Nothing waiting means the first click already acted — it zoomed, or it + // selected a file — and a second zoom on top of that is not what anyone + // asked for. + if (pendingRef.current === null) return; + cancelPending(); + onNavigate(hit.id); }, - [regionAt, onNavigate], + [hitTest, onNavigate, cancelPending], ); const zoomOut = useCallback(() => { diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..06ff098 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -9,8 +9,6 @@ --color-raised: #27272a; /* selection, hover on panel surfaces */ --color-edge: #27272a; --color-edge-strong: #3f3f46; - --color-plate: #30323a; /* treemap directory plate */ - --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -44,8 +42,6 @@ --color-raised: #e4e4e7; --color-edge: #e4e4e7; --color-edge-strong: #d4d4d8; - --color-plate: #dcdce0; - --color-plate-grain: #bfc0c8; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index ac41948..a38cd1d 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -162,6 +162,7 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, + forceOpen: number | null, ) => invoke("get_treemap", { generation, @@ -170,6 +171,7 @@ export const api = { height, hideSystem, filter, + forceOpen, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 53eb86a..e1372f9 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -1,7 +1,23 @@ -// Indexed by mathom-core's `Category as u8`. Index 0 (directories) is -// theme-dependent — canvas code paints plates with canvasColors().plate. +/** + * The colour every directory plate is painted in. The one category colour that + * is not a file type, and the only dark one: a folder is a container, and a + * plate that reads as background made an unopened folder look like nothing was + * there at all. Deliberately not any file colour's hue, so a plate is never + * mistaken for the thing inside it. + */ +export const FOLDER_PLATE = "#3b4a6b"; +/** The plate's texture dot — a lighter tint of the same slate. */ +export const FOLDER_GRAIN = "#55618c"; +/** + * The 1px seam between two plates. A shade of the plate rather than the app + * background: the folder colour does not move with the theme, so a background + * seam would draw a white hairline through the map in the light theme. + */ +export const FOLDER_SEAM = "#2b3752"; + +// Indexed by mathom-core's `Category as u8`. export const PALETTE: readonly string[] = [ - "#30323a", // 0 directory plate (dark fallback; see canvasColors) + FOLDER_PLATE, // 0 directory "#a855f7", // 1 video "#22c55e", // 2 audio "#eab308", // 3 image @@ -20,8 +36,6 @@ export function canvasColors() { const v = (name: string) => style.getPropertyValue(name).trim(); return { background: v("--color-app"), - plate: v("--color-plate"), - plateGrain: v("--color-plate-grain"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; From 302c0269b781585d2aed6f2f4a2833417fc3c0d5 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:26:40 +0800 Subject: [PATCH 08/13] Folders are drawn like files, and the map keeps its own surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I read the ask as "give folders a colour" and stopped there, so a folder kept the dot texture files never had. That made a plate read as a surface full of specks rather than as a block — the opposite of the point, which was for a folder to look like a file with a different colour and nothing else. So: one flat fill, the same 1% gap, the same sheen, same everything. `FOLDER_PLATE` and the highlight sprite are the whole of it; the grain tile, its cache and the seam pass are gone, since a 1px gap against the backdrop does what the seam was invented to do. A directory that *did* subdivide is no longer painted at all — it is only the backdrop for what it holds, and painting it put a ring of folder colour around every level of the map. What shows instead is the map's own surface, which is the neutral grey that was there before and that I should not have taken away: it is not the window background, it is the map's, and it follows the theme in both directions. --- ui/src/components/Treemap.tsx | 79 ++++++++--------------------------- ui/src/index.css | 2 + ui/src/lib/palette.ts | 23 ++++------ 3 files changed, 28 insertions(+), 76 deletions(-) diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 396141a..2055ccc 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -25,14 +25,7 @@ import { } from "../lib/api"; import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; -import { - FOLDER_GRAIN, - FOLDER_PLATE, - FOLDER_SEAM, - PALETTE, - canvasColors, - textOn, -} from "../lib/palette"; +import { FOLDER_PLATE, PALETTE, canvasColors, textOn } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; @@ -45,30 +38,6 @@ const TOOLTIP_DELAY_MS = 120; */ const DOUBLE_CLICK_MS = 300; -// Grain tile for directory plates. By the layout's contract, culled children -// still consume their share of space, so every bare plate pixel is real bytes -// too small to draw — the grain makes that read as "many small files" instead -// of dead space. Drawn tiles paint over it, so it shows only where content -// was culled. -const GRAIN_PITCH = 4; - -let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; - -function getGrainTile(plate: string, grain: string): HTMLCanvasElement { - const key = `${plate}|${grain}`; - if (grainTile?.key === key) return grainTile.canvas; - const c = document.createElement("canvas"); - c.width = GRAIN_PITCH; - c.height = GRAIN_PITCH; - const ctx = c.getContext("2d")!; - ctx.fillStyle = plate; - ctx.fillRect(0, 0, GRAIN_PITCH, GRAIN_PITCH); - ctx.fillStyle = grain; - ctx.fillRect(1, 1, 1, 1); - grainTile = { key, canvas: c }; - return c; -} - /** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; const LABEL_SIZE_PX = 11; @@ -394,40 +363,26 @@ export function Treemap({ const rects = rectsRef.current; const theme = canvasColors(); - ctx.fillStyle = theme.background; + // The map's own surface, behind every block: a folder that subdivided is + // only the backdrop for what it contains, so it is not painted at all — + // this is what shows through where it would have been, and through the + // gaps between blocks. Its own colour, not the window's: the map is a + // surface with blocks on it, and it keeps that colour in both themes. + ctx.fillStyle = theme.plate; ctx.fillRect(0, 0, off.width, off.height); - ctx.fillStyle = ctx.createPattern( - getGrainTile(FOLDER_PLATE, FOLDER_GRAIN), - "repeat", - )!; + // Folders are drawn exactly like files — one flat colour, a 1px gap, the + // same sheen over the top — and differ only in the colour. Anything more + // than that (a texture, a seam, a reserved strip) makes a folder read as a + // surface rather than as a block, which is what a plate full of small + // files should look like. + const plates = solidPlates(rects); + ctx.fillStyle = FOLDER_PLATE; ctx.beginPath(); for (const r of rects) { - if (!r.isDir) continue; - const s = snap(r, dpr, 0); - if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); - } - ctx.fill(); - - // A plate with no children is the final answer for the space it covers, - // and it needs a seam — the grain under it is one continuous pattern, so - // thirty folders of equal size would otherwise draw as a single flat - // field with nothing to say where one ends. The seam has to be painted: - // the parent's plate is underneath, and a gap alone would just show more - // of the same texture. Plates that *do* have children are only backing - // for them and get nothing, or every subdivided directory would ring - // itself in a hairline frame. - ctx.fillStyle = FOLDER_SEAM; - ctx.beginPath(); - for (let i = 0; i < rects.length; i++) { - const r = rects[i]; - // Rects come out parents before children, so the next entry is a child - // exactly when it is deeper — the test for "subdivided". - if (!r.isDir || (rects[i + 1]?.depth ?? 0) > r.depth) continue; + if (!plates.has(r.id)) continue; const s = snap(r, dpr, 1); - if (s.w <= 0 || s.h <= 0) continue; - ctx.rect(s.x + s.w, s.y, dpr, s.h + dpr); - ctx.rect(s.x, s.y + s.h, s.w, dpr); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } ctx.fill(); @@ -449,7 +404,7 @@ export function Treemap({ const sprite = getHighlightSprite(); for (const r of rects) { - if (r.isDir) continue; + if (r.isDir && !plates.has(r.id)) continue; const s = snap(r, dpr, 1); if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } diff --git a/ui/src/index.css b/ui/src/index.css index 06ff098..6947841 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -9,6 +9,7 @@ --color-raised: #27272a; /* selection, hover on panel surfaces */ --color-edge: #27272a; --color-edge-strong: #3f3f46; + --color-plate: #30323a; /* the map's own surface, behind every block */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -42,6 +43,7 @@ --color-raised: #e4e4e7; --color-edge: #e4e4e7; --color-edge-strong: #d4d4d8; + --color-plate: #dcdce0; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index e1372f9..d2057b3 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -1,19 +1,13 @@ /** - * The colour every directory plate is painted in. The one category colour that - * is not a file type, and the only dark one: a folder is a container, and a - * plate that reads as background made an unopened folder look like nothing was - * there at all. Deliberately not any file colour's hue, so a plate is never - * mistaken for the thing inside it. + * The colour of a folder block. The one category colour that is not a file + * type, and the only dark one: a folder is a container, and a plate painted in + * the app's own background grey made an unopened folder look like nothing was + * there at all. Deliberately not any file colour's hue, so a container is never + * mistaken for what it holds. + * + * It is a colour and nothing else — folders are drawn exactly like files. */ export const FOLDER_PLATE = "#3b4a6b"; -/** The plate's texture dot — a lighter tint of the same slate. */ -export const FOLDER_GRAIN = "#55618c"; -/** - * The 1px seam between two plates. A shade of the plate rather than the app - * background: the folder colour does not move with the theme, so a background - * seam would draw a white hairline through the map in the light theme. - */ -export const FOLDER_SEAM = "#2b3752"; // Indexed by mathom-core's `Category as u8`. export const PALETTE: readonly string[] = [ @@ -35,7 +29,8 @@ export function canvasColors() { const style = getComputedStyle(document.documentElement); const v = (name: string) => style.getPropertyValue(name).trim(); return { - background: v("--color-app"), + /** The map's surface, behind every block — not the window background. */ + plate: v("--color-plate"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; From 5f5e2615b89b8b4b003643a456cf946824aa0a4c Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:09:56 +0800 Subject: [PATCH 09/13] Seams are one width, and how much a folder shows depends on its room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the map got wrong, both of them about a number that was not a number. **Blocks looked chewed.** Every visible seam was supposed to be the renderer's 1px, but the layout inset each directory's children by another 1px, so the two stacked: two siblings showed a 1px seam and two blocks from different parents showed 3px. Measured along one row of the real window: runs of 1, 2 and 3 pixels. `padding_px` is gone, so a child's frame is its parent's frame whole and every seam is the same 1px at any depth. Re-measured: every run is 1. **A fixed child limit cannot be right.** Two hundred children in one folder and two hundred in another are the same number and not the same picture — the first might be a wall of specks and the second a clean grid. What matters is how much room the folder has, so the limit is now a capacity derived from its own area: each child gets at least `COMFORT_PX` squared to sit in, or the folder folds. At 32px that is 100 children in a 320×320 block and 39 in a 200×200 one; above four, so the count alone never folds a directory holding a handful. The tests that pinned the padding are replaced by one that pins the absence of it — every frame in a chain is the frame the root was handed — and by one where the same directory folds in a small window and opens in a large one, which a fixed limit cannot do. --- crates/core/src/treemap.rs | 222 ++++++++++++++++++++----------------- src-tauri/src/scan.rs | 1 - 2 files changed, 121 insertions(+), 102 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index d79b90e..b66da3d 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -11,10 +11,14 @@ //! so dropping leaves no hole either. //! //! How deep that goes is decided by the pixels, not by a number: a directory -//! subdivides only while its interior can hold a legible child *and* its -//! biggest child would land well clear of the floor. Without the second half, -//! folders nest into folders of specks — each level spending padding to cut -//! the next one finer — and the map ends up a mesh nothing can be read from. +//! subdivides only while its interior can hold a legible child, its biggest +//! child would land well clear of the floor, and it has room to give each +//! child a block worth looking at. Without the last two, folders nest into +//! folders of specks and the map ends up a mesh nothing can be read from. +//! +//! A directory's children get its frame whole — no inset, at any depth. What +//! separates two blocks is the renderer's 1px and nothing else, so the seams +//! are the same width however the nesting falls. use crate::category::categorize; use crate::entry::EntryFlags; @@ -36,7 +40,6 @@ pub struct TreemapOptions { /// smallest first, and the rest spread into their space. Zero lays the /// children out strictly proportionally. pub min_side_px: f32, - pub padding_px: f32, pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, @@ -53,16 +56,21 @@ pub struct TreemapOptions { /// child would still be a speck says more as one plate. const DETAIL_FACTOR: f64 = 2.0; -/// How many direct children a directory may have before the layout folds it -/// into one plate by default. +/// The smallest block a directory is willing to *average* when it subdivides. +/// +/// The legibility gate asks whether the biggest child is worth a block. This +/// one asks how thinly the whole set is spread — which is what a folder of +/// three hundred small files is — and the answer depends on how much room the +/// folder has, not on a count: a small folder showing twenty things and a +/// large one showing thirty are the same number and not the same picture. So +/// the limit is a capacity derived from the folder's own area, and a directory +/// that cannot give each child this much folds into one plate instead. /// /// Counting visible children, not descendants: what the layout has to place at /// this level is its own children, and a folder of three folders holding a -/// thousand files each is three blocks here, not a thousand. Well past this -/// the blocks stop being things you can point at — even at 6px, a plate this -/// subdivided is a texture — and a plate you can click is the better answer. -/// Clicking is the ask that overrides it. -const MAX_DIRECT_CHILDREN: usize = 200; +/// thousand files each is three blocks here, not a thousand. Clicking a plate +/// is the ask that overrides all of this. +const COMFORT_PX: f64 = 32.0; #[derive(Clone, Copy, Debug, PartialEq)] pub struct TreemapRect { @@ -218,15 +226,6 @@ impl Frame { fn area(&self) -> f64 { self.w * self.h } - - fn inset(&self, pad: f64) -> Frame { - Frame { - x: self.x + pad, - y: self.y + pad, - w: self.w - 2.0 * pad, - h: self.h - 2.0 * pad, - } - } } // `open` is threaded down the recursion rather than carried in a context @@ -270,20 +269,23 @@ fn emit( // still. The floor, the dropping and the re-normalizing in `lay_children` // are functions of this directory alone for the same reason: never of // `max_depth`, and never of anything global. - let inner = frame.inset(opts.padding_px as f64); - if inner.w <= 0.0 || inner.h <= 0.0 { + // + // Children get the frame *whole* — the seam between two blocks is the + // renderer's 1px and only that, whatever depth either block sits at. An + // inset here would stack with it, so a block two levels down from another + // would sit behind a 3px seam and the map would look chewed. + if frame.w <= 0.0 || frame.h <= 0.0 { return; } - // Adaptive depth, second half: going a level deeper costs padding off - // every side of every grandchild, so a branch runs out of room on its own - // and stops. (`lay_children` applies the first half — whether the biggest - // child here is worth a level at all.) `max_depth` is only an upper bound - // on top of that, and it is the one bound forcing does *not* lift: it is - // the only brake on recursion, and the tree's depth is unbounded. - if inner.w < opts.min_side_px as f64 || inner.h < opts.min_side_px as f64 { + // Adaptive depth, second half: a body too thin to hold a legible block + // stops here. (`lay_children` applies the first half — whether what is + // inside is worth a level at all.) `max_depth` is only an upper bound on + // top of those, and it is the one bound forcing does *not* lift: it is the + // only brake on recursion, and the tree's depth is unbounded. + if frame.w < opts.min_side_px as f64 || frame.h < opts.min_side_px as f64 { return; } - lay_children(tree, id, inner, depth + 1, opts, visible, open, out); + lay_children(tree, id, frame, depth + 1, opts, visible, open, out); } #[allow(clippy::too_many_arguments)] @@ -321,11 +323,17 @@ fn lay_children( // exact-geometry tests ask for and what makes those reasons moot. Neither // applies to a directory the user opened by hand; that ask outranks them. if !forced && min_side > 0.0 { + // How many children this much room can hold at a size worth looking + // at, from the frame's own area — so the same directory opens up when + // it has room and folds when it does not. At least a few, so the count + // alone never folds a directory holding only a handful: whether those + // are readable is the legibility gate's question, below. + let capacity = ((frame.area() / (COMFORT_PX * COMFORT_PX)) as usize).max(4); // Counted from the visible children rather than `tree.children`, // because under a filter or hide_system what matters is how many // blocks would actually be drawn. Ahead of the sort, so a directory // this is meant to keep off the map never pays to order it. - if items.len() > MAX_DIRECT_CHILDREN { + if items.len() > capacity { return; } // The biggest thing in here would come out a speck: the subdivision @@ -554,7 +562,6 @@ mod tests { fn no_padding() -> TreemapOptions { TreemapOptions { min_side_px: 0.0, - padding_px: 0.0, max_depth: 32, hide_system: false, } @@ -599,7 +606,7 @@ mod tests { let mut b = EntryBatch::default(); b.push("root", entry(0, 0, DIR, 0)); b.push("media", entry(1, 0, DIR, 0)); - b.push("movie", entry(2, 1, FILE, 400_000)); + b.push("movie", entry(2, 1, FILE, 40_000)); b.push("clips", entry(3, 1, DIR, 0)); b.push("readme", entry(4, 0, FILE, 500)); b.push("src", entry(5, 0, DIR, 0)); @@ -608,7 +615,7 @@ mod tests { b.push("clip", entry(next, 3, FILE, 1_000)); next += 1; } - for _ in 0..150 { + for _ in 0..60 { b.push("dud", entry(next, 3, FILE, 1)); next += 1; } @@ -625,22 +632,20 @@ mod tests { builder.finish() } - /// The area a directory hands to its children: its own rect inset by the - /// padding, exactly as `emit` computes it. - fn body_area(dir: &TreemapRect, opts: &TreemapOptions) -> f64 { - let pad = opts.padding_px as f64; - let w = (dir.w as f64 - 2.0 * pad).max(0.0); - let h = (dir.h as f64 - 2.0 * pad).max(0.0); - w * h + /// The area a directory hands to its children. With no padding anywhere in + /// the layout that is simply its own rect — and the point of the tests + /// below is that it stays that way: a block's frame is its parent's frame, + /// so nothing but the renderer's 1px sits between two blocks. + fn body_area(dir: &TreemapRect) -> f64 { + dir.w as f64 * dir.h as f64 } /// Total area `dir`'s direct children were laid into. Containment is what /// identifies them — it has to be, since two directories at the same depth /// are both `depth + 1` away from the same root. - fn children_area(rects: &[TreemapRect], dir: &TreemapRect, opts: &TreemapOptions) -> f64 { - let pad = opts.padding_px; - let (x0, y0) = (dir.x + pad, dir.y + pad); - let (x1, y1) = (dir.x + dir.w - pad, dir.y + dir.h - pad); + fn children_area(rects: &[TreemapRect], dir: &TreemapRect) -> f64 { + let (x0, y0) = (dir.x, dir.y); + let (x1, y1) = (dir.x + dir.w, dir.y + dir.h); rects .iter() .filter(|r| { @@ -761,32 +766,6 @@ mod tests { assert!((area(&dir) - 10_000.0).abs() < 1.0); } - #[test] - fn padding_insets_children_inside_their_directory() { - let mut b = EntryBatch::default(); - b.push("root", entry(0, 0, DIR, 0)); - b.push("dir", entry(1, 0, DIR, 0)); - b.push("f", entry(2, 1, FILE, 100)); - let mut builder = TreeBuilder::new(); - builder.add_batch(&b); - let tree = builder.finish(); - - let opts = TreemapOptions { - min_side_px: 0.0, - padding_px: 2.0, - max_depth: 32, - hide_system: false, - }; - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); - - let dir = rect_of(&rects, 1); - let f = rect_of(&rects, 2); - assert!((f.x - (dir.x + 2.0)).abs() < 0.01); - assert!((f.y - (dir.y + 2.0)).abs() < 0.01); - assert!((f.w - (dir.w - 4.0)).abs() < 0.01); - assert!((f.h - (dir.h - 4.0)).abs() < 0.01); - } - /// 1,000,000 vs 1 in 100×100: the small file's share is a 100×0.0001 /// sliver, and it cannot buy a 1×1 block either — so it goes. Note where /// its space ends up: with the others, not left blank where it was. @@ -833,13 +812,13 @@ mod tests { min_side_px: 6.0, ..no_padding() }; - let rects = layout(&tree, 0, Viewport { w: 200.0, h: 200.0 }, &opts); + let rects = layout(&tree, 0, Viewport { w: 300.0, h: 300.0 }, &opts); let root = rect_of(&rects, 0); let drawn = rects.iter().filter(|r| r.depth == 1).count(); assert!(drawn > 1, "the specks are drawn, not dropped: {drawn}"); assert!( - (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + (children_area(&rects, &root) - body_area(&root)).abs() < 0.01, "the children cover the block" ); for r in rects.iter().filter(|r| r.depth == 1) { @@ -862,7 +841,7 @@ mod tests { min_side_px: 6.0, ..no_padding() }; - let rects = layout(&tree, 0, Viewport { w: 200.0, h: 200.0 }, &opts); + let rects = layout(&tree, 0, Viewport { w: 300.0, h: 300.0 }, &opts); // Everything but `big` is a speck, and they are all the same speck. let areas: Vec = rects @@ -884,7 +863,6 @@ mod tests { let tree = speck_tree(1_000_000, 1); let opts = TreemapOptions { min_side_px: 6.0, - padding_px: 2.0, ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -892,7 +870,7 @@ mod tests { let root = rect_of(&rects, 0); assert!(rects.iter().all(|r| r.id != 2), "tiny cannot buy a block"); assert!( - (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + (children_area(&rects, &root) - body_area(&root)).abs() < 0.01, "the survivor takes the dropped child's share too" ); } @@ -921,11 +899,42 @@ mod tests { } let root = rect_of(&rects, 0); assert!( - (children_area(&rects, &root, &opts) - body_area(&root, &opts)).abs() < 0.01, + (children_area(&rects, &root) - body_area(&root)).abs() < 0.01, "dropping the tail does not open a hole" ); } + /// A block sits in the frame its parent was handed, exactly: the only thing + /// between two blocks is the 1px the renderer draws, whatever depth either + /// sits at. An inset here would stack with that one, so a block a level + /// down from its neighbour would stand behind a 3px seam next to that + /// neighbour's 1px — the map looked chewed, and this is the cause. + #[test] + fn a_block_sits_in_its_parents_frame_with_no_inset() { + // root / d1 / d2 / f, each the only child of the one above it, so every + // frame in the chain is the one the root was handed. + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("d2", entry(2, 1, DIR, 0)); + b.push("f", entry(3, 2, FILE, 100)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let rects = layout(&tree, 0, Viewport { w: 300.0, h: 200.0 }, &floored(6.0)); + + let root = rect_of(&rects, 0); + for id in [1u32, 2, 3] { + let r = rect_of(&rects, id); + assert_eq!( + (r.x, r.y, r.w, r.h), + (root.x, root.y, root.w, root.h), + "id {id} is inset from the frame its parent was handed" + ); + } + } + /// The whole rule as one invariant, under the shipped options: at every /// depth, every directory's children exactly cover the frame it hands /// down. A gap anywhere is the bug this exists to prevent. @@ -933,7 +942,6 @@ mod tests { fn production_options_leave_no_gaps_at_any_depth() { let opts = TreemapOptions { min_side_px: 6.0, - padding_px: 1.0, max_depth: 32, hide_system: false, }; @@ -947,13 +955,13 @@ mod tests { "and runs deep enough for the floor to bite" ); for dir in &dirs { - let kids = children_area(&rects, dir, &opts); + let kids = children_area(&rects, dir); // No children means the directory stayed a plate: its body could // not hold a legible block, so it never subdivided. if kids == 0.0 { continue; } - let body = body_area(dir, &opts); + let body = body_area(dir); assert!( (kids - body).abs() < 0.001 * body, "dir {} covers {kids} of its {body}px² body", @@ -1028,29 +1036,41 @@ mod tests { assert!(opts.max_depth > 1, "the cap never came into it"); } - /// The child limit, from both sides of it. The viewport is far too big for - /// the legibility gate to be what folds either one — 200 equal children in - /// 600×600 is 1800px² each — so only the count can be doing it. + /// How many children a directory will show is `area / COMFORT_PX²`. In a + /// 320×320 viewport that is 100, and each of those children comes out + /// around 31×31 — far over the legibility gate's 12px — so only the + /// capacity can be what folds the 101st. #[test] - fn more_direct_children_than_the_limit_stay_a_plate() { - let vp = Viewport { w: 600.0, h: 600.0 }; + fn more_children_than_the_room_allows_stay_a_plate() { + let vp = Viewport { w: 320.0, h: 320.0 }; let opts = floored(6.0); + let capacity = (320.0 * 320.0 / (COMFORT_PX * COMFORT_PX)) as u32; - let under = layout(&equal_files(MAX_DIRECT_CHILDREN as u32, 1), 0, vp, &opts); - assert!(under.len() > 1, "the limit itself is still drawn"); + let under = layout(&equal_files(capacity, 1), 0, vp, &opts); + assert!(under.len() > 1, "the capacity itself is still drawn"); - let over = layout( - &equal_files(MAX_DIRECT_CHILDREN as u32 + 1, 1), - 0, - vp, - &opts, - ); + let over = layout(&equal_files(capacity + 1, 1), 0, vp, &opts); assert_eq!(over.len(), 1, "one plate once it is over"); } - /// What the limit counts is children that would be *drawn*. Hiding the - /// system files takes this directory from 250 of them to 50, which is back - /// under the limit, so it opens again. + /// The rule is about room, not about a number: the same directory with the + /// same children folds in a small window and opens in a large one. A fixed + /// limit cannot do both, which is the whole reason this one changed. + #[test] + fn the_child_limit_scales_with_the_room_a_directory_has() { + let tree = equal_files(40, 1); + let opts = floored(6.0); + + let roomy = layout(&tree, 0, Viewport { w: 320.0, h: 320.0 }, &opts); + assert!(roomy.len() > 1, "100 will fit here"); + + let cramped = layout(&tree, 0, Viewport { w: 200.0, h: 200.0 }, &opts); + assert_eq!(cramped.len(), 1, "39 will not"); + } + + /// What the capacity counts is children that would be *drawn*. Hiding the + /// system files takes this directory from 250 of them to 50, back under + /// what 320×320 can show, so it opens again. #[test] fn the_child_limit_counts_visible_direct_children() { let mut b = EntryBatch::default(); @@ -1068,11 +1088,11 @@ mod tests { builder.add_batch(&b); let tree = builder.finish(); - let vp = Viewport { w: 600.0, h: 600.0 }; + let vp = Viewport { w: 320.0, h: 320.0 }; assert_eq!( layout(&tree, 0, vp, &floored(6.0)).len(), 1, - "250 of them is over the limit" + "250 of them is over the capacity" ); let hidden = TreemapOptions { hide_system: true, @@ -1187,7 +1207,8 @@ mod tests { builder.add_batch(&b); let tree = builder.finish(); - let vp = Viewport { w: 600.0, h: 600.0 }; + // 320×320 holds 100, so `outer`'s 201 children are well past it. + let vp = Viewport { w: 320.0, h: 320.0 }; let opts = floored(6.0); let base = layout(&tree, 0, vp, &opts); assert!(base.iter().all(|r| r.depth < 2), "outer is folded"); @@ -1389,7 +1410,6 @@ mod tests { let opts = TreemapOptions { min_side_px: 0.0, - padding_px: 0.0, max_depth: 1, hide_system: false, }; diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 2f38085..aecef74 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -373,7 +373,6 @@ pub fn get_treemap( } let opts = TreemapOptions { min_side_px: TREEMAP_MIN_SIDE_PX, - padding_px: 1.0, // The labels the UI can draw are a *view* of this geometry, never an // input to it: nothing here knows whether they are on, so switching // them cannot move a single block. From b3b1bdc6a1b17451a1b05742acf594acf8843ffc Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:34:45 +0800 Subject: [PATCH 10/13] The folder colour is a large area, so pick it as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#3b4a6b` was chosen as a colour without being looked at as a *surface*, and folders cover more of the map than anything else — a dark, low saturation blue over that much area reads as a muddy wash rather than as a palette. `#55618f` is the same family with the value and saturation lifted, which does two things at once: the map gets a tone instead of a murk, and a plate now sits clearly above the backdrop so a block reads as raised rather than as a slightly different patch of background. --- ui/src/lib/palette.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index d2057b3..139348a 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -1,13 +1,18 @@ /** * The colour of a folder block. The one category colour that is not a file - * type, and the only dark one: a folder is a container, and a plate painted in - * the app's own background grey made an unopened folder look like nothing was - * there at all. Deliberately not any file colour's hue, so a container is never - * mistaken for what it holds. + * type: a folder is a container, and a plate painted in the app's own + * background grey made an unopened folder look like nothing was there at all. + * Its hue is not any file colour's, so a container is never mistaken for what + * it holds. + * + * Picked for being a *large* area rather than a small one — folders cover more + * of the map than anything else, so this is the map's tone. It sits clearly + * above the backdrop in value, which is what makes a block read as raised + * instead of as a slightly different patch of background. * * It is a colour and nothing else — folders are drawn exactly like files. */ -export const FOLDER_PLATE = "#3b4a6b"; +export const FOLDER_PLATE = "#55618f"; // Indexed by mathom-core's `Category as u8`. export const PALETTE: readonly string[] = [ From a3b74886280391af0014d0245a689459c216017c Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:57:19 +0800 Subject: [PATCH 11/13] Depth is a setting again, with Auto beside the original levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adaptive rules are what the map should do by default, but they had displaced the fixed-depth layout entirely, and a fixed depth is a legitimate thing to want from a treemap. Depth now offers both: **Auto** is the adaptive layout, and **All**, **1**, **2**, **3** are the original levels, where the cap is the whole rule. That is not just a shorter Auto. `adaptive_depth: false` stands the legibility gates down — a cap takes every level it is allowed to and asks nothing about whether the level is worth taking — which is what makes the four levels a real alternative rather than four ways to say the same thing. It comes with the picture that implies: All goes back to a mesh of small blocks. That is the original behaviour, deliberately, and it is no longer the default. Two things follow from the gates standing down. Opening a plate by hand is a request to override the legibility rules, so it only means something under Auto; elsewhere a click on a folder goes back to zooming, which is the same "show me inside this" answered the other way. And an open folder is dropped when the depth changes, rather than left set to something the map is not doing. The caps nest — cap 1 is cap 2 above its line — so the control is a dial and not four unrelated pictures, and switching cannot make the blocks above the line jump. --- crates/core/src/treemap.rs | 100 +++++++++++++++++++++++++++-- src-tauri/src/scan.rs | 13 +++- ui/src/App.tsx | 4 ++ ui/src/components/SettingsMenu.tsx | 30 +++++++++ ui/src/components/Toolbar.tsx | 7 ++ ui/src/components/Treemap.tsx | 26 +++++++- ui/src/hooks/useScan.ts | 16 +++++ ui/src/lib/api.ts | 2 + ui/src/lib/prefs.ts | 55 ++++++++++++++++ 9 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 ui/src/lib/prefs.ts diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b66da3d..d51ed7f 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -40,7 +40,19 @@ pub struct TreemapOptions { /// smallest first, and the rest spread into their space. Zero lays the /// children out strictly proportionally. pub min_side_px: f32, + /// Hard ceiling on how deep the layout goes. The only brake on a tree + /// whose depth is unbounded, so it holds in both modes below — and it is + /// deliberately not lifted by `layout_with_force`. pub max_depth: u8, + /// Whether a level is taken because the pixels say it is worth taking, or + /// simply because `max_depth` has not been reached yet. + /// + /// True is what the map does by default: a directory stops when nothing + /// inside it would be legible, whatever the cap says. False is the + /// original behaviour — subdivide until the frame runs out — kept because + /// a fixed depth is a legitimate thing to want from a treemap, and it is + /// what the Depth setting's All/1/2/3 choose. + pub adaptive_depth: bool, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, } @@ -277,11 +289,10 @@ fn emit( if frame.w <= 0.0 || frame.h <= 0.0 { return; } - // Adaptive depth, second half: a body too thin to hold a legible block - // stops here. (`lay_children` applies the first half — whether what is - // inside is worth a level at all.) `max_depth` is only an upper bound on - // top of those, and it is the one bound forcing does *not* lift: it is the - // only brake on recursion, and the tree's depth is unbounded. + // A body too thin to hold a legible block stops here — geometry, not + // policy, so this holds in both depth modes. `max_depth` is the other + // bound, and it is the one forcing does *not* lift: it is the only brake + // on recursion, and the tree's depth is unbounded. if frame.w < opts.min_side_px as f64 || frame.h < opts.min_side_px as f64 { return; } @@ -322,7 +333,12 @@ fn lay_children( // guard: zero means "no floor, lay it out as it is", which is what the // exact-geometry tests ask for and what makes those reasons moot. Neither // applies to a directory the user opened by hand; that ask outranks them. - if !forced && min_side > 0.0 { + // + // `adaptive_depth` off means the caller asked for the *other* rule: take + // every level the cap allows and let the floor deal with whatever it + // turns up. That is the original behaviour, and the Depth setting's + // All/1/2/3 are how it is asked for. + if opts.adaptive_depth && !forced && min_side > 0.0 { // How many children this much room can hold at a size worth looking // at, from the frame's own area — so the same directory opens up when // it has room and folds when it does not. At least a few, so the count @@ -563,6 +579,7 @@ mod tests { TreemapOptions { min_side_px: 0.0, max_depth: 32, + adaptive_depth: true, hide_system: false, } } @@ -904,6 +921,75 @@ mod tests { ); } + /// The Depth setting's fixed levels: every level the cap allows, and the + /// legibility rules stand down. This is the original layout, kept as an + /// option because a fixed depth is a legitimate thing to want. + fn capped_depth(max_depth: u8) -> TreemapOptions { + TreemapOptions { + min_side_px: 6.0, + max_depth, + adaptive_depth: false, + hide_system: false, + } + } + + /// The whole point of having both: a directory Auto folds because nothing + /// inside it would be readable is opened by a cap, because a cap does not + /// ask that question. Leave the gates on and this fails. + #[test] + fn a_cap_expands_what_the_adaptive_rule_folds() { + let tree = legibility_gated_plate(200); + let vp = Viewport { w: 400.0, h: 400.0 }; + + let auto = layout(&tree, 0, vp, &floored(6.0)); + assert!(auto.iter().all(|r| r.depth < 2), "Auto folds it"); + + let capped = layout(&tree, 0, vp, &capped_depth(2)); + assert!( + capped.iter().any(|r| r.depth == 2), + "a cap of 2 opens it anyway" + ); + } + + /// A cap hides what is below it and moves nothing else — so switching the + /// Depth setting cannot make the blocks above the line jump. + #[test] + fn a_cap_hides_only_the_rects_it_stops_short_of() { + let tree = mixed_tree(); + let vp = Viewport { w: 900.0, h: 600.0 }; + + let shallow = layout(&tree, 0, vp, &capped_depth(1)); + let deep: Vec = layout(&tree, 0, vp, &capped_depth(3)) + .into_iter() + .filter(|r| r.depth <= 1) + .collect(); + + assert_eq!(shallow, deep); + assert!(shallow.iter().any(|r| r.depth == 1), "depth 1 is drawn"); + } + + /// And the caps nest in order, which is what makes the setting a dial + /// rather than four unrelated pictures: every level is the one above it, + /// plus a level. + #[test] + fn the_caps_nest_inside_one_another() { + let tree = mixed_tree(); + let vp = Viewport { w: 900.0, h: 600.0 }; + let at = |depth: u8| layout(&tree, 0, vp, &capped_depth(depth)); + + for (shallow, deep) in [(1u8, 2u8), (2, 3)] { + let filtered: Vec = at(deep) + .into_iter() + .filter(|r| r.depth <= shallow) + .collect(); + assert_eq!( + at(shallow), + filtered, + "cap {shallow} sits inside cap {deep}" + ); + } + } + /// A block sits in the frame its parent was handed, exactly: the only thing /// between two blocks is the 1px the renderer draws, whatever depth either /// sits at. An inset here would stack with that one, so a block a level @@ -943,6 +1029,7 @@ mod tests { let opts = TreemapOptions { min_side_px: 6.0, max_depth: 32, + adaptive_depth: true, hide_system: false, }; let tree = mixed_tree(); @@ -1411,6 +1498,7 @@ mod tests { let opts = TreemapOptions { min_side_px: 0.0, max_depth: 1, + adaptive_depth: true, hide_system: false, }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index aecef74..72d194a 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -341,6 +341,11 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } +/// The deepest the adaptive layout will go. Only a backstop — the legibility +/// rules stop it long before this — but the tree's depth is unbounded, so +/// something has to. Mirrored as `MAX_TREEMAP_DEPTH` in `ui/src/lib/prefs.ts`. +const MAX_TREEMAP_DEPTH: u8 = 24; + /// Legibility floor for a treemap rect, in CSS pixels. A child whose share of /// its directory falls under this is drawn at this size anyway — a directory /// has to tile edge to edge, or the small stuff reads as a hole rather than as @@ -361,6 +366,7 @@ pub fn get_treemap( hide_system: bool, filter: Option, force_open: Option, + max_depth: Option, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -376,7 +382,12 @@ pub fn get_treemap( // The labels the UI can draw are a *view* of this geometry, never an // input to it: nothing here knows whether they are on, so switching // them cannot move a single block. - max_depth: 24, + // + // `max_depth` is the Depth setting: absent means Auto, where the + // pixels decide how deep to go; present means the original fixed + // layout, which takes every level the cap allows and asks nothing. + max_depth: max_depth.unwrap_or(MAX_TREEMAP_DEPTH), + adaptive_depth: max_depth.is_none(), hide_system, }; let viewport = Viewport { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 35edfe8..c307ead 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -17,6 +17,7 @@ import { type TreemapRect, } from "./lib/api"; import { copyText } from "./lib/clipboard"; +import { layoutDepth } from "./lib/prefs"; import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors"; const TREE_PANE_MIN = 320; @@ -360,6 +361,7 @@ export default function App() { startError={scan.startError} hideSystem={scan.hideSystem} showLabels={scan.showLabels} + depth={scan.depth} filter={scan.filter} typePanelOpen={typePanelOpen} themePref={theme.pref} @@ -368,6 +370,7 @@ export default function App() { onCancel={scan.cancel} onToggleHideSystem={scan.toggleHideSystem} onToggleShowLabels={scan.toggleShowLabels} + onDepth={scan.setDepth} onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} @@ -427,6 +430,7 @@ export default function App() { hideSystem={scan.hideSystem} filter={scan.filter} labels={scan.showLabels} + maxDepth={layoutDepth(scan.depth)} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/SettingsMenu.tsx b/ui/src/components/SettingsMenu.tsx index ffbdc68..1338f50 100644 --- a/ui/src/components/SettingsMenu.tsx +++ b/ui/src/components/SettingsMenu.tsx @@ -5,6 +5,7 @@ import { type ThemePref, accentSwatch, } from "../lib/theme"; +import { DEPTH_OPTIONS, type DepthPref } from "../lib/prefs"; import { PaletteIcon } from "./icons"; const THEME_OPTIONS: { value: ThemePref; label: string }[] = [ @@ -16,10 +17,12 @@ const THEME_OPTIONS: { value: ThemePref; label: string }[] = [ interface SettingsMenuProps { hideSystem: boolean; showLabels: boolean; + depth: DepthPref; themePref: ThemePref; accent: AccentName; onToggleHideSystem: () => void; onToggleShowLabels: () => void; + onDepth: (depth: DepthPref) => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } @@ -27,10 +30,12 @@ interface SettingsMenuProps { export function SettingsMenu({ hideSystem, showLabels, + depth, themePref, accent, onToggleHideSystem, onToggleShowLabels, + onDepth, onThemePref, onAccent, }: SettingsMenuProps) { @@ -96,6 +101,31 @@ export function SettingsMenu({ /> Show names in treemap +
+ Depth +
+
+ {DEPTH_OPTIONS.map((opt) => ( + + ))} +
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index d61edf3..77eb195 100644 --- a/ui/src/components/Toolbar.tsx +++ b/ui/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import type { SearchHit } from "../lib/api"; +import type { DepthPref } from "../lib/prefs"; import type { AccentName, ThemePref } from "../lib/theme"; import { ExportMenu } from "./ExportMenu"; import { ScanMenu } from "./ScanMenu"; @@ -14,6 +15,7 @@ interface ToolbarProps { startError: string | null; hideSystem: boolean; showLabels: boolean; + depth: DepthPref; filter: string | null; typePanelOpen: boolean; themePref: ThemePref; @@ -22,6 +24,7 @@ interface ToolbarProps { onCancel: () => void; onToggleHideSystem: () => void; onToggleShowLabels: () => void; + onDepth: (depth: DepthPref) => void; onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; @@ -36,6 +39,7 @@ export function Toolbar({ startError, hideSystem, showLabels, + depth, filter, typePanelOpen, themePref, @@ -44,6 +48,7 @@ export function Toolbar({ onCancel, onToggleHideSystem, onToggleShowLabels, + onDepth, onToggleTypePanel, onSearchSelect, onApplyFilter, @@ -102,10 +107,12 @@ export function Toolbar({ diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 2055ccc..666f73b 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -214,6 +214,12 @@ export interface TreemapProps { filter: string | null; /** Draw each block's name and size inside it. Off by default. */ labels: boolean; + /** + * Depth setting: null is Auto, where the layout decides how deep to go. + * A number is a fixed cap, which is a *different* layout rule, not just a + * shorter one — see `TreemapOptions::adaptive_depth`. + */ + maxDepth: number | null; selected: number | null; hoveredId: number | null; onSelect: (rect: TreemapRect) => void; @@ -237,6 +243,7 @@ export function Treemap({ hideSystem, filter, labels, + maxDepth, selected, hoveredId, onSelect, @@ -286,6 +293,8 @@ export function Treemap({ filterRef.current = filter; const labelsRef = useRef(labels); labelsRef.current = labels; + const maxDepthRef = useRef(maxDepth); + maxDepthRef.current = maxDepth; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -460,6 +469,7 @@ export function Treemap({ hideSystemRef.current, filterRef.current, forceOpenRef.current, + maxDepthRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; @@ -626,6 +636,16 @@ export function Treemap({ void fetchLayout(); }, [forceOpenId, fetchLayout]); + useEffect(() => { + // Same for the depth: it picks which rule the layout follows. + // Opening a plate is a request to override the legibility rules, and a + // cap is a request that they not apply at all — so an open folder has + // nothing left to say under one. Drop it rather than leave the state + // meaning something the map is not doing. + setForceOpenId(null); + void fetchLayout(); + }, [maxDepth, fetchLayout]); + useEffect(() => { // Labels are painted over the baked layout, never into it: toggling them // is a repaint of the same rects, not a new query. @@ -798,7 +818,11 @@ export function Treemap({ // A plate has nothing to zoom into without opening first, so the click // opens it — unless it turns out to be too small to be worth showing in // place, which `fetchLayout` answers by zooming instead. - if (hit.isDir && platesRef.current.has(hit.id)) { + if ( + hit.isDir && + maxDepthRef.current === null && + platesRef.current.has(hit.id) + ) { arm(hit.id, false); return; } diff --git a/ui/src/hooks/useScan.ts b/ui/src/hooks/useScan.ts index 41cadf7..527f795 100644 --- a/ui/src/hooks/useScan.ts +++ b/ui/src/hooks/useScan.ts @@ -12,6 +12,11 @@ import { } from "../lib/api"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { reportUiError, reportUnlessStale } from "../lib/errors"; +import { + loadTreemapDepth, + saveTreemapDepth, + type DepthPref, +} from "../lib/prefs"; export interface Sort { key: SortKey; @@ -28,6 +33,9 @@ export interface ScanController { hideSystem: boolean; /** Draw each treemap block's name and size inside it. */ showLabels: boolean; + /** Depth setting: "auto" is the adaptive layout, the rest are fixed caps. */ + depth: DepthPref; + setDepth: (depth: DepthPref) => void; /** Active view filter (search grammar) or null; applies post-scan only. */ filter: string | null; startError: string | null; @@ -53,6 +61,12 @@ export function useScan(): ScanController { const [sort, setSort] = useState({ key: "size", desc: true }); const [hideSystem, setHideSystem] = useState(true); const [showLabels, setShowLabels] = useState(false); + const [depth, setDepthState] = useState(loadTreemapDepth); + + const setDepth = useCallback((next: DepthPref) => { + setDepthState(next); + saveTreemapDepth(next); + }, []); const [filter, setFilterState] = useState(null); const [startError, setStartError] = useState(null); @@ -281,6 +295,8 @@ export function useScan(): ScanController { sort, hideSystem, showLabels, + depth, + setDepth, filter, startError, scanning: snapshot?.state === "scanning", diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index a38cd1d..5388b36 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -163,6 +163,7 @@ export const api = { hideSystem: boolean, filter: string | null, forceOpen: number | null, + maxDepth: number | null, ) => invoke("get_treemap", { generation, @@ -172,6 +173,7 @@ export const api = { hideSystem, filter, forceOpen, + maxDepth, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/prefs.ts b/ui/src/lib/prefs.ts new file mode 100644 index 0000000..26740c0 --- /dev/null +++ b/ui/src/lib/prefs.ts @@ -0,0 +1,55 @@ +// View preferences that outlive a scan. Values the back end also has to agree +// on are mirrored here, same as palette.ts mirrors mathom-core's categories. + +/** Mirrors `MAX_TREEMAP_DEPTH` in src-tauri/src/scan.rs. */ +export const MAX_TREEMAP_DEPTH = 24; + +/** + * How deep the treemap expands, as the Depth setting offers it. + * + * `"auto"` is the map's own judgement — a directory opens when what is inside + * it would be legible, and folds when it would not. The rest are the original + * fixed levels: take every level the cap allows and ask nothing, which is a + * different picture and a legitimate thing to want. + */ +export type DepthPref = "auto" | "all" | 1 | 2 | 3; + +const DEPTH_KEY = "mathom:treemapDepth"; + +/** The fixed levels the settings menu offers. */ +const CAPS = [1, 2, 3] as const; + +export const DEPTH_OPTIONS: { value: DepthPref; label: string }[] = [ + { value: "auto", label: "Auto" }, + { value: "all", label: "All" }, + ...CAPS.map((depth) => ({ value: depth as DepthPref, label: String(depth) })), +]; + +/** + * Total, and its domain is exactly the options above: a stored value no button + * can show would leave the control with nothing selected, so anything else + * reads as Auto. + */ +export function parseDepth(raw: string | null): DepthPref { + if (raw === "all") return "all"; + const n = Number(raw); + return (CAPS as readonly number[]).includes(n) ? (n as DepthPref) : "auto"; +} + +/** + * The depth the back end should lay out to — `null` for Auto, which is the + * only value that leaves the legibility rules in charge. + */ +export function layoutDepth(pref: DepthPref): number | null { + if (pref === "auto") return null; + if (pref === "all") return MAX_TREEMAP_DEPTH; + return Math.min(Math.max(pref, 1), MAX_TREEMAP_DEPTH); +} + +export function loadTreemapDepth(): DepthPref { + return parseDepth(localStorage.getItem(DEPTH_KEY)); +} + +export function saveTreemapDepth(depth: DepthPref) { + localStorage.setItem(DEPTH_KEY, String(depth)); +} From 8109ad35c3891b9bab150ebe18a3663c5560c15e Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:12:44 +0800 Subject: [PATCH 12/13] The click acts at once, and opening is shown as the map moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a folder waited 300ms to find out whether the click was half of a double click. That wait was the whole of the "delay" — the expand itself was instant — and it made the double click worse than either gesture alone: nothing visible on the first click, then a zoom, so the two halves read as a pause and then an unrelated jump. Both go away by letting each click do its own thing. A click on a plate opens it immediately; the second click of a double click then zooms the folder that click opened — by identity, not by hit test, because by then the layout has been replaced by what that folder held and the cursor is over one of its children. And the opening is animated, since it is the one layout change that is demonstrably the same blocks at different sizes: the plate the user clicked becomes what was inside it. Every rect that existed before keeps its identity and slides to its new frame, and the ones that appear grow out of the plate's old rect — which needs no parent lookup, because everything revealed is inside that one rect by construction. Only this change is animated; a scan tick, a resize or a filter snaps, because those are not the map moving, they are the map being redrawn. --- ui/src/components/Treemap.tsx | 296 ++++++++++++++++++++-------------- 1 file changed, 175 insertions(+), 121 deletions(-) diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 666f73b..5bee280 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -30,13 +30,8 @@ import { FOLDER_PLATE, PALETTE, canvasColors, textOn } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; -/** - * How long a click on a plate waits to find out whether it was half of a - * double click. Chromium reports the second click at the platform's interval, - * which defaults to 500ms — too long to hold every open for, so this is the - * compromise: a slow double click opens first and then zooms. - */ -const DOUBLE_CLICK_MS = 300; +/** How long the map takes to move from one layout to the next. */ +const MORPH_MS = 180; /** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; @@ -276,10 +271,10 @@ export function Treemap({ const platesRef = useRef>(new Set()); /** The one directory the user opened by hand. Mirrors `forceOpenId`. */ const forceOpenRef = useRef(null); - /** A click waiting out the double-click window, and its sequence number. */ - const pendingRef = useRef<{ id: number; collapse: boolean } | null>(null); - const pendingSeqRef = useRef(0); - const pendingTimerRef = useRef(0); + /** The layout being animated away from, and whose plate it opens out of. */ + const fromRef = useRef | null>(null); + const morphFromRef = useRef(null); + const morphRafRef = useRef(0); const generationRef = useRef(generation); generationRef.current = generation; @@ -357,71 +352,132 @@ export function Treemap({ drawOverlay(); }, [drawOverlay]); - const bake = useCallback(() => { - const base = baseRef.current; - if (!base || base.width === 0) return; - let off = offscreenRef.current; - if (!off) { - off = document.createElement("canvas"); - offscreenRef.current = off; - } - off.width = base.width; - off.height = base.height; - const ctx = off.getContext("2d")!; - const dpr = window.devicePixelRatio || 1; - const rects = rectsRef.current; - const theme = canvasColors(); - - // The map's own surface, behind every block: a folder that subdivided is - // only the backdrop for what it contains, so it is not painted at all — - // this is what shows through where it would have been, and through the - // gaps between blocks. Its own colour, not the window's: the map is a - // surface with blocks on it, and it keeps that colour in both themes. - ctx.fillStyle = theme.plate; - ctx.fillRect(0, 0, off.width, off.height); - - // Folders are drawn exactly like files — one flat colour, a 1px gap, the - // same sheen over the top — and differ only in the colour. Anything more - // than that (a texture, a seam, a reserved strip) makes a folder read as a - // surface rather than as a block, which is what a plate full of small - // files should look like. - const plates = solidPlates(rects); - ctx.fillStyle = FOLDER_PLATE; - ctx.beginPath(); - for (const r of rects) { - if (!plates.has(r.id)) continue; - const s = snap(r, dpr, 1); - if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); - } - ctx.fill(); - - const buckets: TreemapRect[][] = PALETTE.map(() => []); - for (const r of rects) { - if (!r.isDir) buckets[r.category]?.push(r); - } - for (let c = 0; c < buckets.length; c++) { - const bucket = buckets[c]; - if (bucket.length === 0) continue; - ctx.fillStyle = PALETTE[c]; + const bake = useCallback( + (drawn: TreemapRect[] = rectsRef.current) => { + const base = baseRef.current; + if (!base || base.width === 0) return; + let off = offscreenRef.current; + if (!off) { + off = document.createElement("canvas"); + offscreenRef.current = off; + } + off.width = base.width; + off.height = base.height; + const ctx = off.getContext("2d")!; + const dpr = window.devicePixelRatio || 1; + const rects = drawn; + const theme = canvasColors(); + + // The map's own surface, behind every block: a folder that subdivided is + // only the backdrop for what it contains, so it is not painted at all — + // this is what shows through where it would have been, and through the + // gaps between blocks. Its own colour, not the window's: the map is a + // surface with blocks on it, and it keeps that colour in both themes. + ctx.fillStyle = theme.plate; + ctx.fillRect(0, 0, off.width, off.height); + + // Folders are drawn exactly like files — one flat colour, a 1px gap, the + // same sheen over the top — and differ only in the colour. Anything more + // than that (a texture, a seam, a reserved strip) makes a folder read as a + // surface rather than as a block, which is what a plate full of small + // files should look like. + const plates = solidPlates(rects); + ctx.fillStyle = FOLDER_PLATE; ctx.beginPath(); - for (const r of bucket) { + for (const r of rects) { + if (!plates.has(r.id)) continue; const s = snap(r, dpr, 1); if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } ctx.fill(); - } - const sprite = getHighlightSprite(); - for (const r of rects) { - if (r.isDir && !plates.has(r.id)) continue; - const s = snap(r, dpr, 1); - if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); - } + const buckets: TreemapRect[][] = PALETTE.map(() => []); + for (const r of rects) { + if (!r.isDir) buckets[r.category]?.push(r); + } + for (let c = 0; c < buckets.length; c++) { + const bucket = buckets[c]; + if (bucket.length === 0) continue; + ctx.fillStyle = PALETTE[c]; + ctx.beginPath(); + for (const r of bucket) { + const s = snap(r, dpr, 1); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); + } - if (labelsRef.current) drawLabels(ctx, rects, dpr); + const sprite = getHighlightSprite(); + for (const r of rects) { + if (r.isDir && !plates.has(r.id)) continue; + const s = snap(r, dpr, 1); + if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); + } + + if (labelsRef.current) drawLabels(ctx, rects, dpr); + + if (zoomRafRef.current === 0) blit(); + }, + [blit], + ); - if (zoomRafRef.current === 0) blit(); - }, [blit]); + /** + * Show the map moving from the layout it had to the one it now has, rather + * than cutting between them. + * + * Only the *opening* of a folder is animated, and that is the case this is + * shaped for: the plate the user clicked takes the place of what was inside + * it, so every block that was already there is still there at a new size, + * and the blocks that appeared come out of the plate. Rects that only exist + * in the new layout grow from the plate's old frame — which is what a folder + * opening should look like, and needs no parent lookup, because everything + * revealed is inside that one rect by construction. + * + * Hit testing is frozen for the duration: the frames drawn in between are + * real interpolation but not a layout anything can be clicked on. + */ + const morph = useCallback( + (to: TreemapRect[]) => { + const from = fromRef.current; + fromRef.current = null; + const opened = morphFromRef.current; + if (!from || from.size === 0) { + bake(to); + return; + } + const origin = opened === null ? null : from.get(opened); + const start = performance.now(); + const step = () => { + const t = Math.min(1, (performance.now() - start) / MORPH_MS); + const ease = 1 - (1 - t) * (1 - t); + const mix = (a: number, b: number) => a + (b - a) * ease; + const drawn = to.map((r) => { + const was = from.get(r.id) ?? origin; + if (!was) return r; + return { + ...r, + x: mix(was.x, r.x), + y: mix(was.y, r.y), + w: mix(was.w, r.w), + h: mix(was.h, r.h), + }; + }); + bake(drawn); + if (t < 1) { + morphRafRef.current = requestAnimationFrame(step); + } else { + morphRafRef.current = 0; + bake(); + hitFrozenRef.current = false; + drawOverlay(); + } + }; + hitFrozenRef.current = true; + cancelAnimationFrame(morphRafRef.current); + morphRafRef.current = requestAnimationFrame(step); + }, + [bake, drawOverlay], + ); const refreshCrumbs = useCallback(() => { const generation = generationRef.current; @@ -471,13 +527,21 @@ export function Treemap({ forceOpenRef.current, maxDepthRef.current, ); - if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; + if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) { + fromRef.current = null; + return; + } rectsRef.current = rects; byIdRef.current = new Map(rects.map((r) => [r.id, r])); platesRef.current = solidPlates(rects); - hitFrozenRef.current = false; setHasRects(rects.length > 0); - bake(); + if (fromRef.current) { + // `morph` owns the freeze from here: it lifts it when the frames stop. + morph(rects); + } else { + hitFrozenRef.current = false; + bake(); + } if (crumbsRootRef.current !== forRoot) refreshCrumbs(); // The folder the user opened is open — but if the biggest thing in it // still came out too small to read, opening it in place achieved @@ -498,37 +562,21 @@ export function Treemap({ reportUnlessStale("loading treemap", e); if (seq === fetchSeqRef.current) hitFrozenRef.current = false; } - }, [bake, refreshCrumbs, onNavigate]); + }, [bake, refreshCrumbs, onNavigate, morph]); /** - * A click on a plate is ambiguous until the double-click window closes: it - * could be "open this" or the first half of "zoom into this". So that one - * action waits, and only that one — everything else acts on the spot, and - * `handleDoubleClick` cancels what is waiting. `pendingRef` doubles as the - * flag: it is non-null exactly while a click has yet to be acted on. + * What the last click did, for the double click to read. There is no timer + * any more: a click on a plate opens it right away, and the *second* click + * of a double click zooms instead of waiting to find out. Waiting was worse + * than either — the open felt slow, and the zoom that followed had a pause + * in front of it that read as nothing having happened. */ - const arm = useCallback((id: number, collapse: boolean) => { - window.clearTimeout(pendingTimerRef.current); - const seq = ++pendingSeqRef.current; - const generation = generationRef.current; - const forRoot = rootIdRef.current; - pendingRef.current = { id, collapse }; - pendingTimerRef.current = window.setTimeout(() => { - if (seq !== pendingSeqRef.current) return; - // A scan tick re-fetching underneath us is not a reason to drop the - // click — the id is what the action applies to, and those are stable - // within a generation. Leaving the tree or starting a new scan is. - if (generation !== generationRef.current) return; - if (forRoot !== rootIdRef.current) return; - if (!byIdRef.current.has(id)) return; - setForceOpenId(collapse ? null : id); - }, DOUBLE_CLICK_MS); - }, []); + const lastClickRef = useRef(null); - const cancelPending = useCallback(() => { - pendingRef.current = null; - window.clearTimeout(pendingTimerRef.current); - pendingSeqRef.current++; + /** Open (or close) the folder the click landed on, right away. */ + const setOpen = useCallback((id: number | null) => { + lastClickRef.current = id; + setForceOpenId(id); }, []); const drillTo = useCallback( @@ -536,10 +584,12 @@ export function Treemap({ if (id === rootIdRef.current) return; const zoomFrom = byIdRef.current.get(id); rootIdRef.current = id; + cancelAnimationFrame(morphRafRef.current); + morphRafRef.current = 0; + fromRef.current = null; hitFrozenRef.current = true; setTooltip(null); mouseOverRef.current = null; - cancelPending(); // The open folder follows the view only into itself. Zooming into it // should show what is inside — without this it would arrive as the root // and be folded back into one plate by the same rule that folded it @@ -580,7 +630,7 @@ export function Treemap({ void fetchLayout(); }, - [blit, drawOverlay, fetchLayout, cancelPending], + [blit, drawOverlay, fetchLayout], ); useEffect(() => { @@ -602,14 +652,11 @@ export function Treemap({ offscreenRef.current = null; // Ids belong to a tree, and this is a different one. Holding an open // folder across scans would open whatever now happens to have that id. - cancelPending(); setForceOpenId(null); const base = baseRef.current; if (base) base.getContext("2d")!.clearRect(0, 0, base.width, base.height); if (generation !== 0) void fetchLayout(); - }, [generation, fetchLayout, cancelPending]); - - useEffect(() => cancelPending, [cancelPending]); + }, [generation, fetchLayout]); useEffect(() => { drawOverlay(); @@ -633,6 +680,16 @@ export function Treemap({ // backend can decide what a directory hides. Read through the ref inside // `fetchLayout`, keyed on the value here — the callback's identity has to // stay put, or this cascades into the size effect and double-fetches. + // + // Opening is the one change worth showing as a movement: the plate the + // user clicked becomes what was inside it, so the two layouts are the same + // blocks at different sizes, and a straight cut makes that look like a + // different picture rather than the same one opening. + fromRef.current = + forceOpenId === null + ? null + : new Map(rectsRef.current.map((r) => [r.id, { ...r }])); + morphFromRef.current = forceOpenId; void fetchLayout(); }, [forceOpenId, fetchLayout]); @@ -808,10 +865,11 @@ export function Treemap({ const bounds = containerRef.current!.getBoundingClientRect(); const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); if (!hit) return; + lastClickRef.current = null; // The folder that is open is the one case a click closes: it has // children now, so nothing below would offer to. if (hit.id === forceOpenRef.current) { - arm(hit.id, true); + setOpen(null); return; } onSelect(hit); @@ -823,14 +881,14 @@ export function Treemap({ maxDepthRef.current === null && platesRef.current.has(hit.id) ) { - arm(hit.id, false); + setOpen(hit.id); return; } // A directory the layout did choose to subdivide: single click zooms, as // it always has. if (hit.isDir) onNavigate(hit.id); }, - [hitTest, onSelect, onNavigate, arm], + [hitTest, onSelect, onNavigate, setOpen], ); const handleContextMenu = useCallback( @@ -849,20 +907,16 @@ export function Treemap({ [hitTest, regionAt, crumbs, onContext], ); - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const bounds = containerRef.current!.getBoundingClientRect(); - const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); - if (!hit?.isDir || hit.id === rootIdRef.current) return; - // Nothing waiting means the first click already acted — it zoomed, or it - // selected a file — and a second zoom on top of that is not what anyone - // asked for. - if (pendingRef.current === null) return; - cancelPending(); - onNavigate(hit.id); - }, - [hitTest, onNavigate, cancelPending], - ); + const handleDoubleClick = useCallback(() => { + // The double click zooms the folder the *first* click opened. Not the rect + // under the cursor: by the time the second click lands, the layout has + // been replaced by what that folder held, so the cursor is over one of its + // children — or over nothing, since the frames of the opening animation + // are not a layout anyone can point at. + const opened = lastClickRef.current; + if (opened === null || opened === rootIdRef.current) return; + onNavigate(opened); + }, [onNavigate]); const zoomOut = useCallback(() => { if (crumbs.length < 2 || hitFrozenRef.current) return; From eeca22e67516da4bafeb8d3d222e0aae4adfaeea Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:26:04 +0800 Subject: [PATCH 13/13] Opening dissolves instead of exploding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Growing the children out of the plate looked like what it was: three hundred blocks leaving the same point at once, each carrying the sheen that makes a block read as raised, stacked on top of each other until the middle of the plate went white. A flashbang, and the wrong shape for the change — opening a folder adds rects *inside* the one that was clicked and moves nothing else, so there is nothing to travel between and nothing should travel. It is a crossfade now: the picture as it stands fades into the one that replaces it. Same duration, same immediacy, no flare. --- ui/src/components/Treemap.tsx | 100 +++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 5bee280..8d4c43d 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -30,8 +30,8 @@ import { FOLDER_PLATE, PALETTE, canvasColors, textOn } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; -/** How long the map takes to move from one layout to the next. */ -const MORPH_MS = 180; +/** How long the map takes to settle into a new layout. */ +const MORPH_MS = 160; /** Mirrors the body font stack in index.css so canvas text matches the DOM's. */ const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; @@ -271,9 +271,9 @@ export function Treemap({ const platesRef = useRef>(new Set()); /** The one directory the user opened by hand. Mirrors `forceOpenId`. */ const forceOpenRef = useRef(null); - /** The layout being animated away from, and whose plate it opens out of. */ - const fromRef = useRef | null>(null); - const morphFromRef = useRef(null); + /** The picture being dissolved away from, while a layout settles in. */ + const wasRef = useRef(null); + const morphRef = useRef(false); const morphRafRef = useRef(0); const generationRef = useRef(generation); @@ -422,52 +422,61 @@ export function Treemap({ ); /** - * Show the map moving from the layout it had to the one it now has, rather - * than cutting between them. + * Dissolve the layout it had into the one it now has, rather than cutting. * - * Only the *opening* of a folder is animated, and that is the case this is - * shaped for: the plate the user clicked takes the place of what was inside - * it, so every block that was already there is still there at a new size, - * and the blocks that appeared come out of the plate. Rects that only exist - * in the new layout grow from the plate's old frame — which is what a folder - * opening should look like, and needs no parent lookup, because everything - * revealed is inside that one rect by construction. + * A crossfade, not a movement, and that is deliberate: opening a folder adds + * rects *inside* the one that was clicked and moves nothing else, so the + * only thing that could animate is the children appearing. Growing them out + * of the plate looks like what it is — three hundred blocks leaving the same + * point at once, each with the sheen that makes a block read as raised, + * stacked until the middle of the plate goes white. There is nothing to + * travel between, so nothing travels. * - * Hit testing is frozen for the duration: the frames drawn in between are - * real interpolation but not a layout anything can be clicked on. + * The old picture is the canvas as it stands, which is why this has to + * happen before the new one is baked over it. */ const morph = useCallback( (to: TreemapRect[]) => { - const from = fromRef.current; - fromRef.current = null; - const opened = morphFromRef.current; - if (!from || from.size === 0) { + const base = baseRef.current; + const off = offscreenRef.current; + if (!base || !off || off.width === 0) { bake(to); return; } - const origin = opened === null ? null : from.get(opened); + let was = wasRef.current; + if (!was) { + was = document.createElement("canvas"); + wasRef.current = was; + } + was.width = off.width; + was.height = off.height; + was.getContext("2d")!.drawImage(off, 0, 0); + + bake(to); const start = performance.now(); + const ctx = base.getContext("2d")!; const step = () => { const t = Math.min(1, (performance.now() - start) / MORPH_MS); - const ease = 1 - (1 - t) * (1 - t); - const mix = (a: number, b: number) => a + (b - a) * ease; - const drawn = to.map((r) => { - const was = from.get(r.id) ?? origin; - if (!was) return r; - return { - ...r, - x: mix(was.x, r.x), - y: mix(was.y, r.y), - w: mix(was.w, r.w), - h: mix(was.h, r.h), - }; - }); - bake(drawn); + ctx.clearRect(0, 0, base.width, base.height); + ctx.drawImage(was!, 0, 0); + ctx.globalAlpha = 1 - (1 - t) * (1 - t); + ctx.drawImage( + off, + 0, + 0, + off.width, + off.height, + 0, + 0, + base.width, + base.height, + ); + ctx.globalAlpha = 1; if (t < 1) { morphRafRef.current = requestAnimationFrame(step); } else { morphRafRef.current = 0; - bake(); + blit(); hitFrozenRef.current = false; drawOverlay(); } @@ -476,7 +485,7 @@ export function Treemap({ cancelAnimationFrame(morphRafRef.current); morphRafRef.current = requestAnimationFrame(step); }, - [bake, drawOverlay], + [bake, blit, drawOverlay], ); const refreshCrumbs = useCallback(() => { @@ -528,15 +537,16 @@ export function Treemap({ maxDepthRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) { - fromRef.current = null; + morphRef.current = false; return; } rectsRef.current = rects; byIdRef.current = new Map(rects.map((r) => [r.id, r])); platesRef.current = solidPlates(rects); setHasRects(rects.length > 0); - if (fromRef.current) { + if (morphRef.current) { // `morph` owns the freeze from here: it lifts it when the frames stop. + morphRef.current = false; morph(rects); } else { hitFrozenRef.current = false; @@ -586,7 +596,7 @@ export function Treemap({ rootIdRef.current = id; cancelAnimationFrame(morphRafRef.current); morphRafRef.current = 0; - fromRef.current = null; + morphRef.current = false; hitFrozenRef.current = true; setTooltip(null); mouseOverRef.current = null; @@ -685,11 +695,11 @@ export function Treemap({ // user clicked becomes what was inside it, so the two layouts are the same // blocks at different sizes, and a straight cut makes that look like a // different picture rather than the same one opening. - fromRef.current = - forceOpenId === null - ? null - : new Map(rectsRef.current.map((r) => [r.id, { ...r }])); - morphFromRef.current = forceOpenId; + // + // Nothing else gets one. A scan tick, a resize or a filter change is the + // map being redrawn rather than the map moving, and animating those would + // leave the picture permanently in motion. + morphRef.current = true; void fetchLayout(); }, [forceOpenId, fetchLayout]);