file manager
git clone https://git.lucas.co/cce-files.git
feat: Space — a squarified treemap of what's using the disk
A third view alongside List and Graph. Every file in the subtree is one
rectangle sized by its bytes and nested inside its directory's, so the
thing filling the disk is the biggest shape on screen no matter how deep
it is buried — the question neither of the other two views can answer,
since both only ever show one directory level and a file's own size.
services/scan.rs walks the tree on the blocking pool (a full scan is far
too long to sit on an async worker). It never follows symlinks, so a link
cannot pull its target's bytes into a subtree total or loop the walk, and
it never crosses a device boundary, which is what keeps a scan of / out
of /proc, /sys and any mounted drive. Superseded scans are cancelled
through a shared flag rather than left to run unwatched.
The layout is squarified (Bruls/Huizing/van Wijk): rows fill along the
rect's shorter side and grow only while the worst aspect ratio in them
improves. That keeps tiles near-square, which is the whole point — a
slice-and-dice layout gives slivers whose areas cannot be compared by
eye. Tiles below a few px are culled instead of emitted, which is what
actually bounds tile count on a large tree.
Tiles flatten parents-before-children so the hit-test takes the last
match (the deepest tile). Selection and double-click track a PathBuf
rather than an index, because a relayout renumbers every tile.
Also fixes the dropdown's page mapping, which was `== 0 { Browse } else
{ Network }` — it would have sent this new third entry to Network.
Verified live against cce-compositor: 11.4 M in 477 tiles, sizes exact
to the byte (config.rs 109.9 K, libscenefx-0.5.a 369.4 K, src 1.3 M),
hover/selection outlines, preview-pane integration, and double-click
drill-in re-rooting and rescanning the map.
CLAUDE.md | 14 +-
src/lib.rs | 1 +
src/main.rs | 143 +++++++++-
src/pages/mod.rs | 7 +-
src/pages/space.rs | 774 +++++++++++++++++++++++++++++++++++++++++++++++++++
src/services/fs.rs | 45 +++
src/services/mod.rs | 1 +
src/services/scan.rs | 263 +++++++++++++++++
8 files changed, 1240 insertions(+), 8 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5d20901..2f71132 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,10 +41,20 @@ Shared, page-independent formatting helpers (`format_size`, `format_permissions`
### Widget hierarchy uses raw pointers
`BrowseContainer` and `NetworkContainer` are composite widgets whose children (`breadcrumb`, `list_box`, `save_name_box`, `graph`) are held as `*mut dyn Element` and wired up in `set_parent` via `ctx.register_widget` / `ctx.link_ids`. This mirrors the cce-ui widget model; the containers are `unsafe impl Send/Sync`. When adding a child widget to a container, replicate the register + link + `set_parent` sequence, and clear it in `rebuild_layout`'s teardown block.
-### Two pages, one preview
+### Three pages, one preview
- **Browse** — the `List` widget (columnar, integrated search box) plus a `Breadcrumb`. The right pane is a `Preview` widget, split from the list by a `SplitBox`.
- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview.
-The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`).
+- **Space** — a GrandPerspective-style treemap of the whole subtree, also split against the preview.
+The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`). The dropdown's labels name the *visualization* ("List"/"Graph"/"Space") and are a separate list from `Page::label()` ("Browse"/"Network"/"Space") — but **its order must track `Page::ALL`**, because the selected index is indexed straight into it.
+
+### 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.
+
+Two things to know when touching it:
+- Tiles are flattened **parents-before-children**, so the hit-test is `rposition` (last match = deepest tile).
+- 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/lib.rs b/src/lib.rs
index 620605f..7c3ce77 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,6 +11,7 @@ pub enum Message {
SwitchPage(Page),
Browse(pages::browse::BrowseMessage),
Preview(pages::preview::PreviewMessage),
+ Space(pages::space::SpaceMessage),
SelectOpen,
SelectCancel,
PromptOpenWith(std::path::PathBuf),
diff --git a/src/main.rs b/src/main.rs
index acba659..05664dc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -298,6 +298,7 @@ struct FilesystemApp {
current_page: Page,
browse: pages::browse::BrowseState,
network: pages::network::NetworkState,
+ space: pages::space::SpaceState,
preview: cce_files::preview_pane::PreviewPane,
// Command-line chooser options
@@ -329,6 +330,11 @@ struct FilesystemApp {
open_with_dialog: Option<(std::path::PathBuf, cce_ui::widget::Adapted<cce_ui::widget::TextBox>)>,
browse_split: SplitPane,
network_split: SplitPane,
+ space_split: SplitPane,
+ // Space's double-click is tracked by path, not row index: its tiles are
+ // renumbered by every relayout, so an index would not survive a resize.
+ last_space_click_time: std::time::Instant,
+ last_space_path: Option<std::path::PathBuf>,
last_click_time: std::time::Instant,
last_clicked_idx: Option<usize>,
keys: BrowseKeys,
@@ -338,6 +344,21 @@ struct FilesystemApp {
// ── Layout Rebuild ──────────────────────────────────────────────────
impl FilesystemApp {
+ /// Kick off a subtree scan if the Space page is showing a directory it has
+ /// not scanned. Cheap to call — it no-ops off the Space page, and while a
+ /// scan for the same directory is already running.
+ fn ensure_space_scan(&mut self) {
+ if self.current_page != Page::Space {
+ return;
+ }
+ let dir = self.browse.current_dir.clone();
+ if !self.space.needs_scan(&dir) {
+ return;
+ }
+ let cancel = self.space.begin_scan(&dir);
+ self.fs_service.send(services::fs::FsRequest::ScanTree(dir, cancel));
+ }
+
fn start_watching(&mut self, path: std::path::PathBuf) {
use tokio::sync::mpsc;
use std::time::Duration;
@@ -408,6 +429,7 @@ impl FilesystemApp {
self.browse.breadcrumb.clear_children(&mut self.ui_context); self.browse.breadcrumb.set_parent(None, &mut self.ui_context);
self.network.breadcrumb.clear_children(&mut self.ui_context); self.network.breadcrumb.set_parent(None, &mut self.ui_context);
self.network.graph.clear_children(&mut self.ui_context); self.network.graph.set_parent(None, &mut self.ui_context);
+ self.space.breadcrumb.clear_children(&mut self.ui_context); self.space.breadcrumb.set_parent(None, &mut self.ui_context);
if let Some((_, textbox)) = &mut self.open_with_dialog {
textbox.clear_children(&mut self.ui_context);
textbox.set_parent(None, &mut self.ui_context);
@@ -447,6 +469,7 @@ impl FilesystemApp {
match self.current_page {
Page::Browse => self.browse_split.set_rect(browse_x, content_y, usable_w, content_h),
Page::Network => self.network_split.set_rect(browse_x, content_y, usable_w, content_h),
+ Page::Space => self.space_split.set_rect(browse_x, content_y, usable_w, content_h),
}
if let Some((_, textbox)) = &mut self.open_with_dialog {
@@ -481,6 +504,7 @@ impl FilesystemApp {
let split = match self.current_page {
Page::Browse => &self.browse_split,
Page::Network => &self.network_split,
+ Page::Space => &self.space_split,
};
let (dx, dy, dw, dh, dc) = split.divider_quad();
plain_pc.rects.push((dc, dx, dy, dw, dh, 0.0, (true, true, true, true)));
@@ -541,6 +565,16 @@ impl FilesystemApp {
pc.reliefs.extend(network_pc.reliefs);
pc.images.extend(network_pc.images);
}
+ Page::Space => {
+ let (sx, sy, sw, sh) = self.space_split.left_rect();
+ let space_pc = pages::space::view(&mut self.space, &self.browse, &mut self.view_dropdown, sx, sy, sw, sh, &mut self.ui_context);
+
+ pc.rects.extend(space_pc.rects);
+ pc.texts.extend(space_pc.texts);
+ pc.buttons.extend(space_pc.buttons);
+ pc.reliefs.extend(space_pc.reliefs);
+ pc.images.extend(space_pc.images);
+ }
}
// Draw bottom selection bar if select_mode is enabled. It lives below the
@@ -927,6 +961,17 @@ impl Application for FilesystemApp {
if self.network_split.dragging || self.network_split.hovered {
return false;
}
+ } else if self.current_page == Page::Space {
+ if self.space_split.dragging || self.space_split.hovered {
+ return false;
+ }
+ // Same reasoning as the List above: without this veto every press
+ // on a tile starts a compositor window move and the app never sees
+ // the click.
+ let (mx, my, mw, mh) = self.space.map_rect;
+ if px >= mx && px <= mx + mw && py >= my && py <= my + mh {
+ return false;
+ }
}
// 5. Root Backplate dissolved: the surface itself is the movable plate; drag
// anywhere a drag-blocking widget isn't.
@@ -947,8 +992,11 @@ impl Application for FilesystemApp {
let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
let paginator = cce_ui::widget::Paginator::new(pages_names);
+ // These name the visualization rather than the page, so they are not
+ // Page::label(). Order MUST track Page::ALL — the selected index is
+ // indexed straight into it when the dropdown changes.
let view_dropdown = cce_ui::widget::Dropdown::new(
- vec!["List".to_string(), "Graph".to_string()],
+ vec!["List".to_string(), "Graph".to_string(), "Space".to_string()],
0,
).with_font_family(&cce_ui::layout::list_font_parsed().0);
@@ -959,6 +1007,7 @@ impl Application for FilesystemApp {
current_page: Page::Browse,
browse,
network: pages::network::NetworkState::default(),
+ space: pages::space::SpaceState::default(),
preview: Default::default(),
select_mode,
select_directory,
@@ -992,6 +1041,9 @@ impl Application for FilesystemApp {
open_with_dialog: None,
browse_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::backplate_gap()),
network_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::backplate_gap()),
+ space_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::backplate_gap()),
+ last_space_click_time: std::time::Instant::now(),
+ last_space_path: None,
last_click_time: std::time::Instant::now(),
last_clicked_idx: None,
keys: BrowseKeys::load(),
@@ -1046,6 +1098,9 @@ impl Application for FilesystemApp {
let page_idx = Page::ALL.iter().position(|&p| p == page).unwrap_or(0);
self.paginator.set_selected_page(page_idx);
self.view_dropdown.selected = page_idx;
+ // Switching to Space is what triggers the first scan — it is
+ // far too expensive to run for a page nobody is looking at.
+ self.ensure_space_scan();
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -1075,6 +1130,8 @@ impl Application for FilesystemApp {
if let Some(path) = is_directory_loaded {
self.start_watching(path);
+ // Navigating re-scans the new subtree when Space is up.
+ self.ensure_space_scan();
}
// If NavigateTo or SelectEntry happened, update Preview path
@@ -1115,6 +1172,11 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
}
+ Message::Space(msg) => {
+ pages::space::update(&mut self.space, msg);
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
Message::SelectOpen => {
if self.select_directory {
let selected_path = self.browse.selected_path();
@@ -1407,6 +1469,10 @@ impl Application for FilesystemApp {
if self.network_split.cursor_moved(pos.x, pos.y) {
changed = true;
}
+ } else if self.current_page == Page::Space {
+ if self.space_split.cursor_moved(pos.x, pos.y) {
+ changed = true;
+ }
}
if !self.select_mode {
@@ -1465,6 +1531,21 @@ impl Application for FilesystemApp {
changed = true;
}
}
+ } else if self.current_page == Page::Space {
+ {
+ let root = self.space.breadcrumb.id();
+ if self.ui_context.propagate_event(&mv, root) {
+ changed = true;
+ }
+ }
+ // Tile hover drives both the highlight outline and the footer
+ // readout, so only a change of tile is worth a rebuild — a move
+ // within one tile repaints nothing.
+ let hovered = self.space.tile_at(pos.x, pos.y);
+ if hovered != self.space.hovered {
+ self.space.hovered = hovered;
+ changed = true;
+ }
}
// Repaint only when the hovered page button actually changes. Page-button hover is
@@ -1587,8 +1668,11 @@ impl Application for FilesystemApp {
}
if button == MouseButton::Right && state == ElementState::Pressed {
- let is_browse = self.current_page == Page::Browse;
- let breadcrumb = if is_browse { &mut self.browse.breadcrumb } else { &mut self.network.breadcrumb };
+ let breadcrumb = match self.current_page {
+ Page::Browse => &mut self.browse.breadcrumb,
+ Page::Network => &mut self.network.breadcrumb,
+ Page::Space => &mut self.space.breadcrumb,
+ };
if breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
let root = breadcrumb.id();
@@ -1693,7 +1777,13 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
if self.view_dropdown.take_change() {
- let new_page = if self.view_dropdown.selected == 0 { Page::Browse } else { Page::Network };
+ // Indexed off Page::ALL rather than hand-mapped: the old
+ // `== 0 { Browse } else { Network }` silently sent every
+ // entry past the first to Network.
+ let new_page = Page::ALL
+ .get(self.view_dropdown.selected)
+ .copied()
+ .unwrap_or(Page::Browse);
return Some(Message::SwitchPage(new_page));
}
return None;
@@ -1706,6 +1796,7 @@ impl Application for FilesystemApp {
let split = match self.current_page {
Page::Browse => &mut self.browse_split,
Page::Network => &mut self.network_split,
+ Page::Space => &mut self.space_split,
};
if state == ElementState::Pressed {
if split.press(pos.x, pos.y) {
@@ -1854,6 +1945,47 @@ impl Application for FilesystemApp {
}
}
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ } else if self.current_page == Page::Space {
+ if button == MouseButton::Left && state == ElementState::Pressed {
+ if self.space.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
+ if { let root = self.space.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
+ if let Some(seg) = self.space.breadcrumb.path_click() {
+ let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
+ self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
+ changed = true;
+ }
+ }
+ } else if let Some(idx) = self.space.tile_at(pos.x, pos.y) {
+ let tile_path = self.space.tiles[idx].path.clone();
+ let is_dir = self.space.tiles[idx].is_dir;
+
+ // Same temporal double-click as the Browse list — the
+ // toolkit does not deliver a double-click event.
+ let now = std::time::Instant::now();
+ let is_double = self.last_space_path.as_ref() == Some(&tile_path)
+ && now.duration_since(self.last_space_click_time).as_millis() < 500;
+ self.last_space_click_time = now;
+ self.last_space_path = Some(tile_path.clone());
+
+ if is_double {
+ if is_dir {
+ // Navigating re-roots the map: ReadDirectory moves
+ // current_dir, and ensure_space_scan rescans it.
+ self.fs_service.send(services::fs::FsRequest::ReadDirectory(tile_path));
+ } else {
+ services::fs::open_file(&tile_path);
+ }
+ } else {
+ self.space.selected_path = Some(tile_path.clone());
+ self.fs_service.send(services::fs::FsRequest::ReadPreview(tile_path));
+ }
+ changed = true;
+ }
+ }
if changed {
*needs_rebuild = true;
self.needs_rebuild = true;
@@ -1891,7 +2023,8 @@ impl Application for FilesystemApp {
}
fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
- if self.current_page == Page::Browse || self.current_page == Page::Network {
+ // Every page shares the preview pane on the right.
+ if matches!(self.current_page, Page::Browse | Page::Network | Page::Space) {
// The pane hit-tests its own laid-out rect and consumes any wheel
// over its content region, scrolled or not.
if let Some(changed) = self.preview.wheel(delta, pos.x as f32, pos.y as f32) {
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index b65ccdc..daad095 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,6 +1,7 @@
pub mod browse;
pub mod preview;
pub mod network;
+pub mod space;
use cce_ui::layout::RenderTarget;
@@ -8,18 +9,21 @@ use cce_ui::layout::RenderTarget;
pub enum Page {
Browse,
Network,
+ Space,
}
impl Page {
- pub const ALL: [Page; 2] = [
+ pub const ALL: [Page; 3] = [
Page::Browse,
Page::Network,
+ Page::Space,
];
pub fn label(self) -> &'static str {
match self {
Page::Browse => "Browse",
Page::Network => "Network",
+ Page::Space => "Space",
}
}
@@ -27,6 +31,7 @@ impl Page {
match self {
Page::Browse => "📁",
Page::Network => "🌐",
+ Page::Space => "▦",
}
}
}
diff --git a/src/pages/space.rs b/src/pages/space.rs
new file mode 100644
index 0000000..084e3df
--- /dev/null
+++ b/src/pages/space.rs
@@ -0,0 +1,774 @@
+//! The Space page — a GrandPerspective-style treemap of disk usage.
+//!
+//! Every file in the scanned subtree is one rectangle whose *area* is its size,
+//! nested inside its directory's rectangle. That is the whole idea: the thing
+//! eating your disk is the biggest shape on screen, however deep it is buried.
+//!
+//! Three pieces live here:
+//! - [`squarify`], the Bruls/Huizing/van Wijk squarified layout, which keeps
+//! tiles near-square instead of the slivers a naive slice-and-dice produces;
+//! - [`SpaceState::relayout`], which walks the scanned tree recursively and
+//! flattens it into a [`Tile`] list, culling anything too small to see;
+//! - [`view`], which paints that list into a `PageContent`.
+//!
+//! The scan itself is `services::scan`, driven from `main.rs` — this module is
+//! given a finished tree.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use cce_ui::widget::{Adapted, Breadcrumb, PathController};
+
+use crate::pages::PageContent;
+use crate::pages::browse::BrowseState;
+use crate::services::scan::TreeNode;
+use crate::util::format_size;
+
+/// Below this, in either dimension, a tile is too small to read and is not
+/// emitted at all — its bytes stay accounted for in the parent's area, which
+/// still shows as filled. This is what bounds tile count on a large tree far
+/// more effectively than [`MAX_TILES`].
+const MIN_TILE: f32 = 3.0;
+
+/// A directory smaller than this is drawn as one aggregate block rather than
+/// recursed into: below it the frame and padding would eat the children.
+const MIN_RECURSE: f32 = 20.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;
+
+/// Inset applied to a directory's rect before laying out its children — the
+/// gap that makes nesting legible.
+const DIR_PAD: f32 = 1.0;
+
+/// Height reserved at the top of a directory's rect for its name, when the
+/// rect is big enough to bother.
+const DIR_LABEL_H: f32 = 13.0;
+
+/// Directory rects at least this tall get a name strip.
+const DIR_LABEL_MIN: f32 = 46.0;
+
+/// File tiles at least this big get their name drawn inside them.
+const FILE_LABEL_MIN_W: f32 = 44.0;
+const FILE_LABEL_MIN_H: f32 = 15.0;
+
+/// Rows reserved at the bottom of the pane for the hover/summary readout.
+const FOOTER_H: f32 = 18.0;
+
+// ── File-type colors ────────────────────────────────────────────────
+
+/// The category a file's extension puts it in. Area says how big a thing is;
+/// hue says what kind of thing it is, which is how you tell "my photo library"
+/// from "one enormous VM image" at a glance.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Category {
+ Image,
+ Video,
+ Audio,
+ Code,
+ Document,
+ Archive,
+ Binary,
+ Other,
+}
+
+impl Category {
+ pub fn of(name: &str) -> Category {
+ // A leading-dot name with no other dot (`.bashrc`) has no extension —
+ // splitting on the last dot would otherwise read "bashrc" as one.
+ let ext = name
+ .rsplit_once('.')
+ .filter(|(stem, _)| !stem.is_empty())
+ .map(|(_, e)| e.to_ascii_lowercase());
+ match ext.as_deref() {
+ Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff" | "tif"
+ | "svg" | "svgz" | "psd" | "xcf" | "raw" | "cr2" | "nef" | "heic" | "avif") => Category::Image,
+ Some("mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" | "mpg" | "mpeg" | "wmv"
+ | "flv" | "ogv") => Category::Video,
+ Some("mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" | "wma" | "aiff"
+ | "mid" | "midi") => Category::Audio,
+ Some("rs" | "c" | "h" | "cpp" | "hpp" | "cc" | "py" | "js" | "ts" | "jsx" | "tsx"
+ | "go" | "java" | "kt" | "rb" | "php" | "swift" | "hs" | "ml" | "lua" | "sh"
+ | "bash" | "zsh" | "fish" | "vim" | "el" | "scm" | "clj" | "ex" | "erl"
+ | "sql" | "html" | "css" | "scss" | "glsl" | "cl" | "wgsl") => Category::Code,
+ Some("txt" | "md" | "rst" | "org" | "pdf" | "doc" | "docx" | "odt" | "rtf"
+ | "xls" | "xlsx" | "ods" | "csv" | "tsv" | "ppt" | "pptx" | "odp" | "epub"
+ | "mobi" | "tex" | "json" | "toml" | "yaml" | "yml" | "xml" | "kdl"
+ | "ini" | "conf" | "cfg" | "log") => Category::Document,
+ Some("zip" | "tar" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "tgz" | "txz"
+ | "iso" | "img" | "dmg" | "deb" | "rpm" | "pkg" | "apk" | "jar" | "whl") => Category::Archive,
+ Some("so" | "a" | "o" | "dll" | "dylib" | "exe" | "bin" | "elf" | "class"
+ | "pyc" | "rlib" | "wasm" | "qcow2" | "vdi" | "vmdk") => Category::Binary,
+ _ => Category::Other,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Category::Image => "Images",
+ Category::Video => "Video",
+ Category::Audio => "Audio",
+ Category::Code => "Code",
+ Category::Document => "Documents",
+ Category::Archive => "Archives",
+ Category::Binary => "Binaries",
+ Category::Other => "Other",
+ }
+ }
+
+ /// Written as sRGB hex and converted the same way config colors are, so
+ /// these sit in the same space as everything else the renderer is handed.
+ pub fn color(self) -> [f32; 4] {
+ let hex = match self {
+ Category::Image => "#4f9fd1",
+ Category::Video => "#9b6bd6",
+ Category::Audio => "#4fb98a",
+ Category::Code => "#dfb341",
+ Category::Document => "#d1685f",
+ Category::Archive => "#c77e3e",
+ Category::Binary => "#b85c9e",
+ Category::Other => "#6b7280",
+ };
+ cce_ui::color::parse_hex_rgba_linear(hex).unwrap_or([0.4, 0.4, 0.45, 1.0])
+ }
+}
+
+/// Every category, for the legend.
+pub const CATEGORIES: [Category; 8] = [
+ Category::Image,
+ Category::Video,
+ Category::Audio,
+ Category::Code,
+ Category::Document,
+ Category::Archive,
+ Category::Binary,
+ Category::Other,
+];
+
+// ── Tiles ───────────────────────────────────────────────────────────
+
+/// One laid-out rectangle. Directories come before their children in the list,
+/// so a reverse scan finds the deepest tile under a point first.
+#[derive(Debug, Clone)]
+pub struct Tile {
+ pub path: PathBuf,
+ pub name: String,
+ pub size: u64,
+ pub is_dir: bool,
+ pub depth: u32,
+ /// (x, y, w, h) in window coordinates.
+ pub rect: (f32, f32, f32, f32),
+}
+
+impl Tile {
+ fn contains(&self, x: f32, y: f32) -> bool {
+ let (rx, ry, rw, rh) = self.rect;
+ x >= rx && x < rx + rw && y >= ry && y < ry + rh
+ }
+}
+
+// ── Squarified layout ───────────────────────────────────────────────
+
+/// Lay `values` (which MUST be sorted descending and strictly positive) into
+/// `rect`, returning one rect per value in the same order.
+///
+/// This is the squarified treemap of Bruls, Huizing & van Wijk (2000): fill
+/// the rect row by row along its shorter side, growing each row only while
+/// doing so improves the worst aspect ratio in it. The result is tiles close
+/// to square, which is what makes areas visually comparable — a naive
+/// slice-and-dice gives slivers you cannot compare at all.
+fn squarify(values: &[f64], rect: (f32, f32, f32, f32)) -> Vec<(f32, f32, f32, f32)> {
+ let mut out = Vec::with_capacity(values.len());
+ let (rx, ry, rw, rh) = rect;
+ let total: f64 = values.iter().sum();
+ if values.is_empty() || total <= 0.0 || rw <= 0.0 || rh <= 0.0 {
+ return vec![(0.0, 0.0, 0.0, 0.0); values.len()];
+ }
+
+ // Work in pixel² so a row's thickness is just its area over its length.
+ let scale = (rw as f64) * (rh as f64) / total;
+ let areas: Vec<f64> = values.iter().map(|v| v * scale).collect();
+
+ let (mut x, mut y, mut w, mut h) = (rx as f64, ry as f64, rw as f64, rh as f64);
+ let mut i = 0;
+
+ while i < areas.len() {
+ if w <= 0.0 || h <= 0.0 {
+ out.extend(std::iter::repeat((0.0, 0.0, 0.0, 0.0)).take(areas.len() - i));
+ break;
+ }
+
+ // Rows run along the shorter side; that is the whole trick.
+ let horizontal = w >= h;
+ let side = if horizontal { h } else { w };
+
+ // Grow the row while the worst aspect ratio in it keeps improving.
+ let mut end = i;
+ let mut sum = 0.0;
+ let mut best = f64::INFINITY;
+ while end < areas.len() {
+ let next_sum = sum + areas[end];
+ // Descending order means the row's max is its first item and its
+ // min is the one we are considering adding.
+ let worst = worst_ratio(next_sum, areas[i], areas[end], side);
+ if end > i && worst > best {
+ break;
+ }
+ best = worst;
+ sum = next_sum;
+ end += 1;
+ }
+
+ // Place the row.
+ let thickness = (sum / side).min(if horizontal { w } else { h });
+ let mut offset = 0.0;
+ for k in i..end {
+ let len = if sum > 0.0 { areas[k] / sum * side } else { 0.0 };
+ let r = if horizontal {
+ (x, y + offset, thickness, len)
+ } else {
+ (x + offset, y, len, thickness)
+ };
+ out.push((r.0 as f32, r.1 as f32, r.2 as f32, r.3 as f32));
+ offset += len;
+ }
+
+ if horizontal {
+ x += thickness;
+ w -= thickness;
+ } else {
+ y += thickness;
+ h -= thickness;
+ }
+ i = end;
+ }
+
+ out
+}
+
+/// Worst (largest) aspect ratio produced by a row of total area `sum` laid
+/// along a side of length `side`, containing items of area `max` and `min`.
+fn worst_ratio(sum: f64, max: f64, min: f64, side: f64) -> f64 {
+ if sum <= 0.0 || side <= 0.0 || min <= 0.0 {
+ return f64::INFINITY;
+ }
+ let s2 = sum * sum;
+ let w2 = side * side;
+ (w2 * max / s2).max(s2 / (w2 * min))
+}
+
+// ── Messages ────────────────────────────────────────────────────────
+
+/// Results coming back from a `FsRequest::ScanTree`. Each carries the
+/// directory it is about, because a scan that has been superseded can still
+/// deliver messages after the app has moved on.
+#[derive(Debug, Clone)]
+pub enum SpaceMessage {
+ Progress { dir: PathBuf, files: u64, bytes: u64 },
+ Scanned { dir: PathBuf, tree: TreeNode },
+ Failed(String),
+}
+
+pub fn update(state: &mut SpaceState, msg: SpaceMessage) {
+ match msg {
+ SpaceMessage::Progress { dir, files, bytes } => {
+ // Late progress from a scan we no longer care about.
+ if !state.scanning || state.scanned_dir != dir {
+ return;
+ }
+ state.scan_files = files;
+ state.scan_bytes = bytes;
+ }
+ SpaceMessage::Scanned { dir, tree } => {
+ if state.scanned_dir != dir {
+ return;
+ }
+ state.scan_finished(dir, tree);
+ }
+ SpaceMessage::Failed(err) => state.scan_failed(err),
+ }
+}
+
+// ── Page state ──────────────────────────────────────────────────────
+
+pub struct SpaceState {
+ pub breadcrumb: Adapted<Breadcrumb>,
+ /// The directory the current `tree` describes. Empty until a scan lands.
+ pub scanned_dir: PathBuf,
+ pub tree: Option<TreeNode>,
+ pub tiles: Vec<Tile>,
+ /// Rect the current `tiles` were laid out for — a resize invalidates them.
+ laid_out: (f32, f32, f32, f32),
+ /// The map region as of the last `view`. Input handlers need it to tell a
+ /// press on the treemap from one on the window backplate behind it.
+ pub map_rect: (f32, f32, f32, f32),
+ pub hovered: Option<usize>,
+ /// Selection is held by path, not index: a relayout renumbers every tile.
+ pub selected_path: Option<PathBuf>,
+ pub scanning: bool,
+ pub scan_files: u64,
+ pub scan_bytes: u64,
+ /// Raised to abandon the in-flight scan when a newer one supersedes it.
+ pub cancel: Arc<AtomicBool>,
+ pub error: Option<String>,
+}
+
+impl Default for SpaceState {
+ fn default() -> Self {
+ let mut breadcrumb = Breadcrumb::new();
+ breadcrumb.set_network_opacity(0.95);
+ Self {
+ breadcrumb,
+ scanned_dir: PathBuf::new(),
+ tree: None,
+ tiles: Vec::new(),
+ laid_out: (0.0, 0.0, 0.0, 0.0),
+ map_rect: (0.0, 0.0, 0.0, 0.0),
+ hovered: None,
+ selected_path: None,
+ scanning: false,
+ scan_files: 0,
+ scan_bytes: 0,
+ cancel: Arc::new(AtomicBool::new(false)),
+ error: None,
+ }
+ }
+}
+
+impl SpaceState {
+ /// True when `dir` is not what the current tree describes — the caller
+ /// should kick off a scan.
+ pub fn needs_scan(&self, dir: &Path) -> bool {
+ !self.scanning && (self.tree.is_none() || self.scanned_dir != dir)
+ }
+
+ /// Abandon any in-flight scan and arm a fresh cancel token for the next
+ /// one. Returns the token the new scan should carry.
+ pub fn begin_scan(&mut self, dir: &Path) -> Arc<AtomicBool> {
+ self.cancel.store(true, Ordering::Relaxed);
+ self.cancel = Arc::new(AtomicBool::new(false));
+ self.scanning = true;
+ self.scan_files = 0;
+ self.scan_bytes = 0;
+ self.error = None;
+ self.tree = None;
+ self.tiles.clear();
+ self.hovered = None;
+ self.scanned_dir = dir.to_path_buf();
+ self.laid_out = (0.0, 0.0, 0.0, 0.0);
+ self.cancel.clone()
+ }
+
+ pub fn scan_finished(&mut self, dir: PathBuf, tree: TreeNode) {
+ self.scanning = false;
+ self.scanned_dir = dir;
+ self.tree = Some(tree);
+ self.tiles.clear();
+ self.laid_out = (0.0, 0.0, 0.0, 0.0);
+ self.hovered = None;
+ }
+
+ pub fn scan_failed(&mut self, err: String) {
+ self.scanning = false;
+ self.tree = None;
+ self.tiles.clear();
+ self.error = Some(err);
+ }
+
+ /// The deepest tile under the cursor, which is the one the user means.
+ pub fn tile_at(&self, x: f32, y: f32) -> Option<usize> {
+ self.tiles.iter().rposition(|t| t.contains(x, y))
+ }
+
+ /// Recompute tiles for `rect` if the tree or the rect has changed.
+ pub fn relayout(&mut self, rect: (f32, f32, f32, f32)) {
+ if self.laid_out == rect && !self.tiles.is_empty() {
+ return;
+ }
+ self.tiles.clear();
+ self.laid_out = rect;
+ let Some(tree) = self.tree.take() else { return };
+ let root = self.scanned_dir.clone();
+ place(&tree, &root, rect, 0, &mut self.tiles);
+ self.tree = Some(tree);
+ // A relayout renumbers everything; the stale hover index would point
+ // at an unrelated tile.
+ self.hovered = None;
+ }
+}
+
+/// Recursively lay `node` into `rect`, appending tiles. The node's own tile is
+/// pushed before its children so a reverse hit-test finds the deepest first.
+fn place(node: &TreeNode, path: &Path, rect: (f32, f32, f32, f32), depth: u32, out: &mut Vec<Tile>) {
+ let (x, y, w, h) = rect;
+ if w < MIN_TILE || h < MIN_TILE || out.len() >= MAX_TILES {
+ return;
+ }
+
+ out.push(Tile {
+ path: path.to_path_buf(),
+ name: node.name.clone(),
+ size: node.size,
+ is_dir: node.is_dir,
+ depth,
+ rect,
+ });
+
+ if !node.is_dir || node.children.is_empty() {
+ return;
+ }
+ // Too small to subdivide usefully: it stays one aggregate block.
+ if w < MIN_RECURSE || h < MIN_RECURSE {
+ return;
+ }
+
+ // Inset for the frame, plus a name strip when there is room for one.
+ let label = h >= DIR_LABEL_MIN && w >= FILE_LABEL_MIN_W;
+ let top = DIR_PAD + if label { DIR_LABEL_H } else { 0.0 };
+ let inner = (
+ x + DIR_PAD,
+ y + top,
+ (w - DIR_PAD * 2.0).max(0.0),
+ (h - top - DIR_PAD).max(0.0),
+ );
+ if inner.2 < MIN_TILE || inner.3 < MIN_TILE {
+ return;
+ }
+
+ // Zero-byte children have no area to occupy and would divide by zero in
+ // the aspect-ratio test; they are simply not drawn.
+ let kids: Vec<&TreeNode> = node.children.iter().filter(|c| c.size > 0).collect();
+ 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, out);
+ }
+}
+
+// ── View ────────────────────────────────────────────────────────────
+
+pub fn view(
+ state: &mut SpaceState,
+ browse: &BrowseState,
+ view_dropdown: &mut Adapted<cce_ui::widget::Dropdown>,
+ cx: f32,
+ cy: f32,
+ cw: f32,
+ ch: f32,
+ ctx: &mut cce_ui::context::UiContext,
+) -> PageContent {
+ let mut pc = PageContent::new();
+
+ // Breadcrumb + view dropdown, mirroring the Network page's header so the
+ // two views line up when you switch between them.
+ let dropdown_w = 120.0;
+ let breadcrumb_w = cw - 16.0 - dropdown_w - 12.0;
+ cce_ui::layout::render_widget(&mut pc, &mut state.breadcrumb, cx + 4.0, cy + 6.0, breadcrumb_w, 24.0, ctx);
+ {
+ let r = cce_ui::layout::breadcrumb_corner_radius();
+ pc.relief_recessed(cx + 4.0, cy + 6.0, breadcrumb_w, 24.0, r);
+ let rect = cce_ui::scene::layout::Rect { x: cx + 4.0, y: cy + 6.0, width: breadcrumb_w, height: 24.0 };
+ for (sx, sy, sw, sh) in state.breadcrumb.segment_boxes(rect) {
+ pc.relief_raised(sx, sy, sw, sh, r.min(sh * 0.5));
+ }
+ }
+ cce_ui::layout::render_widget(&mut pc, view_dropdown, cx + 4.0 + breadcrumb_w + 12.0, cy + 6.0, dropdown_w, 24.0, ctx);
+
+ let mut segments = Vec::new();
+ for component in browse.current_dir.components() {
+ let s = component.as_os_str().to_string_lossy().to_string();
+ if s != "/" && !s.is_empty() {
+ segments.push(s);
+ }
+ }
+ state.breadcrumb.set_path(&segments);
+
+ // The map occupies everything below the header, less the footer readout.
+ let map = (cx, cy + 34.0, cw, (ch - 34.0 - FOOTER_H).max(0.0));
+ state.map_rect = map;
+ let bg = cce_ui::color::list_bg_color();
+ pc.rect(bg, map.0, map.1, map.2, map.3);
+ pc.relief_recessed(map.0, map.1, map.2, map.3, cce_ui::layout::plate_corner_radius());
+
+ let text_dim = cce_ui::color::TEXT_DIM;
+ let text_fg = cce_ui::color::TEXT_FG;
+
+ if let Some(err) = &state.error {
+ pc.text(err, map.0 + 12.0, map.1 + 12.0, 11.0, text_dim);
+ return pc;
+ }
+
+ if state.scanning {
+ let msg = format!(
+ "Scanning {} — {} files, {}",
+ browse.current_dir.display(),
+ state.scan_files,
+ format_size(state.scan_bytes)
+ );
+ pc.text(&msg, map.0 + 12.0, map.1 + 12.0, 11.0, text_dim);
+ return pc;
+ }
+
+ // Inset one pixel so tiles do not sit on top of the well's rim.
+ state.relayout((map.0 + 1.0, map.1 + 1.0, (map.2 - 2.0).max(0.0), (map.3 - 2.0).max(0.0)));
+
+ if state.tiles.is_empty() {
+ pc.text("Nothing to show — the directory is empty.", map.0 + 12.0, map.1 + 12.0, 11.0, text_dim);
+ return pc;
+ }
+
+ let frame = cce_ui::color::parse_hex_rgba_linear("#20242b").unwrap_or([0.1, 0.1, 0.12, 1.0]);
+ for tile in &state.tiles {
+ let (tx, ty, tw, th) = tile.rect;
+ if tile.is_dir {
+ // A directory paints only its frame — its children cover the
+ // inside, and where they do not, the gap reads as slack space.
+ pc.rect(frame, tx, ty, tw, th);
+ 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,
+ );
+ }
+ } else {
+ pc.rect(Category::of(&tile.name).color(), tx, ty, tw, th);
+ 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);
+ }
+ }
+ }
+
+ // Selection and hover are drawn as outlines over the tiles.
+ 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);
+ }
+ }
+ if let Some(idx) = state.hovered {
+ if let Some(t) = state.tiles.get(idx) {
+ outline(&mut pc, t.rect, cce_ui::color::TEXT_ACCENT, 1.0);
+ }
+ }
+
+ // Footer: whatever the cursor is over, else the total.
+ let footer_y = map.1 + map.3 + 3.0;
+ let footer = match state.hovered.and_then(|i| state.tiles.get(i)) {
+ Some(t) => format!("{} — {}", t.path.display(), format_size(t.size)),
+ None => {
+ let total = state.tree.as_ref().map(|t| t.size).unwrap_or(0);
+ format!("{} in {} tiles", format_size(total), state.tiles.len())
+ }
+ };
+ pc.text(&elide(&footer, cw - 16.0), cx + 8.0, footer_y, 10.0, text_dim);
+
+ pc
+}
+
+/// Four thin rects making a border — `PageContent` has no stroke primitive.
+fn outline(pc: &mut PageContent, rect: (f32, f32, f32, f32), color: [f32; 4], t: f32) {
+ let (x, y, w, h) = rect;
+ if w <= 0.0 || h <= 0.0 {
+ return;
+ }
+ let t = t.min(w / 2.0).min(h / 2.0);
+ pc.rect(color, x, y, w, t);
+ pc.rect(color, x, y + h - t, w, t);
+ pc.rect(color, x, y + t, t, h - t * 2.0);
+ pc.rect(color, x + w - t, y + t, t, h - t * 2.0);
+}
+
+/// Trim to what fits in `width` px at the ~10px tile font. An estimate, not a
+/// shaping pass — labels here are decoration over an exact rectangle, and
+/// running cosmic-text over thousands of tiles per rebuild would not pay.
+fn elide(s: &str, width: f32) -> String {
+ const CHAR_W: f32 = 5.2;
+ let max = (width / CHAR_W).floor().max(0.0) as usize;
+ if max == 0 {
+ return String::new();
+ }
+ if s.chars().count() <= max {
+ return s.to_string();
+ }
+ if max <= 1 {
+ return "…".to_string();
+ }
+ s.chars().take(max - 1).collect::<String>() + "…"
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn area(r: (f32, f32, f32, f32)) -> f32 {
+ r.2 * r.3
+ }
+
+ #[test]
+ fn squarify_covers_the_rect_exactly_once() {
+ let values = vec![600.0, 300.0, 100.0, 50.0, 25.0, 25.0];
+ let rect = (10.0, 20.0, 400.0, 300.0);
+ let out = squarify(&values, rect);
+
+ assert_eq!(out.len(), values.len());
+ let total_area: f32 = out.iter().map(|r| area(*r)).sum();
+ assert!(
+ (total_area - area(rect)).abs() < 1.0,
+ "tiles should tile the rect: {total_area} vs {}",
+ area(rect)
+ );
+
+ // Every tile stays inside the rect.
+ for r in &out {
+ assert!(r.0 >= rect.0 - 0.01 && r.1 >= rect.1 - 0.01, "{r:?}");
+ assert!(r.0 + r.2 <= rect.0 + rect.2 + 0.01, "{r:?}");
+ assert!(r.1 + r.3 <= rect.1 + rect.3 + 0.01, "{r:?}");
+ }
+ }
+
+ #[test]
+ fn squarify_areas_are_proportional_to_values() {
+ let values = vec![500.0, 250.0, 250.0];
+ let rect = (0.0, 0.0, 200.0, 100.0);
+ let out = squarify(&values, rect);
+
+ let total = area(rect);
+ assert!((area(out[0]) - total * 0.5).abs() < 1.0);
+ assert!((area(out[1]) - total * 0.25).abs() < 1.0);
+ assert!((area(out[2]) - total * 0.25).abs() < 1.0);
+ }
+
+ #[test]
+ fn squarify_keeps_tiles_roughly_square() {
+ // 64 equal values in a square: a slice-and-dice layout would give
+ // 64 slivers of aspect 64:1. Squarified should stay near 1:1.
+ let values = vec![1.0; 64];
+ let out = squarify(&values, (0.0, 0.0, 400.0, 400.0));
+ for r in &out {
+ let aspect = (r.2 / r.3).max(r.3 / r.2);
+ assert!(aspect < 2.0, "tile too elongated: {r:?} aspect {aspect}");
+ }
+ }
+
+ #[test]
+ fn squarify_handles_degenerate_input() {
+ assert!(squarify(&[], (0.0, 0.0, 10.0, 10.0)).is_empty());
+ // A zero-area rect still returns one entry per value.
+ assert_eq!(squarify(&[1.0, 2.0], (0.0, 0.0, 0.0, 10.0)).len(), 2);
+ assert_eq!(squarify(&[0.0, 0.0], (0.0, 0.0, 10.0, 10.0)).len(), 2);
+ }
+
+ fn file(name: &str, size: u64) -> TreeNode {
+ TreeNode { name: name.into(), size, is_dir: false, children: Vec::new() }
+ }
+
+ #[test]
+ fn place_nests_children_inside_their_directory() {
+ let tree = TreeNode {
+ name: "root".into(),
+ size: 1000,
+ is_dir: true,
+ children: vec![
+ TreeNode {
+ name: "sub".into(),
+ size: 800,
+ is_dir: true,
+ children: vec![file("big.bin", 800)],
+ },
+ file("small.txt", 200),
+ ],
+ };
+
+ let mut tiles = Vec::new();
+ place(&tree, Path::new("/root"), (0.0, 0.0, 400.0, 400.0), 0, &mut tiles);
+
+ // Root, sub, big.bin, small.txt.
+ assert_eq!(tiles.len(), 4);
+ assert_eq!(tiles[0].name, "root");
+ assert_eq!(tiles[0].depth, 0);
+
+ // Paths are rebuilt from the names on the way down.
+ let big = tiles.iter().find(|t| t.name == "big.bin").unwrap();
+ assert_eq!(big.path, PathBuf::from("/root/sub/big.bin"));
+ assert_eq!(big.depth, 2);
+
+ // The child sits strictly inside its parent.
+ let sub = tiles.iter().find(|t| t.name == "sub").unwrap();
+ assert!(big.rect.0 >= sub.rect.0 && big.rect.1 >= sub.rect.1);
+ assert!(big.rect.0 + big.rect.2 <= sub.rect.0 + sub.rect.2 + 0.01);
+ assert!(big.rect.1 + big.rect.3 <= sub.rect.1 + sub.rect.3 + 0.01);
+ }
+
+ #[test]
+ fn place_culls_tiles_below_the_minimum() {
+ // One huge file and a thousand tiny ones in a small rect: the tiny
+ // ones fall under MIN_TILE and are dropped rather than emitted as
+ // sub-pixel slivers.
+ let mut children = vec![file("huge.bin", 10_000_000)];
+ for i in 0..1000 {
+ children.push(file(&format!("tiny{i}"), 1));
+ }
+ let tree = TreeNode { name: "root".into(), size: 10_001_000, is_dir: true, children };
+
+ let mut tiles = Vec::new();
+ place(&tree, Path::new("/root"), (0.0, 0.0, 100.0, 100.0), 0, &mut tiles);
+
+ assert!(tiles.len() < 50, "expected culling, got {} tiles", tiles.len());
+ assert!(tiles.iter().any(|t| t.name == "huge.bin"));
+ }
+
+ #[test]
+ fn hit_test_finds_the_deepest_tile() {
+ let tree = TreeNode {
+ name: "root".into(),
+ size: 1000,
+ is_dir: true,
+ children: vec![TreeNode {
+ name: "sub".into(),
+ size: 1000,
+ is_dir: true,
+ children: vec![file("leaf.bin", 1000)],
+ }],
+ };
+ let mut state = SpaceState::default();
+ state.scanned_dir = PathBuf::from("/root");
+ state.tree = Some(tree);
+ state.relayout((0.0, 0.0, 400.0, 400.0));
+
+ // Dead centre is inside root, sub, and leaf — the leaf must win.
+ let hit = state.tile_at(200.0, 200.0).unwrap();
+ assert_eq!(state.tiles[hit].name, "leaf.bin");
+
+ assert!(state.tile_at(-5.0, 200.0).is_none());
+ }
+
+ #[test]
+ fn category_maps_extensions() {
+ assert_eq!(Category::of("photo.JPG"), Category::Image);
+ assert_eq!(Category::of("main.rs"), Category::Code);
+ assert_eq!(Category::of("disk.qcow2"), Category::Binary);
+ assert_eq!(Category::of("notes.md"), Category::Document);
+ assert_eq!(Category::of("bundle.tar.gz"), Category::Archive);
+ // No extension at all; and a dotfile, whose "extension" is its name.
+ assert_eq!(Category::of("README"), Category::Other);
+ assert_eq!(Category::of(".bashrc"), Category::Other);
+ // A dotfile that really does carry one is still classified.
+ assert_eq!(Category::of(".config.toml"), Category::Document);
+ }
+
+ #[test]
+ fn elide_respects_width() {
+ assert_eq!(elide("hi", 0.0), "");
+ assert_eq!(elide("short", 200.0), "short");
+ let long = elide("a-very-long-file-name.txt", 30.0);
+ assert!(long.ends_with('…'));
+ assert!(long.chars().count() <= 6);
+ }
+}
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 755425c..90c17bf 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -40,6 +40,10 @@ pub enum FsRequest {
/// Restore a trashed item (a path under Trash/files) to its origin.
RestorePath(PathBuf),
EmptyTrash,
+ /// Walk a whole subtree for the Space view. The flag is the caller's
+ /// cancel token — raising it abandons a scan whose answer is no longer
+ /// wanted (see `SpaceState::begin_scan`).
+ ScanTree(PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>),
ReadLastDir,
SaveLastDir(PathBuf),
}
@@ -119,6 +123,47 @@ impl FsService {
));
});
}
+ FsRequest::ScanTree(path, cancel) => {
+ // Minutes of blocking recursion on a large tree, so
+ // this goes to the blocking pool rather than tying up
+ // an async worker the way the short reads above can.
+ tokio::task::spawn_blocking(move || {
+ let progress_sender = app_sender.clone();
+ let progress_dir = path.clone();
+ let mut on_progress = |files, bytes| {
+ let _ = progress_sender.send(crate::Message::Space(
+ crate::pages::space::SpaceMessage::Progress {
+ dir: progress_dir.clone(),
+ files,
+ bytes,
+ },
+ ));
+ };
+ let result = super::scan::scan(&path, &cancel, &mut on_progress);
+ match result {
+ Some(res) if !res.cancelled => {
+ let _ = app_sender.send(crate::Message::Space(
+ crate::pages::space::SpaceMessage::Scanned {
+ dir: path,
+ tree: res.tree,
+ },
+ ));
+ }
+ // A cancelled scan's tree is partial. Drop it
+ // silently — the scan that superseded it is
+ // already on its way with the real one.
+ Some(_) => {}
+ None => {
+ let _ = app_sender.send(crate::Message::Space(
+ crate::pages::space::SpaceMessage::Failed(format!(
+ "Cannot scan {}",
+ path.display()
+ )),
+ ));
+ }
+ }
+ });
+ }
FsRequest::ReadLastDir => {
tokio::spawn(async move {
let last_dir = read_last_dir_internal();
diff --git a/src/services/mod.rs b/src/services/mod.rs
index c557d75..4ef3a00 100644
--- a/src/services/mod.rs
+++ b/src/services/mod.rs
@@ -1,2 +1,3 @@
pub mod fs;
+pub mod scan;
pub mod trash;
diff --git a/src/services/scan.rs b/src/services/scan.rs
new file mode 100644
index 0000000..21ab615
--- /dev/null
+++ b/src/services/scan.rs
@@ -0,0 +1,263 @@
+//! Recursive directory scan feeding the Space (treemap) view.
+//!
+//! Unlike `read_directory_internal`, which reads one level and reports each
+//! entry's own size, this walks the whole subtree and gives every directory
+//! the sum of what it contains — the number a treemap's area encodes. It runs
+//! on the `FsService` thread like every other request; the app sees it only as
+//! progress messages followed by a completed tree.
+
+use std::path::Path;
+use std::os::unix::fs::MetadataExt;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+/// Deepest nesting the walk will descend. Ordinary trees are nowhere near
+/// this; the cap exists so a pathological one cannot overflow the recursion
+/// stack.
+const MAX_DEPTH: u32 = 64;
+
+/// How often a scan in flight reports what it has counted so far. Short
+/// enough that the progress line moves, long enough that a fast tree is not
+/// mostly channel traffic.
+const PROGRESS_INTERVAL: Duration = Duration::from_millis(150);
+
+/// One node of a scanned tree. `size` is the recursive total for a directory
+/// and the apparent size for a file.
+///
+/// Nodes deliberately carry no `PathBuf` — a large tree is millions of nodes,
+/// and only the few thousand tiles that survive layout culling ever need a
+/// path. The Space page rebuilds those from the names along the way down.
+#[derive(Debug, Clone, Default)]
+pub struct TreeNode {
+ pub name: String,
+ pub size: u64,
+ pub is_dir: bool,
+ /// Sorted descending by `size` — the order the squarified layout wants,
+ /// established once here rather than per frame.
+ pub children: Vec<TreeNode>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct ScanResult {
+ pub tree: TreeNode,
+ pub files: u64,
+ pub dirs: u64,
+ /// True when the scan stopped early because its cancel flag was raised —
+ /// the tree is a partial one and should be discarded, not drawn.
+ pub cancelled: bool,
+}
+
+struct Walker<'a> {
+ /// Device of the scan root. Entries on any other device are skipped, so
+ /// scanning `/` does not wander into `/proc`, `/sys`, or a mounted backup
+ /// drive — and cannot loop through a bind mount pointing back inside.
+ dev: u64,
+ cancel: &'a AtomicBool,
+ files: u64,
+ dirs: u64,
+ bytes: u64,
+ on_progress: &'a mut dyn FnMut(u64, u64),
+ last_report: Instant,
+}
+
+impl Walker<'_> {
+ fn walk(&mut self, dir: &Path, name: String, depth: u32) -> TreeNode {
+ let mut node = TreeNode { name, size: 0, is_dir: true, children: Vec::new() };
+ if depth >= MAX_DEPTH || self.cancel.load(Ordering::Relaxed) {
+ return node;
+ }
+ let Ok(rd) = std::fs::read_dir(dir) else {
+ // Unreadable directory (permissions, races) contributes nothing
+ // rather than aborting the scan around it.
+ return node;
+ };
+
+ for entry in rd.filter_map(|e| e.ok()) {
+ if self.cancel.load(Ordering::Relaxed) {
+ break;
+ }
+ // `DirEntry::metadata` does not traverse symlinks, which is what we
+ // want twice over: a link cannot pull its target's bytes into this
+ // subtree's total, and cannot loop the walk back into itself.
+ let Ok(meta) = entry.metadata() else { continue };
+ let ft = meta.file_type();
+ if ft.is_symlink() || meta.dev() != self.dev {
+ continue;
+ }
+
+ let child_name = entry.file_name().to_string_lossy().into_owned();
+ if ft.is_dir() {
+ self.dirs += 1;
+ let child = self.walk(&entry.path(), child_name, depth + 1);
+ node.size += child.size;
+ node.children.push(child);
+ } else if ft.is_file() {
+ let size = meta.len();
+ self.files += 1;
+ self.bytes += size;
+ node.size += size;
+ node.children.push(TreeNode {
+ name: child_name,
+ size,
+ is_dir: false,
+ children: Vec::new(),
+ });
+ }
+ // Sockets, fifos, and device nodes occupy no meaningful space and
+ // are dropped entirely.
+ self.maybe_report();
+ }
+
+ node.children.sort_unstable_by(|a, b| b.size.cmp(&a.size));
+ node
+ }
+
+ fn maybe_report(&mut self) {
+ if self.last_report.elapsed() >= PROGRESS_INTERVAL {
+ self.last_report = Instant::now();
+ (self.on_progress)(self.files, self.bytes);
+ }
+ }
+}
+
+/// Walk `root`, returning its tree with directory sizes summed.
+///
+/// Returns `None` when `root` is not a readable directory. `cancel` is polled
+/// per entry, so a superseded scan stops within a directory rather than
+/// running to completion unwatched.
+pub fn scan(
+ root: &Path,
+ cancel: &AtomicBool,
+ on_progress: &mut dyn FnMut(u64, u64),
+) -> Option<ScanResult> {
+ // The root is stat'd through symlinks — the user may well have navigated
+ // to one — while everything beneath it is not.
+ let meta = std::fs::metadata(root).ok()?;
+ if !meta.is_dir() {
+ return None;
+ }
+
+ let name = root
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| root.to_string_lossy().into_owned());
+
+ let mut walker = Walker {
+ dev: meta.dev(),
+ cancel,
+ files: 0,
+ dirs: 0,
+ bytes: 0,
+ on_progress,
+ last_report: Instant::now(),
+ };
+ let tree = walker.walk(root, name, 0);
+
+ Some(ScanResult {
+ tree,
+ files: walker.files,
+ dirs: walker.dirs,
+ cancelled: cancel.load(Ordering::Relaxed),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ /// A scratch tree: `<tmp>/cce_scan_test_<nanos>/`.
+ fn scratch(tag: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let dir = std::env::temp_dir().join(format!("cce_scan_test_{tag}_{nanos}"));
+ fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ #[test]
+ fn sums_sizes_recursively_and_sorts_descending() {
+ let root = scratch("sum");
+ fs::write(root.join("small.txt"), vec![b'a'; 10]).unwrap();
+ let sub = root.join("sub");
+ fs::create_dir(&sub).unwrap();
+ fs::write(sub.join("big.bin"), vec![b'b'; 5000]).unwrap();
+ fs::write(sub.join("mid.bin"), vec![b'c'; 500]).unwrap();
+
+ let cancel = AtomicBool::new(false);
+ let res = scan(&root, &cancel, &mut |_, _| {}).unwrap();
+
+ assert_eq!(res.files, 3);
+ assert_eq!(res.dirs, 1);
+ assert!(!res.cancelled);
+ // The directory carries what it contains, not its own inode size.
+ assert_eq!(res.tree.size, 5510);
+
+ // Children sorted descending: sub (5500) before small.txt (10).
+ assert_eq!(res.tree.children.len(), 2);
+ assert_eq!(res.tree.children[0].name, "sub");
+ assert_eq!(res.tree.children[0].size, 5500);
+ assert!(res.tree.children[0].is_dir);
+ assert_eq!(res.tree.children[1].name, "small.txt");
+
+ // ...and so are the grandchildren.
+ let sub_node = &res.tree.children[0];
+ assert_eq!(sub_node.children[0].name, "big.bin");
+ assert_eq!(sub_node.children[1].name, "mid.bin");
+
+ fs::remove_dir_all(&root).unwrap();
+ }
+
+ #[test]
+ fn symlinks_are_skipped_not_followed() {
+ let root = scratch("link");
+ let real = root.join("real");
+ fs::create_dir(&real).unwrap();
+ fs::write(real.join("data.bin"), vec![b'x'; 1000]).unwrap();
+ // A link back to the root would loop the walk if it were followed, and
+ // a link to the sibling directory would double-count its bytes.
+ std::os::unix::fs::symlink(&root, root.join("loop")).unwrap();
+ std::os::unix::fs::symlink(&real, root.join("alias")).unwrap();
+
+ let cancel = AtomicBool::new(false);
+ let res = scan(&root, &cancel, &mut |_, _| {}).unwrap();
+
+ assert_eq!(res.files, 1);
+ assert_eq!(res.tree.size, 1000);
+ assert_eq!(res.tree.children.len(), 1, "only `real` — both links dropped");
+
+ fs::remove_dir_all(&root).unwrap();
+ }
+
+ #[test]
+ fn cancel_flag_stops_the_walk() {
+ let root = scratch("cancel");
+ for i in 0..50 {
+ fs::write(root.join(format!("f{i}")), vec![b'z'; 100]).unwrap();
+ }
+
+ // Already-raised flag: the walk bails before reading any entry.
+ let cancel = AtomicBool::new(true);
+ let res = scan(&root, &cancel, &mut |_, _| {}).unwrap();
+
+ assert!(res.cancelled);
+ assert_eq!(res.files, 0);
+ assert!(res.tree.children.is_empty());
+
+ fs::remove_dir_all(&root).unwrap();
+ }
+
+ #[test]
+ fn non_directory_root_is_rejected() {
+ let root = scratch("file");
+ let file = root.join("plain.txt");
+ fs::write(&file, b"hello").unwrap();
+
+ let cancel = AtomicBool::new(false);
+ assert!(scan(&file, &cancel, &mut |_, _| {}).is_none());
+
+ fs::remove_dir_all(&root).unwrap();
+ }
+}