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

src/pages/space.rs (29.9K)

  1 //! The Space page — a GrandPerspective-style treemap of disk usage.
  2 //!
  3 //! Every file in the scanned subtree is one rectangle whose *area* is its size,
  4 //! nested inside its directory's rectangle. That is the whole idea: the thing
  5 //! eating your disk is the biggest shape on screen, however deep it is buried.
  6 //!
  7 //! Three pieces live here:
  8 //! - [`squarify`], the Bruls/Huizing/van Wijk squarified layout, which keeps
  9 //!   tiles near-square instead of the slivers a naive slice-and-dice produces;
 10 //! - [`SpaceState::relayout`], which walks the scanned tree recursively and
 11 //!   flattens it into a [`Tile`] list, culling anything too small to see;
 12 //! - [`view`], which paints that list into a `PageContent`.
 13 //!
 14 //! The scan itself is `services::scan`, driven from `main.rs` — this module is
 15 //! given a finished tree.
 16 
 17 use std::path::{Path, PathBuf};
 18 use std::sync::Arc;
 19 use std::sync::atomic::{AtomicBool, Ordering};
 20 
 21 use cce_ui::widget::{Adapted, Breadcrumb, PathController};
 22 
 23 use crate::pages::PageContent;
 24 use crate::pages::browse::BrowseState;
 25 use crate::services::scan::TreeNode;
 26 use crate::util::format_size;
 27 
 28 /// Below this, in either dimension, a tile is too small to read and is not
 29 /// emitted at all — its bytes stay accounted for in the parent's area, which
 30 /// still shows as filled. This is what bounds tile count on a large tree far
 31 /// more effectively than [`MAX_TILES`].
 32 const MIN_TILE: f32 = 3.0;
 33 
 34 /// A directory smaller than this is drawn as one aggregate block rather than
 35 /// recursed into: below it the frame and padding would eat the children.
 36 const MIN_RECURSE: f32 = 20.0;
 37 
 38 /// Hard ceiling on emitted tiles, so a pathological tree cannot make a frame
 39 /// rebuild unbounded. Reached only when MIN_TILE culling has not already.
 40 const MAX_TILES: usize = 24_000;
 41 
 42 /// Inset applied to a directory's rect before laying out its children — the
 43 /// gap that makes nesting legible.
 44 const DIR_PAD: f32 = 1.0;
 45 
 46 /// Height reserved at the top of a directory's rect for its name, when the
 47 /// rect is big enough to bother.
 48 const DIR_LABEL_H: f32 = 13.0;
 49 
 50 /// Directory rects at least this tall get a name strip.
 51 const DIR_LABEL_MIN: f32 = 46.0;
 52 
 53 /// File tiles at least this big get their name drawn inside them.
 54 const FILE_LABEL_MIN_W: f32 = 44.0;
 55 const FILE_LABEL_MIN_H: f32 = 15.0;
 56 
 57 /// Rows reserved at the bottom of the pane for the hover/summary readout.
 58 const FOOTER_H: f32 = 18.0;
 59 
 60 // ── File-type colors ────────────────────────────────────────────────
 61 
 62 /// The category a file's extension puts it in. Area says how big a thing is;
 63 /// hue says what kind of thing it is, which is how you tell "my photo library"
 64 /// from "one enormous VM image" at a glance.
 65 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 66 pub enum Category {
 67     Image,
 68     Video,
 69     Audio,
 70     Code,
 71     Document,
 72     Archive,
 73     Binary,
 74     Other,
 75 }
 76 
 77 impl Category {
 78     pub fn of(name: &str) -> Category {
 79         // A leading-dot name with no other dot (`.bashrc`) has no extension —
 80         // splitting on the last dot would otherwise read "bashrc" as one.
 81         let ext = name
 82             .rsplit_once('.')
 83             .filter(|(stem, _)| !stem.is_empty())
 84             .map(|(_, e)| e.to_ascii_lowercase());
 85         match ext.as_deref() {
 86             Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff" | "tif"
 87                 | "svg" | "svgz" | "psd" | "xcf" | "raw" | "cr2" | "nef" | "heic" | "avif") => Category::Image,
 88             Some("mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" | "mpg" | "mpeg" | "wmv"
 89                 | "flv" | "ogv") => Category::Video,
 90             Some("mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" | "wma" | "aiff"
 91                 | "mid" | "midi") => Category::Audio,
 92             Some("rs" | "c" | "h" | "cpp" | "hpp" | "cc" | "py" | "js" | "ts" | "jsx" | "tsx"
 93                 | "go" | "java" | "kt" | "rb" | "php" | "swift" | "hs" | "ml" | "lua" | "sh"
 94                 | "bash" | "zsh" | "fish" | "vim" | "el" | "scm" | "clj" | "ex" | "erl"
 95                 | "sql" | "html" | "css" | "scss" | "glsl" | "cl" | "wgsl") => Category::Code,
 96             Some("txt" | "md" | "rst" | "org" | "pdf" | "doc" | "docx" | "odt" | "rtf"
 97                 | "xls" | "xlsx" | "ods" | "csv" | "tsv" | "ppt" | "pptx" | "odp" | "epub"
 98                 | "mobi" | "tex" | "json" | "toml" | "yaml" | "yml" | "xml" | "kdl"
 99                 | "ini" | "conf" | "cfg" | "log") => Category::Document,
100             Some("zip" | "tar" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "tgz" | "txz"
101                 | "iso" | "img" | "dmg" | "deb" | "rpm" | "pkg" | "apk" | "jar" | "whl") => Category::Archive,
102             Some("so" | "a" | "o" | "dll" | "dylib" | "exe" | "bin" | "elf" | "class"
103                 | "pyc" | "rlib" | "wasm" | "qcow2" | "vdi" | "vmdk") => Category::Binary,
104             _ => Category::Other,
105         }
106     }
107 
108     pub fn label(self) -> &'static str {
109         match self {
110             Category::Image => "Images",
111             Category::Video => "Video",
112             Category::Audio => "Audio",
113             Category::Code => "Code",
114             Category::Document => "Documents",
115             Category::Archive => "Archives",
116             Category::Binary => "Binaries",
117             Category::Other => "Other",
118         }
119     }
120 
121     /// Written as sRGB hex and converted the same way config colors are, so
122     /// these sit in the same space as everything else the renderer is handed.
123     pub fn color(self) -> [f32; 4] {
124         let hex = match self {
125             Category::Image => "#4f9fd1",
126             Category::Video => "#9b6bd6",
127             Category::Audio => "#4fb98a",
128             Category::Code => "#dfb341",
129             Category::Document => "#d1685f",
130             Category::Archive => "#c77e3e",
131             Category::Binary => "#b85c9e",
132             Category::Other => "#6b7280",
133         };
134         cce_ui::color::parse_hex_rgba_linear(hex).unwrap_or([0.4, 0.4, 0.45, 1.0])
135     }
136 }
137 
138 /// Every category, for the legend.
139 pub const CATEGORIES: [Category; 8] = [
140     Category::Image,
141     Category::Video,
142     Category::Audio,
143     Category::Code,
144     Category::Document,
145     Category::Archive,
146     Category::Binary,
147     Category::Other,
148 ];
149 
150 // ── Tiles ───────────────────────────────────────────────────────────
151 
152 /// One laid-out rectangle. Directories come before their children in the list,
153 /// so a reverse scan finds the deepest tile under a point first.
154 #[derive(Debug, Clone)]
155 pub struct Tile {
156     pub path: PathBuf,
157     pub name: String,
158     pub size: u64,
159     pub is_dir: bool,
160     pub depth: u32,
161     /// (x, y, w, h) in window coordinates.
162     pub rect: (f32, f32, f32, f32),
163 }
164 
165 impl Tile {
166     fn contains(&self, x: f32, y: f32) -> bool {
167         let (rx, ry, rw, rh) = self.rect;
168         x >= rx && x < rx + rw && y >= ry && y < ry + rh
169     }
170 }
171 
172 // ── Squarified layout ───────────────────────────────────────────────
173 
174 /// Lay `values` (which MUST be sorted descending and strictly positive) into
175 /// `rect`, returning one rect per value in the same order.
176 ///
177 /// This is the squarified treemap of Bruls, Huizing &amp; van Wijk (2000): fill
178 /// the rect row by row along its shorter side, growing each row only while
179 /// doing so improves the worst aspect ratio in it. The result is tiles close
180 /// to square, which is what makes areas visually comparable — a naive
181 /// slice-and-dice gives slivers you cannot compare at all.
182 fn squarify(values: &[f64], rect: (f32, f32, f32, f32)) -> Vec<(f32, f32, f32, f32)> {
183     let mut out = Vec::with_capacity(values.len());
184     let (rx, ry, rw, rh) = rect;
185     let total: f64 = values.iter().sum();
186     if values.is_empty() || total <= 0.0 || rw <= 0.0 || rh <= 0.0 {
187         return vec![(0.0, 0.0, 0.0, 0.0); values.len()];
188     }
189 
190     // Work in pixel² so a row's thickness is just its area over its length.
191     let scale = (rw as f64) * (rh as f64) / total;
192     let areas: Vec<f64> = values.iter().map(|v| v * scale).collect();
193 
194     let (mut x, mut y, mut w, mut h) = (rx as f64, ry as f64, rw as f64, rh as f64);
195     let mut i = 0;
196 
197     while i < areas.len() {
198         if w <= 0.0 || h <= 0.0 {
199             out.extend(std::iter::repeat((0.0, 0.0, 0.0, 0.0)).take(areas.len() - i));
200             break;
201         }
202 
203         // Rows run along the shorter side; that is the whole trick.
204         let horizontal = w >= h;
205         let side = if horizontal { h } else { w };
206 
207         // Grow the row while the worst aspect ratio in it keeps improving.
208         let mut end = i;
209         let mut sum = 0.0;
210         let mut best = f64::INFINITY;
211         while end < areas.len() {
212             let next_sum = sum + areas[end];
213             // Descending order means the row's max is its first item and its
214             // min is the one we are considering adding.
215             let worst = worst_ratio(next_sum, areas[i], areas[end], side);
216             if end > i && worst > best {
217                 break;
218             }
219             best = worst;
220             sum = next_sum;
221             end += 1;
222         }
223 
224         // Place the row.
225         let thickness = (sum / side).min(if horizontal { w } else { h });
226         let mut offset = 0.0;
227         for k in i..end {
228             let len = if sum > 0.0 { areas[k] / sum * side } else { 0.0 };
229             let r = if horizontal {
230                 (x, y + offset, thickness, len)
231             } else {
232                 (x + offset, y, len, thickness)
233             };
234             out.push((r.0 as f32, r.1 as f32, r.2 as f32, r.3 as f32));
235             offset += len;
236         }
237 
238         if horizontal {
239             x += thickness;
240             w -= thickness;
241         } else {
242             y += thickness;
243             h -= thickness;
244         }
245         i = end;
246     }
247 
248     out
249 }
250 
251 /// Worst (largest) aspect ratio produced by a row of total area `sum` laid
252 /// along a side of length `side`, containing items of area `max` and `min`.
253 fn worst_ratio(sum: f64, max: f64, min: f64, side: f64) -> f64 {
254     if sum <= 0.0 || side <= 0.0 || min <= 0.0 {
255         return f64::INFINITY;
256     }
257     let s2 = sum * sum;
258     let w2 = side * side;
259     (w2 * max / s2).max(s2 / (w2 * min))
260 }
261 
262 // ── Messages ────────────────────────────────────────────────────────
263 
264 /// Results coming back from a `FsRequest::ScanTree`. Each carries the
265 /// directory it is about, because a scan that has been superseded can still
266 /// deliver messages after the app has moved on.
267 #[derive(Debug, Clone)]
268 pub enum SpaceMessage {
269     Progress { dir: PathBuf, files: u64, bytes: u64 },
270     Scanned { dir: PathBuf, tree: TreeNode },
271     Failed(String),
272 }
273 
274 pub fn update(state: &mut SpaceState, msg: SpaceMessage) {
275     match msg {
276         SpaceMessage::Progress { dir, files, bytes } => {
277             // Late progress from a scan we no longer care about.
278             if !state.scanning || state.scanned_dir != dir {
279                 return;
280             }
281             state.scan_files = files;
282             state.scan_bytes = bytes;
283         }
284         SpaceMessage::Scanned { dir, tree } => {
285             if state.scanned_dir != dir {
286                 return;
287             }
288             state.scan_finished(dir, tree);
289         }
290         SpaceMessage::Failed(err) => state.scan_failed(err),
291     }
292 }
293 
294 // ── Page state ──────────────────────────────────────────────────────
295 
296 pub struct SpaceState {
297     pub breadcrumb: Adapted<Breadcrumb>,
298     /// The directory the current `tree` describes. Empty until a scan lands.
299     pub scanned_dir: PathBuf,
300     pub tree: Option<TreeNode>,
301     pub tiles: Vec<Tile>,
302     /// Rect the current `tiles` were laid out for — a resize invalidates them.
303     laid_out: (f32, f32, f32, f32),
304     /// The map region as of the last `view`. Input handlers need it to tell a
305     /// press on the treemap from one on the window root plate behind it.
306     pub map_rect: (f32, f32, f32, f32),
307     pub hovered: Option<usize>,
308     /// Selection is held by path, not index: a relayout renumbers every tile.
309     pub selected_path: Option<PathBuf>,
310     pub scanning: bool,
311     pub scan_files: u64,
312     pub scan_bytes: u64,
313     /// Raised to abandon the in-flight scan when a newer one supersedes it.
314     pub cancel: Arc<AtomicBool>,
315     pub error: Option<String>,
316     /// Pointer focus (the app's well-focus tracking): the map well renders as
317     /// the tinted carve — accent ring replacing the relief lighting.
318     pub focused: bool,
319 }
320 
321 impl Default for SpaceState {
322     fn default() -> Self {
323         let mut breadcrumb = Breadcrumb::new();
324         breadcrumb.set_network_opacity(0.95);
325         Self {
326             breadcrumb,
327             scanned_dir: PathBuf::new(),
328             tree: None,
329             tiles: Vec::new(),
330             laid_out: (0.0, 0.0, 0.0, 0.0),
331             map_rect: (0.0, 0.0, 0.0, 0.0),
332             hovered: None,
333             selected_path: None,
334             scanning: false,
335             scan_files: 0,
336             scan_bytes: 0,
337             cancel: Arc::new(AtomicBool::new(false)),
338             error: None,
339             focused: false,
340         }
341     }
342 }
343 
344 impl SpaceState {
345     /// True when `dir` is not what the current tree describes — the caller
346     /// should kick off a scan.
347     pub fn needs_scan(&self, dir: &Path) -> bool {
348         !self.scanning && (self.tree.is_none() || self.scanned_dir != dir)
349     }
350 
351     /// Abandon any in-flight scan and arm a fresh cancel token for the next
352     /// one. Returns the token the new scan should carry.
353     pub fn begin_scan(&mut self, dir: &Path) -> Arc<AtomicBool> {
354         self.cancel.store(true, Ordering::Relaxed);
355         self.cancel = Arc::new(AtomicBool::new(false));
356         self.scanning = true;
357         self.scan_files = 0;
358         self.scan_bytes = 0;
359         self.error = None;
360         self.tree = None;
361         self.tiles.clear();
362         self.hovered = None;
363         self.scanned_dir = dir.to_path_buf();
364         self.laid_out = (0.0, 0.0, 0.0, 0.0);
365         self.cancel.clone()
366     }
367 
368     pub fn scan_finished(&mut self, dir: PathBuf, tree: TreeNode) {
369         self.scanning = false;
370         self.scanned_dir = dir;
371         self.tree = Some(tree);
372         self.tiles.clear();
373         self.laid_out = (0.0, 0.0, 0.0, 0.0);
374         self.hovered = None;
375     }
376 
377     pub fn scan_failed(&mut self, err: String) {
378         self.scanning = false;
379         self.tree = None;
380         self.tiles.clear();
381         self.error = Some(err);
382     }
383 
384     /// The deepest tile under the cursor, which is the one the user means.
385     pub fn tile_at(&self, x: f32, y: f32) -> Option<usize> {
386         self.tiles.iter().rposition(|t| t.contains(x, y))
387     }
388 
389     /// Recompute tiles for `rect` if the tree or the rect has changed.
390     pub fn relayout(&mut self, rect: (f32, f32, f32, f32)) {
391         if self.laid_out == rect && !self.tiles.is_empty() {
392             return;
393         }
394         self.tiles.clear();
395         self.laid_out = rect;
396         let Some(tree) = self.tree.take() else { return };
397         let root = self.scanned_dir.clone();
398         place(&tree, &root, rect, 0, &mut self.tiles);
399         self.tree = Some(tree);
400         // A relayout renumbers everything; the stale hover index would point
401         // at an unrelated tile.
402         self.hovered = None;
403     }
404 }
405 
406 /// Recursively lay `node` into `rect`, appending tiles. The node's own tile is
407 /// pushed before its children so a reverse hit-test finds the deepest first.
408 fn place(node: &TreeNode, path: &Path, rect: (f32, f32, f32, f32), depth: u32, out: &mut Vec<Tile>) {
409     let (x, y, w, h) = rect;
410     if w < MIN_TILE || h < MIN_TILE || out.len() >= MAX_TILES {
411         return;
412     }
413 
414     out.push(Tile {
415         path: path.to_path_buf(),
416         name: node.name.clone(),
417         size: node.size,
418         is_dir: node.is_dir,
419         depth,
420         rect,
421     });
422 
423     if !node.is_dir || node.children.is_empty() {
424         return;
425     }
426     // Too small to subdivide usefully: it stays one aggregate block.
427     if w < MIN_RECURSE || h < MIN_RECURSE {
428         return;
429     }
430 
431     // Inset for the frame, plus a name strip when there is room for one.
432     let label = h >= DIR_LABEL_MIN && w >= FILE_LABEL_MIN_W;
433     let top = DIR_PAD + if label { DIR_LABEL_H } else { 0.0 };
434     let inner = (
435         x + DIR_PAD,
436         y + top,
437         (w - DIR_PAD * 2.0).max(0.0),
438         (h - top - DIR_PAD).max(0.0),
439     );
440     if inner.2 < MIN_TILE || inner.3 < MIN_TILE {
441         return;
442     }
443 
444     // Zero-byte children have no area to occupy and would divide by zero in
445     // the aspect-ratio test; they are simply not drawn.
446     let kids: Vec<&TreeNode> = node.children.iter().filter(|c| c.size > 0).collect();
447     if kids.is_empty() {
448         return;
449     }
450     let values: Vec<f64> = kids.iter().map(|c| c.size as f64).collect();
451 
452     for (child, r) in kids.iter().zip(squarify(&values, inner)) {
453         place(child, &path.join(&child.name), r, depth + 1, out);
454     }
455 }
456 
457 // ── View ────────────────────────────────────────────────────────────
458 
459 pub fn view(
460     state: &mut SpaceState,
461     browse: &BrowseState,
462     view_dropdown: &mut Adapted<cce_ui::widget::Dropdown>,
463     cx: f32,
464     cy: f32,
465     cw: f32,
466     ch: f32,
467     ctx: &mut cce_ui::context::UiContext,
468 ) -> PageContent {
469     let mut pc = PageContent::new();
470 
471     // Breadcrumb + view dropdown, mirroring the Network page's header so the
472     // two views line up when you switch between them.
473     let dropdown_w = 120.0;
474     // TODO(style): the 4/6/16 offsets are a leftover inset from the pane rect
475     // that browse.rs has already dropped; the gap to the dropdown is the rung.
476     let gap = cce_ui::layout::root_plate_gap();
477     let breadcrumb_w = cw - 16.0 - dropdown_w - gap;
478     cce_ui::layout::render_widget(&mut pc, &mut state.breadcrumb, cx + 4.0, cy + 6.0, breadcrumb_w, 24.0, ctx);
479     {
480         let rect = cce_ui::scene::layout::Rect { x: cx + 4.0, y: cy + 6.0, width: breadcrumb_w, height: 24.0 };
481         crate::pages::breadcrumb_relief(&mut pc, &state.breadcrumb, rect);
482     }
483     cce_ui::layout::render_widget(&mut pc, view_dropdown, cx + 4.0 + breadcrumb_w + gap, cy + 6.0, dropdown_w, 24.0, ctx);
484 
485     let mut segments = Vec::new();
486     for component in browse.current_dir.components() {
487         let s = component.as_os_str().to_string_lossy().to_string();
488         if s != "/" && !s.is_empty() {
489             segments.push(s);
490         }
491     }
492     state.breadcrumb.set_path(&segments);
493 
494     // The map occupies everything below the header, less the footer readout.
495     let map = (cx, cy + 34.0, cw, (ch - 34.0 - FOOTER_H).max(0.0));
496     state.map_rect = map;
497     // Fill and well share one rect AND one radius — `RowList::push_prims`'s
498     // pairing, since this pane is the Browse list's opposite number across the
499     // same split. It used to fill square (`pc.rect`) under a well carved at
500     // `plate_corner_radius` (12.0), so the corners disagreed twice over: with
501     // their own fill, and with the r=4 list the pane sits beside.
502     //
503     // The tiles stay square and unclipped — a treemap cannot follow a curve —
504     // so a corner still contradicts the rim. Dropping 12.0 to 4.0 shrinks that
505     // residual to what RowList already lives with for its square row overlays.
506     // Rounding the fill alone would have been inert: the root directory tile
507     // paints a full square rect over the whole map, so the fill is not visible
508     // except in the 1px inset.
509     let bg = cce_ui::color::list_bg_color();
510     let radius = cce_ui::layout::list_corner_radius();
511     {
512         use cce_ui::layout::RenderTarget;
513         pc.rect_with_radius_corners(bg, map.0, map.1, map.2, map.3, radius, (true, true, true, true));
514     }
515     if state.focused {
516         pc.relief_recessed_focused(map.0, map.1, map.2, map.3, radius);
517     } else {
518         pc.relief_recessed(map.0, map.1, map.2, map.3, radius);
519     }
520 
521     let text_dim = cce_ui::color::TEXT_DIM;
522     let text_fg = cce_ui::color::TEXT_FG;
523 
524     if let Some(err) = &state.error {
525         pc.text(err, map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
526         return pc;
527     }
528 
529     if state.scanning {
530         let msg = format!(
531             "Scanning {} — {} files, {}",
532             browse.current_dir.display(),
533             state.scan_files,
534             format_size(state.scan_bytes)
535         );
536         pc.text(&msg, map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
537         return pc;
538     }
539 
540     // Inset one pixel so tiles do not sit on top of the well's rim.
541     state.relayout((map.0 + 1.0, map.1 + 1.0, (map.2 - 2.0).max(0.0), (map.3 - 2.0).max(0.0)));
542 
543     if state.tiles.is_empty() {
544         pc.text("Nothing to show — the directory is empty.", map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
545         return pc;
546     }
547 
548     let frame = cce_ui::color::parse_hex_rgba_linear("#20242b").unwrap_or([0.1, 0.1, 0.12, 1.0]);
549     for tile in &state.tiles {
550         let (tx, ty, tw, th) = tile.rect;
551         if tile.is_dir {
552             // A directory paints only its frame — its children cover the
553             // inside, and where they do not, the gap reads as slack space.
554             pc.rect(frame, tx, ty, tw, th);
555             if th >= DIR_LABEL_MIN && tw >= FILE_LABEL_MIN_W {
556                 pc.text(
557                     &elide(&tile.name, tw - 6.0),
558                     tx + 3.0,
559                     ty + 2.0,
560                     10.0,
561                     text_dim,
562                 );
563             }
564         } else {
565             pc.rect(Category::of(&tile.name).color(), tx, ty, tw, th);
566             if tw >= FILE_LABEL_MIN_W && th >= FILE_LABEL_MIN_H {
567                 pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, text_fg);
568             }
569         }
570     }
571 
572     // Selection and hover are drawn as outlines over the tiles.
573     if let Some(sel) = &state.selected_path {
574         if let Some(t) = state.tiles.iter().find(|t| &t.path == sel) {
575             outline(&mut pc, t.rect, cce_ui::color::TEXT_HEADER, 2.0);
576         }
577     }
578     if let Some(idx) = state.hovered {
579         if let Some(t) = state.tiles.get(idx) {
580             outline(&mut pc, t.rect, cce_ui::color::TEXT_ACCENT, 1.0);
581         }
582     }
583 
584     // Footer: whatever the cursor is over, else the total.
585     let footer_y = map.1 + map.3 + 3.0;
586     let footer = match state.hovered.and_then(|i| state.tiles.get(i)) {
587         Some(t) => format!("{}  —  {}", t.path.display(), format_size(t.size)),
588         None => {
589             let total = state.tree.as_ref().map(|t| t.size).unwrap_or(0);
590             format!("{} in {} tiles", format_size(total), state.tiles.len())
591         }
592     };
593     pc.text(&elide(&footer, cw - 16.0), cx + 8.0, footer_y, 10.0, text_dim);
594 
595     pc
596 }
597 
598 /// Four thin rects making a border — `PageContent` has no stroke primitive.
599 fn outline(pc: &mut PageContent, rect: (f32, f32, f32, f32), color: [f32; 4], t: f32) {
600     let (x, y, w, h) = rect;
601     if w <= 0.0 || h <= 0.0 {
602         return;
603     }
604     let t = t.min(w / 2.0).min(h / 2.0);
605     pc.rect(color, x, y, w, t);
606     pc.rect(color, x, y + h - t, w, t);
607     pc.rect(color, x, y + t, t, h - t * 2.0);
608     pc.rect(color, x + w - t, y + t, t, h - t * 2.0);
609 }
610 
611 /// Trim to what fits in `width` px at the ~10px tile font. An estimate, not a
612 /// shaping pass — labels here are decoration over an exact rectangle, and
613 /// running cosmic-text over thousands of tiles per rebuild would not pay.
614 fn elide(s: &str, width: f32) -> String {
615     const CHAR_W: f32 = 5.2;
616     let max = (width / CHAR_W).floor().max(0.0) as usize;
617     if max == 0 {
618         return String::new();
619     }
620     if s.chars().count() <= max {
621         return s.to_string();
622     }
623     if max <= 1 {
624         return "…".to_string();
625     }
626     s.chars().take(max - 1).collect::<String>() + "…"
627 }
628 
629 #[cfg(test)]
630 mod tests {
631     use super::*;
632 
633     fn area(r: (f32, f32, f32, f32)) -> f32 {
634         r.2 * r.3
635     }
636 
637     #[test]
638     fn squarify_covers_the_rect_exactly_once() {
639         let values = vec![600.0, 300.0, 100.0, 50.0, 25.0, 25.0];
640         let rect = (10.0, 20.0, 400.0, 300.0);
641         let out = squarify(&values, rect);
642 
643         assert_eq!(out.len(), values.len());
644         let total_area: f32 = out.iter().map(|r| area(*r)).sum();
645         assert!(
646             (total_area - area(rect)).abs() < 1.0,
647             "tiles should tile the rect: {total_area} vs {}",
648             area(rect)
649         );
650 
651         // Every tile stays inside the rect.
652         for r in &out {
653             assert!(r.0 >= rect.0 - 0.01 && r.1 >= rect.1 - 0.01, "{r:?}");
654             assert!(r.0 + r.2 <= rect.0 + rect.2 + 0.01, "{r:?}");
655             assert!(r.1 + r.3 <= rect.1 + rect.3 + 0.01, "{r:?}");
656         }
657     }
658 
659     #[test]
660     fn squarify_areas_are_proportional_to_values() {
661         let values = vec![500.0, 250.0, 250.0];
662         let rect = (0.0, 0.0, 200.0, 100.0);
663         let out = squarify(&values, rect);
664 
665         let total = area(rect);
666         assert!((area(out[0]) - total * 0.5).abs() < 1.0);
667         assert!((area(out[1]) - total * 0.25).abs() < 1.0);
668         assert!((area(out[2]) - total * 0.25).abs() < 1.0);
669     }
670 
671     #[test]
672     fn squarify_keeps_tiles_roughly_square() {
673         // 64 equal values in a square: a slice-and-dice layout would give
674         // 64 slivers of aspect 64:1. Squarified should stay near 1:1.
675         let values = vec![1.0; 64];
676         let out = squarify(&values, (0.0, 0.0, 400.0, 400.0));
677         for r in &out {
678             let aspect = (r.2 / r.3).max(r.3 / r.2);
679             assert!(aspect < 2.0, "tile too elongated: {r:?} aspect {aspect}");
680         }
681     }
682 
683     #[test]
684     fn squarify_handles_degenerate_input() {
685         assert!(squarify(&[], (0.0, 0.0, 10.0, 10.0)).is_empty());
686         // A zero-area rect still returns one entry per value.
687         assert_eq!(squarify(&[1.0, 2.0], (0.0, 0.0, 0.0, 10.0)).len(), 2);
688         assert_eq!(squarify(&[0.0, 0.0], (0.0, 0.0, 10.0, 10.0)).len(), 2);
689     }
690 
691     fn file(name: &str, size: u64) -> TreeNode {
692         TreeNode { name: name.into(), size, is_dir: false, children: Vec::new() }
693     }
694 
695     #[test]
696     fn place_nests_children_inside_their_directory() {
697         let tree = TreeNode {
698             name: "root".into(),
699             size: 1000,
700             is_dir: true,
701             children: vec![
702                 TreeNode {
703                     name: "sub".into(),
704                     size: 800,
705                     is_dir: true,
706                     children: vec![file("big.bin", 800)],
707                 },
708                 file("small.txt", 200),
709             ],
710         };
711 
712         let mut tiles = Vec::new();
713         place(&tree, Path::new("/root"), (0.0, 0.0, 400.0, 400.0), 0, &mut tiles);
714 
715         // Root, sub, big.bin, small.txt.
716         assert_eq!(tiles.len(), 4);
717         assert_eq!(tiles[0].name, "root");
718         assert_eq!(tiles[0].depth, 0);
719 
720         // Paths are rebuilt from the names on the way down.
721         let big = tiles.iter().find(|t| t.name == "big.bin").unwrap();
722         assert_eq!(big.path, PathBuf::from("/root/sub/big.bin"));
723         assert_eq!(big.depth, 2);
724 
725         // The child sits strictly inside its parent.
726         let sub = tiles.iter().find(|t| t.name == "sub").unwrap();
727         assert!(big.rect.0 >= sub.rect.0 && big.rect.1 >= sub.rect.1);
728         assert!(big.rect.0 + big.rect.2 <= sub.rect.0 + sub.rect.2 + 0.01);
729         assert!(big.rect.1 + big.rect.3 <= sub.rect.1 + sub.rect.3 + 0.01);
730     }
731 
732     #[test]
733     fn place_culls_tiles_below_the_minimum() {
734         // One huge file and a thousand tiny ones in a small rect: the tiny
735         // ones fall under MIN_TILE and are dropped rather than emitted as
736         // sub-pixel slivers.
737         let mut children = vec![file("huge.bin", 10_000_000)];
738         for i in 0..1000 {
739             children.push(file(&format!("tiny{i}"), 1));
740         }
741         let tree = TreeNode { name: "root".into(), size: 10_001_000, is_dir: true, children };
742 
743         let mut tiles = Vec::new();
744         place(&tree, Path::new("/root"), (0.0, 0.0, 100.0, 100.0), 0, &mut tiles);
745 
746         assert!(tiles.len() < 50, "expected culling, got {} tiles", tiles.len());
747         assert!(tiles.iter().any(|t| t.name == "huge.bin"));
748     }
749 
750     #[test]
751     fn hit_test_finds_the_deepest_tile() {
752         let tree = TreeNode {
753             name: "root".into(),
754             size: 1000,
755             is_dir: true,
756             children: vec![TreeNode {
757                 name: "sub".into(),
758                 size: 1000,
759                 is_dir: true,
760                 children: vec![file("leaf.bin", 1000)],
761             }],
762         };
763         let mut state = SpaceState::default();
764         state.scanned_dir = PathBuf::from("/root");
765         state.tree = Some(tree);
766         state.relayout((0.0, 0.0, 400.0, 400.0));
767 
768         // Dead centre is inside root, sub, and leaf — the leaf must win.
769         let hit = state.tile_at(200.0, 200.0).unwrap();
770         assert_eq!(state.tiles[hit].name, "leaf.bin");
771 
772         assert!(state.tile_at(-5.0, 200.0).is_none());
773     }
774 
775     #[test]
776     fn category_maps_extensions() {
777         assert_eq!(Category::of("photo.JPG"), Category::Image);
778         assert_eq!(Category::of("main.rs"), Category::Code);
779         assert_eq!(Category::of("disk.qcow2"), Category::Binary);
780         assert_eq!(Category::of("notes.md"), Category::Document);
781         assert_eq!(Category::of("bundle.tar.gz"), Category::Archive);
782         // No extension at all; and a dotfile, whose "extension" is its name.
783         assert_eq!(Category::of("README"), Category::Other);
784         assert_eq!(Category::of(".bashrc"), Category::Other);
785         // A dotfile that really does carry one is still classified.
786         assert_eq!(Category::of(".config.toml"), Category::Document);
787     }
788 
789     #[test]
790     fn elide_respects_width() {
791         assert_eq!(elide("hi", 0.0), "");
792         assert_eq!(elide("short", 200.0), "short");
793         let long = elide("a-very-long-file-name.txt", 30.0);
794         assert!(long.ends_with('…'));
795         assert!(long.chars().count() <= 6);
796     }
797 }