git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit45762a1fbba86a097bb8fb3cdb3708c43bc1b903
parentb684886c95
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 12:37
feat(space): a seam between neighbouring files

Files of one kind side by side fused into one slab: three films read as
one, three thousand object files as a single file. Each file tile's
colour is now inset half a pixel a side (file_face), so the frame
behind it shows as a 1px seam between neighbours. A tile under 4px on
an axis keeps that axis whole rather than lose a third of it. Hit
testing still uses the whole tile, so the seam is not a dead zone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md          |  2 +-
 src/pages/space.rs | 32 +++++++++++++++++++++++++++++++-
 2 files changed, 32 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a9d4fe2..5a5c1bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,7 +57,7 @@ Browse's row list (`row_list.rs`, `RowList`) follows the DE's one scrollbar rule
 ### 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`.
 
-`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`; 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. 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.
 
 Two things to know when touching it:
 - Tiles are flattened **parents-before-children**, so the hit-test is `rposition` (last match = deepest tile).
diff --git a/src/pages/space.rs b/src/pages/space.rs
index d9daa0a..5cef17b 100644
--- a/src/pages/space.rs
+++ b/src/pages/space.rs
@@ -69,6 +69,13 @@ const SWATCH: f32 = 8.0;
 const SWATCH_GAP: f32 = 4.0;
 const LEGEND_GAP: f32 = 14.0;
 
+/// The dark seam left between neighbouring file tiles, half off each side.
+/// Without it, files of one kind side by side fused into one slab: ten
+/// films read as one, three thousand object files as a single file. A tile
+/// narrower than `FILE_GAP_MIN` keeps its full width, since the gap would eat it.
+const FILE_GAP: f32 = 1.0;
+const FILE_GAP_MIN: f32 = 4.0;
+
 /// How far a directory drawn as one block is pulled from its kind's colour
 /// toward the frame: dim enough that it never reads as one big file of that
 /// kind, bright enough that it never reads as empty.
@@ -690,7 +697,8 @@ pub fn view(
                 );
             }
         } else {
-            pc.rect(Category::of(&tile.name).color(), tx, ty, tw, th);
+            let (fx, fy, fw, fh) = file_face(tile.rect);
+            pc.rect(Category::of(&tile.name).color(), 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);
             }
@@ -743,6 +751,18 @@ pub fn view(
     pc
 }
 
+/// The part of a file tile its colour fills: the tile less half of
+/// [`FILE_GAP`] on every side, so the frame behind shows as a seam between
+/// neighbours. Each axis on its own, so a sliver keeps its length.
+fn file_face(rect: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
+    let (x, y, w, h) = rect;
+    let (gx, gy) = (
+        if w >= FILE_GAP_MIN { FILE_GAP / 2.0 } else { 0.0 },
+        if h >= FILE_GAP_MIN { FILE_GAP / 2.0 } else { 0.0 },
+    );
+    (x + gx, y + gy, w - gx * 2.0, h - gy * 2.0)
+}
+
 /// The hovered tile, for the footer: its path under the scanned directory
 /// (cut from the FRONT, so the name survives), size, share of the whole, and
 /// for a block, what fills it.
@@ -1058,6 +1078,16 @@ mod tests {
         assert!(state.tiles.iter().filter(|t| t.name != "sub").all(|t| t.aggregate.is_none()));
     }
 
+    #[test]
+    fn neighbouring_files_are_parted_by_a_seam() {
+        // Two tiles sharing an edge at x = 50 leave FILE_GAP between faces.
+        let a = file_face((0.0, 0.0, 50.0, 40.0));
+        let b = file_face((50.0, 0.0, 50.0, 40.0));
+        assert!((b.0 - (a.0 + a.2) - FILE_GAP).abs() < 1e-4);
+        // A sliver keeps its thin axis whole and loses the gap only along its length.
+        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 share_and_digit_grouping() {
         assert_eq!(share(18, 100), "18%");