diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..d51ed7f 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -1,8 +1,24 @@ //! 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_area_px` are culled but still consume their share of space. +//! 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. +//! +//! 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, 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; @@ -16,23 +32,57 @@ pub struct Viewport { #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { - pub min_area_px: f32, - pub padding_px: f32, + /// 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, + /// 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, } -impl Default for TreemapOptions { - fn default() -> Self { - TreemapOptions { - min_area_px: 1.0, - padding_px: 1.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; + +/// 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. 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 { @@ -53,7 +103,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 @@ -66,7 +116,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( @@ -75,6 +160,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 { @@ -97,7 +183,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 } @@ -152,17 +238,12 @@ 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 +// 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, @@ -170,6 +251,7 @@ fn emit( depth: u8, opts: &TreemapOptions, visible: Option<&[u64]>, + open: &[NodeId], out: &mut Vec, ) { let node = tree.node(id); @@ -183,16 +265,41 @@ 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; } - let inner = frame.inset(opts.padding_px as f64); - if inner.w <= 0.0 || inner.h <= 0.0 { + // 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. 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. + // + // 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; + } + // 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; } - lay_children(tree, id, inner, depth + 1, opts, visible, out); + lay_children(tree, id, frame, depth + 1, opts, visible, open, out); } +#[allow(clippy::too_many_arguments)] fn lay_children( tree: &Tree, dir: NodeId, @@ -200,8 +307,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))) @@ -210,26 +319,172 @@ fn lay_children( if items.is_empty() { return; } + let min_side = opts.min_side_px as f64; + 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. + // + // `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 + // 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() > capacity { + 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 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 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, + open, + 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. +/// +/// 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(); + 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); + // 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); + } +} +/// 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; @@ -241,80 +496,48 @@ fn lay_children( j += 1; } - lay_row( - tree, - &items[i..j], - scale, - sum, - &mut remaining, - depth, - min_area, - 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_area: 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 + if horizontal { + remaining.y += thickness; + remaining.h -= thickness; } else { - (size as f64 * scale) / thickness - }; - let frame = 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; - if frame.area() >= min_area { - 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)] @@ -350,11 +573,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, - padding_px: 0.0, + min_side_px: 0.0, max_depth: 32, + adaptive_depth: true, hide_system: false, } } @@ -367,6 +592,90 @@ 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, 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)); + let mut next = 6u32; + for _ in 0..10 { + b.push("clip", entry(next, 3, FILE, 1_000)); + next += 1; + } + for _ in 0..60 { + 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..150 { + 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. 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) -> f64 { + let (x0, y0) = (dir.x, dir.y); + let (x1, y1) = (dir.x + dir.w, dir.y + dir.h); + 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)]); @@ -474,48 +783,693 @@ mod tests { assert!((area(&dir) - 10_000.0).abs() < 1.0); } + /// 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 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, + ..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 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 an area + /// test passes and nobody can see. + #[test] + 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, + ..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 their corner of it flat and empty. + #[test] + fn small_children_are_raised_to_the_floor_and_fill_the_block() { + // 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: 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) - body_area(&root)).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 = speck_tree(300, 60); + let opts = TreemapOptions { + min_side_px: 6.0, + ..no_padding() + }; + 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 + .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}"); + } + + /// 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, + ..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) - body_area(&root)).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) - body_area(&root)).abs() < 0.01, + "dropping the tail does not open a hole" + ); + } + + /// 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 + /// 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 padding_insets_children_inside_their_directory() { + 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("dir", entry(1, 0, DIR, 0)); - b.push("f", entry(2, 1, FILE, 100)); + 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. + #[test] + fn production_options_leave_no_gaps_at_any_depth() { let opts = TreemapOptions { - min_area_px: 0.0, - padding_px: 2.0, + min_side_px: 6.0, max_depth: 32, + adaptive_depth: true, 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); - 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); + 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); + // 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); + assert!( + (kids - body).abs() < 0.001 * body, + "dir {} covers {kids} of its {body}px² body", + dir.id + ); + } } + /// Depth changes stay live because a capped layout is a prefix of a deeper + /// 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 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². - let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); + 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)); + 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)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8)) + .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"); + } + + /// 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_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)); + 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_area_px: 1.0, - padding_px: 0.0, - max_depth: 32, - hide_system: false, + min_side_px: 25.0, + ..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); + // 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), + "a speck of a level is not worth the descent" + ); + + // 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 the level is worth it"); + assert!(opts.max_depth > 1, "the cap never came into 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_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(capacity, 1), 0, vp, &opts); + assert!(under.len() > 1, "the capacity itself is still drawn"); + + let over = layout(&equal_files(capacity + 1, 1), 0, vp, &opts); + assert_eq!(over.len(), 1, "one plate once it is over"); + } + + /// 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(); + 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: 320.0, h: 320.0 }; + assert_eq!( + layout(&tree, 0, vp, &floored(6.0)).len(), + 1, + "250 of them is over the capacity" + ); + 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(); + + // 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"); + + 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] + 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() { + // 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 150 blocks"); + + let roomy = layout(&tree, 0, Viewport { w: 600.0, h: 600.0 }, &opts); + assert!( + roomy.len() > 100, + "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, + ..no_padding() + } } #[test] @@ -542,9 +1496,9 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, - padding_px: 0.0, + 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 1f4f8a2..72d194a 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,21 @@ 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 +/// 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 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>, @@ -347,6 +365,8 @@ pub fn get_treemap( height: f32, 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(); @@ -358,19 +378,36 @@ pub fn get_treemap( return Ok(Vec::new()); } let opts = TreemapOptions { - min_area_px: 3.0, - padding_px: 1.0, - max_depth: 24, + min_side_px: TREEMAP_MIN_SIDE_PX, + // 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` 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 { 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 + // 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 { @@ -382,6 +419,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()) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..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; @@ -123,8 +124,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) @@ -357,6 +360,8 @@ export default function App() { viewRootId={viewRootId} startError={scan.startError} hideSystem={scan.hideSystem} + showLabels={scan.showLabels} + depth={scan.depth} filter={scan.filter} typePanelOpen={typePanelOpen} themePref={theme.pref} @@ -364,6 +369,8 @@ export default function App() { onScan={handleScan} onCancel={scan.cancel} onToggleHideSystem={scan.toggleHideSystem} + onToggleShowLabels={scan.toggleShowLabels} + onDepth={scan.setDepth} onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} @@ -422,6 +429,8 @@ export default function App() { themeRev={theme.themeRev} 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 211aa0f..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 }[] = [ @@ -15,18 +16,26 @@ 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; } export function SettingsMenu({ hideSystem, + showLabels, + depth, themePref, accent, onToggleHideSystem, + onToggleShowLabels, + onDepth, onThemePref, onAccent, }: SettingsMenuProps) { @@ -80,6 +89,43 @@ export function SettingsMenu({ /> Hide system files + +
+ Depth +
+
+ {DEPTH_OPTIONS.map((opt) => ( + + ))} +
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..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"; @@ -13,6 +14,8 @@ interface ToolbarProps { viewRootId: number; startError: string | null; hideSystem: boolean; + showLabels: boolean; + depth: DepthPref; filter: string | null; typePanelOpen: boolean; themePref: ThemePref; @@ -20,6 +23,8 @@ interface ToolbarProps { onScan: (path: string) => void; onCancel: () => void; onToggleHideSystem: () => void; + onToggleShowLabels: () => void; + onDepth: (depth: DepthPref) => void; onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; @@ -33,6 +38,8 @@ export function Toolbar({ viewRootId, startError, hideSystem, + showLabels, + depth, filter, typePanelOpen, themePref, @@ -40,6 +47,8 @@ export function Toolbar({ onScan, onCancel, onToggleHideSystem, + onToggleShowLabels, + onDepth, onToggleTypePanel, onSearchSelect, onApplyFilter, @@ -97,9 +106,13 @@ export function Toolbar({ /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..8d4c43d 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -25,34 +25,99 @@ import { } from "../lib/api"; import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; -import { PALETTE, canvasColors } from "../lib/palette"; +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 settle into a new layout. */ +const MORPH_MS = 160; -// 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; +/** 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; +/** + * 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; -let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; +/** + * 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, 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. + * + * 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, +) { + 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; + + 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 || s.h < LABEL_MIN_H_PX * dpr) continue; + 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; + 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 { + ctx.fillText(name, cx, s.y + s.h / 2); + } + } } let highlightSprite: HTMLCanvasElement | null = null; @@ -88,6 +153,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; @@ -106,6 +207,14 @@ 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; + /** + * 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; @@ -128,6 +237,8 @@ export function Treemap({ themeRev, hideSystem, filter, + labels, + maxDepth, selected, hoveredId, onSelect, @@ -155,6 +266,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); + /** 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); generationRef.current = generation; @@ -166,10 +286,16 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); 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); const [hasRects, setHasRects] = useState(false); + const [forceOpenId, setForceOpenId] = useState(null); + forceOpenRef.current = forceOpenId; const drawOverlay = useCallback(() => { const overlay = overlayRef.current; @@ -226,61 +352,141 @@ 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(); - - ctx.fillStyle = theme.background; - ctx.fillRect(0, 0, off.width, off.height); + 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(); - ctx.fillStyle = ctx.createPattern( - getGrainTile(theme.plate, theme.plateGrain), - "repeat", - )!; - 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(); + // 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); - 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]; + // 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) 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(); + } + + 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], + ); + + /** + * Dissolve the layout it had into the one it now has, rather than cutting. + * + * 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. + * + * 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 base = baseRef.current; + const off = offscreenRef.current; + if (!base || !off || off.width === 0) { + bake(to); + return; + } + 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); - if (zoomRafRef.current === 0) blit(); - }, [blit]); + bake(to); + const start = performance.now(); + const ctx = base.getContext("2d")!; + const step = () => { + const t = Math.min(1, (performance.now() - start) / MORPH_MS); + 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; + blit(); + hitFrozenRef.current = false; + drawOverlay(); + } + }; + hitFrozenRef.current = true; + cancelAnimationFrame(morphRafRef.current); + morphRafRef.current = requestAnimationFrame(step); + }, + [bake, blit, drawOverlay], + ); const refreshCrumbs = useCallback(() => { const generation = generationRef.current; @@ -327,28 +533,78 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, + forceOpenRef.current, + maxDepthRef.current, ); - if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; + if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) { + morphRef.current = false; + return; + } rectsRef.current = rects; byIdRef.current = new Map(rects.map((r) => [r.id, r])); - hitFrozenRef.current = false; + platesRef.current = solidPlates(rects); setHasRects(rects.length > 0); - bake(); + 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; + 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, morph]); + + /** + * 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 lastClickRef = useRef(null); + + /** 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( (id: number) => { if (id === rootIdRef.current) return; const zoomFrom = byIdRef.current.get(id); rootIdRef.current = id; + cancelAnimationFrame(morphRafRef.current); + morphRafRef.current = 0; + morphRef.current = false; hitFrozenRef.current = true; setTooltip(null); mouseOverRef.current = null; + // 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; @@ -395,6 +651,7 @@ export function Treemap({ rootIdRef.current = 0; rectsRef.current = []; byIdRef.current = new Map(); + platesRef.current = new Set(); setHasRects(false); setCrumbs([]); setTooltip(null); @@ -403,6 +660,9 @@ 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. + setForceOpenId(null); const base = baseRef.current; if (base) base.getContext("2d")!.clearRect(0, 0, base.width, base.height); if (generation !== 0) void fetchLayout(); @@ -425,6 +685,40 @@ 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. + // + // 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. + // + // 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]); + + 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. + bake(); + }, [labels, bake]); + const prevStateRef = useRef(undefined); useEffect(() => { if (!snapshot || snapshot.generation !== generation) return; @@ -580,9 +874,31 @@ 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; + 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) { + setOpen(null); + 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 && + maxDepthRef.current === null && + platesRef.current.has(hit.id) + ) { + 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], + [hitTest, onSelect, onNavigate, setOpen], ); const handleContextMenu = useCallback( @@ -601,15 +917,16 @@ export function Treemap({ [hitTest, regionAt, crumbs, onContext], ); - 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); - }, - [regionAt, onNavigate], - ); + 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; diff --git a/ui/src/hooks/useScan.ts b/ui/src/hooks/useScan.ts index cd4dde9..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; @@ -26,6 +31,11 @@ export interface ScanController { expanded: ReadonlySet; sort: Sort; 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; @@ -37,6 +47,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 +60,13 @@ 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 [depth, setDepthState] = useState(loadTreemapDepth); + + const setDepth = useCallback((next: DepthPref) => { + setDepthState(next); + saveTreemapDepth(next); + }, []); const [filter, setFilterState] = useState(null); const [startError, setStartError] = useState(null); @@ -248,6 +266,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 +294,9 @@ export function useScan(): ScanController { expanded, sort, hideSystem, + showLabels, + depth, + setDepth, filter, startError, scanning: snapshot?.state === "scanning", @@ -283,6 +306,7 @@ export function useScan(): ScanController { expandMany, changeSort, toggleHideSystem, + toggleShowLabels, setFilter, pathOf, }; diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..6947841 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; /* the map's own surface, behind every block */ --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..5388b36 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 when the view asks for labels. */ + name: string; + size: number; } export interface Crumb { @@ -159,6 +162,8 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, + forceOpen: number | null, + maxDepth: number | null, ) => invoke("get_treemap", { generation, @@ -167,6 +172,8 @@ export const api = { height, hideSystem, filter, + forceOpen, + maxDepth, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..139348a 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -1,7 +1,22 @@ -// Indexed by mathom-core's `Category as u8`. Index 0 (directories) is -// theme-dependent — canvas code paints plates with canvasColors().plate. +/** + * The colour of a folder block. The one category colour that is not a file + * 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 = "#55618f"; + +// 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 @@ -19,10 +34,42 @@ 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"), - plateGrain: v("--color-plate-grain"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; } + +/** `#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); +} + +/** + * 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); + return luminance(r, g, b) > 0.45 + ? "rgba(12, 12, 14, 0.88)" + : "rgba(250, 250, 250, 0.92)"; +} 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)); +}