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

commitc52cd0224c5f0380e0ba794cc8dd82fbb99e86bb
parent45762a1fbb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 12:39
feat(space): frame the hovered tile's top-level folder

Deep in the map, the first question is which of the folders on screen a
tile belongs to, and a 1px frame on the dark between tiles did not
answer it. Hovering a tile below depth 1 now also draws a fainter,
2px accent frame round its top-level folder, under the hover and
selection outlines. top_folder finds it as the nearest depth-1 tile
before the hovered one, which the depth-first, parents-first tile order
makes its ancestor.

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

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

diff --git a/CLAUDE.md b/CLAUDE.md
index 5a5c1bc..6a923dc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,7 +60,7 @@ Unlike the other two pages, Space needs data no other page has: the recursive si
 `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).
+- 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.
 - Selection is held as a `PathBuf`, not an index, because a relayout renumbers every tile. Same reason `last_space_path` (not a row index) drives Space's double-click detection.
 
 ## Domain specifics
diff --git a/src/pages/space.rs b/src/pages/space.rs
index 5cef17b..72c4542 100644
--- a/src/pages/space.rs
+++ b/src/pages/space.rs
@@ -76,6 +76,10 @@ const LEGEND_GAP: f32 = 14.0;
 const FILE_GAP: f32 = 1.0;
 const FILE_GAP_MIN: f32 = 4.0;
 
+/// Opacity of the frame around the hovered tile's top-level folder: there
+/// to be found, not to compete with the hover outline itself.
+const TOP_FOLDER_ALPHA: f32 = 0.45;
+
 /// 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.
@@ -705,7 +709,14 @@ pub fn view(
         }
     }
 
-    // Selection and hover are drawn as outlines over the tiles.
+    // Selection and hover are drawn as outlines over the tiles. Under them,
+    // fainter and heavier, the top-level folder the hovered tile lies in:
+    // deep in the map, "which of my folders is this?" is the first question,
+    // and a 1px frame on the dark between tiles did not answer it.
+    if let Some(top) = state.hovered.and_then(|i| top_folder(&state.tiles, i)) {
+        let [r, g, b, _] = cce_ui::color::TEXT_ACCENT;
+        outline(&mut pc, state.tiles[top].rect, [r, g, b, TOP_FOLDER_ALPHA], 2.0);
+    }
     if let Some(sel) = &state.selected_path {
         if let Some(t) = state.tiles.iter().find(|t| &t.path == sel) {
             outline(&mut pc, t.rect, cce_ui::color::TEXT_HEADER, 2.0);
@@ -751,6 +762,16 @@ pub fn view(
     pc
 }
 
+/// The top-level folder (depth 1) holding tile `idx`, when it lies deeper
+/// than that. Tiles are flattened depth-first, parents first, so the nearest
+/// depth-1 tile before it is its ancestor.
+fn top_folder(tiles: &[Tile], idx: usize) -> Option<usize> {
+    if tiles.get(idx)?.depth < 2 {
+        return None;
+    }
+    tiles[..idx].iter().rposition(|t| t.depth == 1)
+}
+
 /// 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.
@@ -1078,6 +1099,42 @@ mod tests {
         assert!(state.tiles.iter().filter(|t| t.name != "sub").all(|t| t.aggregate.is_none()));
     }
 
+    #[test]
+    fn top_folder_is_the_hovered_tiles_depth_one_ancestor() {
+        let tree = TreeNode {
+            name: "root".into(),
+            size: 1000,
+            is_dir: true,
+            children: vec![
+                TreeNode {
+                    name: "a".into(),
+                    size: 600,
+                    is_dir: true,
+                    children: vec![TreeNode {
+                        name: "deep".into(),
+                        size: 600,
+                        is_dir: true,
+                        children: vec![file("x.bin", 400), file("y.bin", 200)],
+                    }],
+                },
+                TreeNode { name: "b".into(), size: 300, is_dir: true, children: vec![file("z.bin", 300)] },
+                file("loose.txt", 100),
+            ],
+        };
+        let mut tiles = Vec::new();
+        place(&tree, Path::new("/root"), (0.0, 0.0, 400.0, 400.0), 0, &HashMap::new(), &mut tiles);
+        let at = |name: &str| tiles.iter().position(|t| t.name == name).unwrap();
+
+        assert_eq!(top_folder(&tiles, at("y.bin")), Some(at("a")));
+        assert_eq!(top_folder(&tiles, at("deep")), Some(at("a")));
+        // Laid out after all of `a`, z must not be credited to it.
+        assert_eq!(top_folder(&tiles, at("z.bin")), Some(at("b")));
+        // At depth 1 or above there is nothing more to frame.
+        assert_eq!(top_folder(&tiles, at("a")), None);
+        assert_eq!(top_folder(&tiles, at("loose.txt")), None);
+        assert_eq!(top_folder(&tiles, 0), None);
+    }
+
     #[test]
     fn neighbouring_files_are_parted_by_a_seam() {
         // Two tiles sharing an edge at x = 50 leave FILE_GAP between faces.