file manager
git clone https://git.lucas.co/cce-files.git
feat(space): legible labels, and a folder's smallest entries as one block
Labels were light text on every tile: 1.6:1 on Code's yellow and under
3:1 on most of the palette. Each now takes whichever of the light text
and the frame's near-black has the higher WCAG contrast with what it
sits on (label_on): dark on every kind but Other's grey, light on the
dimmed fill of a directory drawn as one block.
Entries too small to draw were dropped one by one, so a folder of
thousands of them left a dark hole the size of all of them together.
On / that was most of target/debug/deps (a million files) and much of
/var, reading as empty space. A directory's entries under REST_AREA
(twice MIN_TILE squared) are now lumped into one "N smaller items" tile
in the squarified order by its total, filled like a block in the colour
of what fills it most. It carries the directory's own path, so a click
selects the folder and a double-click opens it. A lone small entry is
left to stand or fall on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 4 +-
src/pages/space.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 156 insertions(+), 17 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6a923dc..232914f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,9 +55,9 @@ The active page is picked from the breadcrumb's context menu (there is no sideba
Browse's row list (`row_list.rs`, `RowList`) follows the DE's one scrollbar rule (cce-ui's CLAUDE.md, "Every scrollbar rides a centre line, behind the plate"; since 2026-10-06 — it was a flat bar at the right edge, always on top). The bar is a pair of pills down the middle of the list's width, `centred_scrollbar_width()` thick, over the rows: no column reserves a lane for it. A `ScrollbarActivity` decides its depth. Sunk, it is drawn before the well's translucent `list_bg_color` fill, which dims it, and it takes no press: a press on its lane goes to the row under it. A wheel, a glide or coast in motion, `scroll_into_view` (how keyboard navigation scrolls the list) and a thumb drag's release raise it, and a pointer over a raised bar holds it up. `RowList::tick`, which the app's `tick` already runs for the glide, ticks the activity too and returns true while the hold or the fade is running, so frames keep coming until the bar has sunk. `push_prims` draws the idle copy at full alpha every frame and the fore copy after the row overlays at `fade()`. That order holds because the list's prims go into the PAGE's `PageContent`, whose rects are replayed in call order (only `plain_pc` is partitioned by radius — see the note at the top of `row_list.rs`). A part's icons and reliefs are replayed after all its rects, so the row glyphs and the well's wall still draw over the fore copy. `the_scrollbar_rides_the_centre_and_sinks_until_scrolled` is the test.
### The Space treemap
-Unlike the other two pages, Space needs data no other page has: the recursive size of everything below the current directory. `services/scan.rs` walks it on the FsService's blocking pool (`FsRequest::ScanTree`), never following symlinks and never crossing a device boundary — the latter is what keeps a scan of `/` out of `/proc`, `/sys`, and mounted drives. Progress is reported every 150 ms; the finished tree arrives as `SpaceMessage::Scanned`.
+Unlike the other two pages, Space needs data no other page has: the recursive size of everything below the current directory. `services/scan.rs` walks it on the FsService's blocking pool (`FsRequest::ScanTree`), never following symlinks, and entering another device only at a mount point on the same physical disk as the scan root (`crossable`: `/proc/self/mountinfo`, each source resolved through `/sys/class/block` past partitions and device-mapper to its disk). That keeps a scan of `/` out of `/proc`, `/sys`, tmpfs, shares and other drives while still taking in btrfs subvolumes and sibling partitions — a plain device check stopped at `/home`, its own subvolume (and so its own device) on this machine, and left most of the disk out. Unmounted nested subvolumes (snapshots, container layers) stay out. Every directory is entered once by (device, inode), so a bind mount neither double-counts nor loops. Progress is reported every 150 ms; the finished tree arrives as `SpaceMessage::Scanned`.
-`pages/space.rs` then lays that tree out with a **squarified** treemap (Bruls/Huizing/van Wijk), which keeps tiles near-square so areas stay visually comparable — a naive slice-and-dice degenerates into unreadable slivers. Layout is recursive, with a directory's children nested inside its rect, and is cached against the pane rect (`laid_out`) so it only recomputes on a resize or a new tree. Tiles under `MIN_TILE` px are dropped rather than emitted as sub-pixel slivers; that culling, not `MAX_TILES`, is what actually bounds tile count. Files are colored by extension `Category`, each inset half of `FILE_GAP` a side (`file_face`) so the frame shows as a seam between neighbours — without it, files of one kind side by side fused into one slab; directories paint only a frame — except one drawn as a single block (too small to open, or nothing inside it big enough to place: `Tile::aggregate`), which is filled with a dimmed copy of the colour of the kind holding most of its bytes. Painted in the frame colour, such blocks read as empty space. `tally` sums the tree by kind once when a scan lands (`SpaceState::breakdown`, plus each directory's dominant kind by path), so a relayout only looks colours up. The footer is two lines: the hovered tile (path relative to the scanned directory, cut from the front; size; share of the total; "mostly …" for a block) or else the summary, and under it the colour legend, largest kind first, the hovered kind lit. The root tile covers the whole map, so hovering it counts as hovering nothing.
+`pages/space.rs` then lays that tree out with a **squarified** treemap (Bruls/Huizing/van Wijk), which keeps tiles near-square so areas stay visually comparable — a naive slice-and-dice degenerates into unreadable slivers. Layout is recursive, with a directory's children nested inside its rect, and is cached against the pane rect (`laid_out`) so it only recomputes on a resize or a new tree. Tiles under `MIN_TILE` px are dropped rather than emitted as sub-pixel slivers; that culling, not `MAX_TILES`, is what actually bounds tile count. Before that, a directory's entries under `REST_AREA` px² (its tail, since children are sorted largest first) are lumped into one "N smaller items" tile (`Tile::rest`, carrying the directory's own path) — dropped one by one, a folder of thousands of them left a dark hole that read as empty. Files are colored by extension `Category`, each inset half of `FILE_GAP` a side (`file_face`) so the frame shows as a seam between neighbours — without it, files of one kind side by side fused into one slab; directories paint only a frame — except one drawn as a single block (too small to open, or nothing inside it big enough to place: `Tile::aggregate`), which is filled with a dimmed copy of the colour of the kind holding most of its bytes. Painted in the frame colour, such blocks read as empty space. `tally` sums the tree by kind once when a scan lands (`SpaceState::breakdown`, plus each directory's dominant kind by path), so a relayout only looks colours up. The footer is two lines: the hovered tile (path relative to the scanned directory, cut from the front; size; share of the total; "mostly …" for a block) or else the summary, and under it the colour legend, largest kind first, the hovered kind lit. Labels on a filled tile pick light text or the frame's near-black by WCAG contrast (`label_on`): light text was 1.6:1 on Code's yellow. The root tile covers the whole map, so hovering it counts as hovering nothing.
Two things to know when touching it:
- Tiles are flattened **parents-before-children**, so the hit-test is `rposition` (last match = deepest tile). The same order finds the faint frame drawn round the hovered tile's top-level folder: `top_folder` takes the nearest depth-1 tile *before* it, which in a depth-first list is its ancestor.
diff --git a/src/pages/space.rs b/src/pages/space.rs
index 72c4542..76f038e 100644
--- a/src/pages/space.rs
+++ b/src/pages/space.rs
@@ -36,6 +36,13 @@ const MIN_TILE: f32 = 3.0;
/// recursed into: below it the frame and padding would eat the children.
const MIN_RECURSE: f32 = 20.0;
+/// A directory's entries smaller than this many px² are not laid out one by
+/// one but lumped into a single "N smaller items" block. Each alone would be
+/// culled under `MIN_TILE`, and a folder of thousands of them left a dark
+/// hole as big as all of them together — read, like an unfilled block, as
+/// empty space. Twice `MIN_TILE`² catches what squarify would cut to slivers.
+const REST_AREA: f32 = MIN_TILE * MIN_TILE * 2.0;
+
/// Hard ceiling on emitted tiles, so a pathological tree cannot make a frame
/// rebuild unbounded. Reached only when MIN_TILE culling has not already.
const MAX_TILES: usize = 24_000;
@@ -260,6 +267,10 @@ pub struct Tile {
/// inside it big enough to place: the kind that fills most of it. Painted
/// in the frame colour, such a block read as empty space however full it was.
pub aggregate: Option<Category>,
+ /// Nonzero on the block standing for that many of a directory's smallest
+ /// entries (see [`REST_AREA`]). It carries the directory's own path, so a
+ /// click selects and a double-click opens the directory they are in.
+ pub rest: usize,
}
impl Tile {
@@ -542,6 +553,7 @@ fn place(
depth,
rect,
aggregate: None,
+ rest: 0,
});
if !node.is_dir {
@@ -590,13 +602,66 @@ fn place_children(
if kids.is_empty() {
return;
}
- let values: Vec<f64> = kids.iter().map(|c| c.size as f64).collect();
- for (child, r) in kids.iter().zip(squarify(&values, inner)) {
- place(child, &path.join(&child.name), r, depth + 1, dominant, out);
+ // Children are sorted largest first, so the ones under REST_AREA are a
+ // tail. Two or more of them become one block; a lone one is laid out as
+ // itself and stands or falls by MIN_TILE.
+ let total: f64 = kids.iter().map(|c| c.size as f64).sum();
+ let px_per_byte = (inner.2 * inner.3) as f64 / total;
+ let mut keep = kids
+ .iter()
+ .position(|c| (c.size as f64 * px_per_byte) < REST_AREA as f64)
+ .unwrap_or(kids.len());
+ if kids.len() - keep < 2 {
+ keep = kids.len();
+ }
+ let (shown, rest) = kids.split_at(keep);
+
+ // squarify wants its values descending, and the lump may outweigh some
+ // of the entries shown alone, so it takes its place among them.
+ let rest_size: u64 = rest.iter().map(|c| c.size).sum();
+ let mut slots: Vec<(u64, Option<&TreeNode>)> = shown.iter().map(|c| (c.size, Some(*c))).collect();
+ if !rest.is_empty() {
+ let at = slots.partition_point(|&(size, _)| size >= rest_size);
+ slots.insert(at, (rest_size, None));
+ }
+ let values: Vec<f64> = slots.iter().map(|&(size, _)| size as f64).collect();
+
+ for (&(_, child), r) in slots.iter().zip(squarify(&values, inner)) {
+ match child {
+ Some(child) => place(child, &path.join(&child.name), r, depth + 1, dominant, out),
+ None => {
+ if r.2 < MIN_TILE || r.3 < MIN_TILE || out.len() >= MAX_TILES {
+ continue;
+ }
+ out.push(Tile {
+ path: path.to_path_buf(),
+ name: format!("{} smaller items", group_digits(rest.len() as u64)),
+ size: rest_size,
+ is_dir: true,
+ depth: depth + 1,
+ rect: r,
+ aggregate: rest_kind(rest, path, dominant),
+ rest: rest.len(),
+ });
+ }
+ }
}
}
+/// What fills a lump of small entries most, by bytes: a file by its own
+/// kind, a directory by its dominant one.
+fn rest_kind(rest: &[&TreeNode], path: &Path, dominant: &HashMap<PathBuf, Category>) -> Option<Category> {
+ let mut b = Breakdown::default();
+ for c in rest {
+ let kind = if c.is_dir { dominant.get(&path.join(&c.name)).copied() } else { Some(Category::of(&c.name)) };
+ if let Some(kind) = kind {
+ b.bytes[kind.index()] += c.size;
+ }
+ }
+ b.dominant()
+}
+
// ── View ────────────────────────────────────────────────────────────
pub fn view(
@@ -686,25 +751,25 @@ pub fn view(
pc.rect(frame, tx, ty, tw, th);
// One drawn as a block has no children over it: fill it, inside a
// frame-coloured rim, in a dimmed copy of what fills it most.
+ // An opened directory's name sits on its frame-coloured strip; a
+ // block's sits on its fill.
+ let mut label = text_dim;
if let Some(cat) = tile.aggregate {
+ let fill = mix(cat.color(), frame, AGGREGATE_DIM);
if tw > 2.0 && th > 2.0 {
- pc.rect(mix(cat.color(), frame, AGGREGATE_DIM), tx + 1.0, ty + 1.0, tw - 2.0, th - 2.0);
+ pc.rect(fill, tx + 1.0, ty + 1.0, tw - 2.0, th - 2.0);
}
+ label = label_on(fill, text_fg, frame);
}
if th >= DIR_LABEL_MIN && tw >= FILE_LABEL_MIN_W {
- pc.text(
- &elide(&tile.name, tw - 6.0),
- tx + 3.0,
- ty + 2.0,
- 10.0,
- text_dim,
- );
+ pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, label);
}
} else {
let (fx, fy, fw, fh) = file_face(tile.rect);
- pc.rect(Category::of(&tile.name).color(), fx, fy, fw, fh);
+ let fill = Category::of(&tile.name).color();
+ pc.rect(fill, fx, fy, fw, fh);
if tw >= FILE_LABEL_MIN_W && th >= FILE_LABEL_MIN_H {
- pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, text_fg);
+ pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, label_on(fill, text_fg, frame));
}
}
}
@@ -789,11 +854,20 @@ fn file_face(rect: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
/// for a block, what fills it.
fn readout(t: &Tile, root: &Path, total: u64, avail: f32) -> String {
let mut tail = format!(" — {} · {}", format_size(t.size), share(t.size, total));
+ if t.rest > 0 {
+ tail.insert_str(0, &format!(" · {}", t.name));
+ }
if let Some(cat) = t.aggregate {
tail.push_str(&format!(" · mostly {}", cat.label().to_lowercase()));
}
- let mut rel = t.path.strip_prefix(root).unwrap_or(&t.path).display().to_string();
- if t.is_dir {
+ // A lump directly under the root has the root's own path: no relative
+ // part to show, so it names the root.
+ let mut rel = match t.path.strip_prefix(root) {
+ Ok(r) if r.as_os_str().is_empty() => root.display().to_string(),
+ Ok(r) => r.display().to_string(),
+ Err(_) => t.path.display().to_string(),
+ };
+ if t.is_dir && !rel.ends_with('/') {
rel.push('/');
}
let tail_w = cce_ui::widget::display::measure_text_width(&tail, FOOTER_FAMILY, FOOTER_FONT);
@@ -842,6 +916,24 @@ fn group_digits(n: u64) -> String {
out
}
+/// Whichever of `light` and `dark` reads better on `bg`, by WCAG contrast
+/// ratio. Light text on every tile was 1.6:1 on Code's yellow and under 3:1 on
+/// most of the palette; the frame's near-black runs 3.8:1 (Binaries) to 7.9:1
+/// (Code), and Other's grey, where light still wins at 3.9:1, keeps it.
+fn label_on(bg: [f32; 4], light: [f32; 4], dark: [f32; 4]) -> [f32; 4] {
+ let l = luminance(bg);
+ let contrast = |c: [f32; 4]| {
+ let (a, b) = (luminance(c).max(l), luminance(c).min(l));
+ (a + 0.05) / (b + 0.05)
+ };
+ if contrast(dark) > contrast(light) { dark } else { light }
+}
+
+/// Relative luminance of a colour already in linear space.
+fn luminance(c: [f32; 4]) -> f32 {
+ 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
+}
+
/// `a` pulled toward `b` by `t` (0 = a, 1 = b). Both linear, so is the mix.
fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
[
@@ -1099,6 +1191,42 @@ mod tests {
assert!(state.tiles.iter().filter(|t| t.name != "sub").all(|t| t.aggregate.is_none()));
}
+ #[test]
+ fn a_folders_smallest_entries_become_one_block() {
+ // Ten thousand 1-byte files beside one big one, in 200 x 200: each
+ // alone is far under REST_AREA, together they are a tenth of the map.
+ let mut children = vec![file("huge.bin", 90_000)];
+ children.extend((0..10_000).map(|i| file(&format!("t{i}.png"), 1)));
+ let tree = TreeNode { name: "root".into(), size: 100_000, is_dir: true, children };
+ let mut tiles = Vec::new();
+ place(&tree, Path::new("/root"), (0.0, 0.0, 200.0, 200.0), 0, &HashMap::new(), &mut tiles);
+
+ assert_eq!(tiles.len(), 3, "root, huge.bin, and one lump: {:?}", tiles.iter().map(|t| &t.name).collect::<Vec<_>>());
+ let lump = &tiles[2];
+ assert_eq!(lump.rest, 10_000);
+ assert_eq!(lump.name, "10,000 smaller items");
+ assert_eq!(lump.size, 10_000);
+ assert_eq!(lump.path, PathBuf::from("/root"), "it opens the folder it is in");
+ assert!(lump.is_dir);
+ assert_eq!(lump.aggregate, Some(Category::Image));
+ // It gets the area of what it stands for, about a tenth of the map.
+ let share = lump.rect.2 * lump.rect.3 / (198.0 * 198.0);
+ assert!((share - 0.1).abs() < 0.02, "{share}");
+ }
+
+ #[test]
+ fn a_single_small_entry_is_not_lumped() {
+ let tree = TreeNode {
+ name: "root".into(),
+ size: 1_000_001,
+ is_dir: true,
+ children: vec![file("huge.bin", 1_000_000), file("tiny.txt", 1)],
+ };
+ let mut tiles = Vec::new();
+ place(&tree, Path::new("/root"), (0.0, 0.0, 200.0, 200.0), 0, &HashMap::new(), &mut tiles);
+ assert!(tiles.iter().all(|t| t.rest == 0));
+ }
+
#[test]
fn top_folder_is_the_hovered_tiles_depth_one_ancestor() {
let tree = TreeNode {
@@ -1145,6 +1273,17 @@ mod tests {
assert_eq!(file_face((10.0, 10.0, 3.0, 40.0)), (10.0, 10.0 + FILE_GAP / 2.0, 3.0, 40.0 - FILE_GAP));
}
+ #[test]
+ fn labels_take_whichever_text_colour_reads_better() {
+ let light = cce_ui::color::TEXT_FG;
+ let dark = cce_ui::color::parse_hex_rgba_linear("#20242b").unwrap();
+ // Code's yellow, the worst case for light text, takes dark.
+ assert_eq!(label_on(Category::Code.color(), light, dark), dark);
+ // The frame itself, and a block's dimmed fill, keep light text.
+ assert_eq!(label_on(dark, light, dark), light);
+ assert_eq!(label_on(mix(Category::Video.color(), dark, AGGREGATE_DIM), light, dark), light);
+ }
+
#[test]
fn share_and_digit_grouping() {
assert_eq!(share(18, 100), "18%");