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

src/pages/space.rs (50.8K)

   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::collections::HashMap;
  18 use std::path::{Path, PathBuf};
  19 use std::sync::Arc;
  20 use std::sync::atomic::{AtomicBool, Ordering};
  21 
  22 use cce_ui::context::UiContext;
  23 use cce_ui::widget::Handle;
  24 use cce_ui::widget::{Adapted, Breadcrumb, PathController};
  25 
  26 use crate::pages::PageContent;
  27 use crate::pages::browse::BrowseState;
  28 use crate::services::scan::TreeNode;
  29 use crate::util::{format_size, truncate_px};
  30 
  31 /// Below this, in either dimension, a tile is too small to read and is not
  32 /// emitted at all — its bytes stay accounted for in the parent's area, which
  33 /// still shows as filled. This is what bounds tile count on a large tree far
  34 /// more effectively than [`MAX_TILES`].
  35 const MIN_TILE: f32 = 3.0;
  36 
  37 /// A directory smaller than this is drawn as one aggregate block rather than
  38 /// recursed into: below it the frame and padding would eat the children.
  39 const MIN_RECURSE: f32 = 20.0;
  40 
  41 /// A directory's entries smaller than this many px² are not laid out one by
  42 /// one but lumped into a single "N smaller items" block. Each alone would be
  43 /// culled under `MIN_TILE`, and a folder of thousands of them left a dark
  44 /// hole as big as all of them together — read, like an unfilled block, as
  45 /// empty space. Twice `MIN_TILE`² catches what squarify would cut to slivers.
  46 const REST_AREA: f32 = MIN_TILE * MIN_TILE * 2.0;
  47 
  48 /// Hard ceiling on emitted tiles, so a pathological tree cannot make a frame
  49 /// rebuild unbounded. Reached only when MIN_TILE culling has not already.
  50 const MAX_TILES: usize = 24_000;
  51 
  52 /// Inset applied to a directory's rect before laying out its children — the
  53 /// gap that makes nesting legible.
  54 const DIR_PAD: f32 = 1.0;
  55 
  56 /// Height reserved at the top of a directory's rect for its name, when the
  57 /// rect is big enough to bother.
  58 const DIR_LABEL_H: f32 = 13.0;
  59 
  60 /// Directory rects at least this tall get a name strip.
  61 const DIR_LABEL_MIN: f32 = 46.0;
  62 
  63 /// File tiles at least this big get their name drawn inside them.
  64 const FILE_LABEL_MIN_W: f32 = 44.0;
  65 const FILE_LABEL_MIN_H: f32 = 15.0;
  66 
  67 /// Rows reserved at the bottom of the pane: the hover/summary readout, then
  68 /// the colour legend under it.
  69 const FOOTER_H: f32 = 34.0;
  70 const FOOTER_LINE: f32 = 15.0;
  71 const FOOTER_FONT: f32 = 10.0;
  72 /// The face `pc.text` falls back to, which the footer measures in — the
  73 /// preview pane's convention.
  74 const FOOTER_FAMILY: &str = "sans-serif";
  75 
  76 /// A legend swatch's side, its gap to its label, and the gap between entries.
  77 const SWATCH: f32 = 8.0;
  78 const SWATCH_GAP: f32 = 4.0;
  79 const LEGEND_GAP: f32 = 14.0;
  80 
  81 /// The dark seam left between neighbouring file tiles, half off each side.
  82 /// Without it, files of one kind side by side fused into one slab: ten
  83 /// films read as one, three thousand object files as a single file. A tile
  84 /// narrower than `FILE_GAP_MIN` keeps its full width, since the gap would eat it.
  85 const FILE_GAP: f32 = 1.0;
  86 const FILE_GAP_MIN: f32 = 4.0;
  87 
  88 /// Opacity of the frame around the hovered tile's top-level folder: there
  89 /// to be found, not to compete with the hover outline itself.
  90 const TOP_FOLDER_ALPHA: f32 = 0.45;
  91 
  92 /// How far a directory drawn as one block is pulled from its kind's colour
  93 /// toward the frame: dim enough that it never reads as one big file of that
  94 /// kind, bright enough that it never reads as empty.
  95 const AGGREGATE_DIM: f32 = 0.6;
  96 
  97 // ── File-type colors ────────────────────────────────────────────────
  98 
  99 /// The category a file's extension puts it in. Area says how big a thing is;
 100 /// hue says what kind of thing it is, which is how you tell "my photo library"
 101 /// from "one enormous VM image" at a glance.
 102 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 103 pub enum Category {
 104     Image,
 105     Video,
 106     Audio,
 107     Code,
 108     Document,
 109     Archive,
 110     Binary,
 111     Other,
 112 }
 113 
 114 impl Category {
 115     pub fn of(name: &str) -> Category {
 116         // A leading-dot name with no other dot (`.bashrc`) has no extension —
 117         // splitting on the last dot would otherwise read "bashrc" as one.
 118         let ext = name
 119             .rsplit_once('.')
 120             .filter(|(stem, _)| !stem.is_empty())
 121             .map(|(_, e)| e.to_ascii_lowercase());
 122         match ext.as_deref() {
 123             Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tiff" | "tif"
 124                 | "svg" | "svgz" | "psd" | "xcf" | "raw" | "cr2" | "nef" | "heic" | "avif") => Category::Image,
 125             Some("mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" | "mpg" | "mpeg" | "wmv"
 126                 | "flv" | "ogv") => Category::Video,
 127             Some("mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" | "wma" | "aiff"
 128                 | "mid" | "midi") => Category::Audio,
 129             Some("rs" | "c" | "h" | "cpp" | "hpp" | "cc" | "py" | "js" | "ts" | "jsx" | "tsx"
 130                 | "go" | "java" | "kt" | "rb" | "php" | "swift" | "hs" | "ml" | "lua" | "sh"
 131                 | "bash" | "zsh" | "fish" | "vim" | "el" | "scm" | "clj" | "ex" | "erl"
 132                 | "sql" | "html" | "css" | "scss" | "glsl" | "cl" | "wgsl") => Category::Code,
 133             Some("txt" | "md" | "rst" | "org" | "pdf" | "doc" | "docx" | "odt" | "rtf"
 134                 | "xls" | "xlsx" | "ods" | "csv" | "tsv" | "ppt" | "pptx" | "odp" | "epub"
 135                 | "mobi" | "tex" | "json" | "toml" | "yaml" | "yml" | "xml" | "kdl"
 136                 | "ini" | "conf" | "cfg" | "log") => Category::Document,
 137             Some("zip" | "tar" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "tgz" | "txz"
 138                 | "iso" | "img" | "dmg" | "deb" | "rpm" | "pkg" | "apk" | "jar" | "whl") => Category::Archive,
 139             Some("so" | "a" | "o" | "dll" | "dylib" | "exe" | "bin" | "elf" | "class"
 140                 | "pyc" | "rlib" | "wasm" | "qcow2" | "vdi" | "vmdk") => Category::Binary,
 141             _ => Category::Other,
 142         }
 143     }
 144 
 145     /// Position in [`CATEGORIES`], which lists them in declaration order.
 146     pub fn index(self) -> usize {
 147         self as usize
 148     }
 149 
 150     pub fn label(self) -> &'static str {
 151         match self {
 152             Category::Image => "Images",
 153             Category::Video => "Video",
 154             Category::Audio => "Audio",
 155             Category::Code => "Code",
 156             Category::Document => "Documents",
 157             Category::Archive => "Archives",
 158             Category::Binary => "Binaries",
 159             Category::Other => "Other",
 160         }
 161     }
 162 
 163     /// Written as sRGB hex and converted the same way config colors are, so
 164     /// these sit in the same space as everything else the renderer is handed.
 165     pub fn color(self) -> [f32; 4] {
 166         let hex = match self {
 167             Category::Image => "#4f9fd1",
 168             Category::Video => "#9b6bd6",
 169             Category::Audio => "#4fb98a",
 170             Category::Code => "#dfb341",
 171             Category::Document => "#d1685f",
 172             Category::Archive => "#c77e3e",
 173             Category::Binary => "#b85c9e",
 174             Category::Other => "#6b7280",
 175         };
 176         cce_ui::color::parse_hex_rgba_linear(hex).unwrap_or([0.4, 0.4, 0.45, 1.0])
 177     }
 178 }
 179 
 180 /// Every category, for the legend.
 181 pub const CATEGORIES: [Category; 8] = [
 182     Category::Image,
 183     Category::Video,
 184     Category::Audio,
 185     Category::Code,
 186     Category::Document,
 187     Category::Archive,
 188     Category::Binary,
 189     Category::Other,
 190 ];
 191 
 192 /// What a subtree holds, by kind. The root's is the legend; every
 193 /// directory's names the colour it wears when drawn as one block.
 194 #[derive(Debug, Clone, Default, PartialEq)]
 195 pub struct Breakdown {
 196     /// Bytes per [`Category`], indexed by [`Category::index`].
 197     pub bytes: [u64; 8],
 198     pub files: u64,
 199 }
 200 
 201 impl Breakdown {
 202     /// The kind holding the most bytes; `None` when nothing has any.
 203     pub fn dominant(&self) -> Option<Category> {
 204         let mut best = None;
 205         let mut most = 0;
 206         for c in CATEGORIES {
 207             if self.bytes[c.index()] > most {
 208                 most = self.bytes[c.index()];
 209                 best = Some(c);
 210             }
 211         }
 212         best
 213     }
 214 
 215     /// The kinds present, largest first — the legend's order.
 216     pub fn legend(&self) -> Vec<(Category, u64)> {
 217         let mut out: Vec<(Category, u64)> = CATEGORIES
 218             .into_iter()
 219             .map(|c| (c, self.bytes[c.index()]))
 220             .filter(|&(_, b)| b > 0)
 221             .collect();
 222         out.sort_by(|a, b| b.1.cmp(&a.1));
 223         out
 224     }
 225 }
 226 
 227 /// Sum `node` by kind, recording every directory's dominant kind under its
 228 /// path along the way. One pass when a scan lands, so a relayout (every frame
 229 /// of a resize) only looks a block's colour up.
 230 fn tally(node: &TreeNode, path: &Path, dominant: &mut HashMap<PathBuf, Category>) -> Breakdown {
 231     let mut b = Breakdown::default();
 232     if !node.is_dir {
 233         b.bytes[Category::of(&node.name).index()] += node.size;
 234         b.files = 1;
 235         return b;
 236     }
 237     for child in &node.children {
 238         if child.is_dir {
 239             let sub = tally(child, &path.join(&child.name), dominant);
 240             for (acc, n) in b.bytes.iter_mut().zip(sub.bytes) {
 241                 *acc += n;
 242             }
 243             b.files += sub.files;
 244         } else {
 245             b.bytes[Category::of(&child.name).index()] += child.size;
 246             b.files += 1;
 247         }
 248     }
 249     if let Some(c) = b.dominant() {
 250         dominant.insert(path.to_path_buf(), c);
 251     }
 252     b
 253 }
 254 
 255 // ── Tiles ───────────────────────────────────────────────────────────
 256 
 257 /// One laid-out rectangle. Directories come before their children in the list,
 258 /// so a reverse scan finds the deepest tile under a point first.
 259 #[derive(Debug, Clone)]
 260 pub struct Tile {
 261     pub path: PathBuf,
 262     pub name: String,
 263     pub size: u64,
 264     pub is_dir: bool,
 265     pub depth: u32,
 266     /// (x, y, w, h) in window coordinates.
 267     pub rect: (f32, f32, f32, f32),
 268     /// Set on a directory drawn as one block — too small to open, or nothing
 269     /// inside it big enough to place: the kind that fills most of it. Painted
 270     /// in the frame colour, such a block read as empty space however full it was.
 271     pub aggregate: Option<Category>,
 272     /// Nonzero on the block standing for that many of a directory's smallest
 273     /// entries (see [`REST_AREA`]). It carries the directory's own path, so a
 274     /// click selects and a double-click opens the directory they are in.
 275     pub rest: usize,
 276 }
 277 
 278 impl Tile {
 279     fn contains(&self, x: f32, y: f32) -> bool {
 280         let (rx, ry, rw, rh) = self.rect;
 281         x >= rx && x < rx + rw && y >= ry && y < ry + rh
 282     }
 283 }
 284 
 285 // ── Squarified layout ───────────────────────────────────────────────
 286 
 287 /// Lay `values` (which MUST be sorted descending and strictly positive) into
 288 /// `rect`, returning one rect per value in the same order.
 289 ///
 290 /// This is the squarified treemap of Bruls, Huizing &amp; van Wijk (2000): fill
 291 /// the rect row by row along its shorter side, growing each row only while
 292 /// doing so improves the worst aspect ratio in it. The result is tiles close
 293 /// to square, which is what makes areas visually comparable — a naive
 294 /// slice-and-dice gives slivers you cannot compare at all.
 295 fn squarify(values: &[f64], rect: (f32, f32, f32, f32)) -> Vec<(f32, f32, f32, f32)> {
 296     let mut out = Vec::with_capacity(values.len());
 297     let (rx, ry, rw, rh) = rect;
 298     let total: f64 = values.iter().sum();
 299     if values.is_empty() || total <= 0.0 || rw <= 0.0 || rh <= 0.0 {
 300         return vec![(0.0, 0.0, 0.0, 0.0); values.len()];
 301     }
 302 
 303     // Work in pixel² so a row's thickness is just its area over its length.
 304     let scale = (rw as f64) * (rh as f64) / total;
 305     let areas: Vec<f64> = values.iter().map(|v| v * scale).collect();
 306 
 307     let (mut x, mut y, mut w, mut h) = (rx as f64, ry as f64, rw as f64, rh as f64);
 308     let mut i = 0;
 309 
 310     while i < areas.len() {
 311         if w <= 0.0 || h <= 0.0 {
 312             out.extend(std::iter::repeat((0.0, 0.0, 0.0, 0.0)).take(areas.len() - i));
 313             break;
 314         }
 315 
 316         // Rows run along the shorter side; that is the whole trick.
 317         let horizontal = w >= h;
 318         let side = if horizontal { h } else { w };
 319 
 320         // Grow the row while the worst aspect ratio in it keeps improving.
 321         let mut end = i;
 322         let mut sum = 0.0;
 323         let mut best = f64::INFINITY;
 324         while end < areas.len() {
 325             let next_sum = sum + areas[end];
 326             // Descending order means the row's max is its first item and its
 327             // min is the one we are considering adding.
 328             let worst = worst_ratio(next_sum, areas[i], areas[end], side);
 329             if end > i && worst > best {
 330                 break;
 331             }
 332             best = worst;
 333             sum = next_sum;
 334             end += 1;
 335         }
 336 
 337         // Place the row.
 338         let thickness = (sum / side).min(if horizontal { w } else { h });
 339         let mut offset = 0.0;
 340         for k in i..end {
 341             let len = if sum > 0.0 { areas[k] / sum * side } else { 0.0 };
 342             let r = if horizontal {
 343                 (x, y + offset, thickness, len)
 344             } else {
 345                 (x + offset, y, len, thickness)
 346             };
 347             out.push((r.0 as f32, r.1 as f32, r.2 as f32, r.3 as f32));
 348             offset += len;
 349         }
 350 
 351         if horizontal {
 352             x += thickness;
 353             w -= thickness;
 354         } else {
 355             y += thickness;
 356             h -= thickness;
 357         }
 358         i = end;
 359     }
 360 
 361     out
 362 }
 363 
 364 /// Worst (largest) aspect ratio produced by a row of total area `sum` laid
 365 /// along a side of length `side`, containing items of area `max` and `min`.
 366 fn worst_ratio(sum: f64, max: f64, min: f64, side: f64) -> f64 {
 367     if sum <= 0.0 || side <= 0.0 || min <= 0.0 {
 368         return f64::INFINITY;
 369     }
 370     let s2 = sum * sum;
 371     let w2 = side * side;
 372     (w2 * max / s2).max(s2 / (w2 * min))
 373 }
 374 
 375 // ── Messages ────────────────────────────────────────────────────────
 376 
 377 /// Results coming back from a `FsRequest::ScanTree`. Each carries the
 378 /// directory it is about, because a scan that has been superseded can still
 379 /// deliver messages after the app has moved on.
 380 #[derive(Debug, Clone)]
 381 pub enum SpaceMessage {
 382     Progress { dir: PathBuf, files: u64, bytes: u64 },
 383     Scanned { dir: PathBuf, tree: TreeNode },
 384     Failed(String),
 385 }
 386 
 387 pub fn update(state: &mut SpaceState, msg: SpaceMessage) {
 388     match msg {
 389         SpaceMessage::Progress { dir, files, bytes } => {
 390             // Late progress from a scan we no longer care about.
 391             if !state.scanning || state.scanned_dir != dir {
 392                 return;
 393             }
 394             state.scan_files = files;
 395             state.scan_bytes = bytes;
 396         }
 397         SpaceMessage::Scanned { dir, tree } => {
 398             if state.scanned_dir != dir {
 399                 return;
 400             }
 401             state.scan_finished(dir, tree);
 402         }
 403         SpaceMessage::Failed(err) => state.scan_failed(err),
 404     }
 405 }
 406 
 407 // ── Page state ──────────────────────────────────────────────────────
 408 
 409 pub struct SpaceState {
 410     pub breadcrumb: Handle<Adapted<Breadcrumb>>,
 411     /// The directory the current `tree` describes. Empty until a scan lands.
 412     pub scanned_dir: PathBuf,
 413     pub tree: Option<TreeNode>,
 414     pub tiles: Vec<Tile>,
 415     /// Rect the current `tiles` were laid out for — a resize invalidates them.
 416     laid_out: (f32, f32, f32, f32),
 417     /// The map region as of the last `view`. Input handlers need it to tell a
 418     /// press on the treemap from one on the window root plate behind it.
 419     pub map_rect: (f32, f32, f32, f32),
 420     pub hovered: Option<usize>,
 421     /// Selection is held by path, not index: a relayout renumbers every tile.
 422     pub selected_path: Option<PathBuf>,
 423     pub scanning: bool,
 424     pub scan_files: u64,
 425     pub scan_bytes: u64,
 426     /// Raised to abandon the in-flight scan when a newer one supersedes it.
 427     pub cancel: Arc<AtomicBool>,
 428     pub error: Option<String>,
 429     /// The scanned tree by kind: the legend, and the file count.
 430     pub breakdown: Breakdown,
 431     /// Every directory's dominant kind, by path — what a directory drawn as
 432     /// one block is coloured by. Filled with `breakdown`, once per scan.
 433     dominant: HashMap<PathBuf, Category>,
 434     /// Pointer focus (the app's well-focus tracking): the map well renders as
 435     /// the tinted carve — accent ring replacing the relief lighting.
 436     pub focused: bool,
 437 }
 438 
 439 impl SpaceState {
 440     /// The page's state, its breadcrumb inserted into `ctx`.
 441     pub fn new(ctx: &mut UiContext) -> Self {
 442         let mut breadcrumb = Breadcrumb::new();
 443         breadcrumb.set_network_opacity(0.95);
 444         Self {
 445             breadcrumb: ctx.insert(breadcrumb),
 446             scanned_dir: PathBuf::new(),
 447             tree: None,
 448             tiles: Vec::new(),
 449             laid_out: (0.0, 0.0, 0.0, 0.0),
 450             map_rect: (0.0, 0.0, 0.0, 0.0),
 451             hovered: None,
 452             selected_path: None,
 453             scanning: false,
 454             scan_files: 0,
 455             scan_bytes: 0,
 456             cancel: Arc::new(AtomicBool::new(false)),
 457             error: None,
 458             breakdown: Breakdown::default(),
 459             dominant: HashMap::new(),
 460             focused: false,
 461         }
 462     }
 463 }
 464 
 465 impl SpaceState {
 466     /// True when `dir` is not what the current tree describes — the caller
 467     /// should kick off a scan.
 468     pub fn needs_scan(&self, dir: &Path) -> bool {
 469         !self.scanning && (self.tree.is_none() || self.scanned_dir != dir)
 470     }
 471 
 472     /// Abandon any in-flight scan and arm a fresh cancel token for the next
 473     /// one. Returns the token the new scan should carry.
 474     pub fn begin_scan(&mut self, dir: &Path) -> Arc<AtomicBool> {
 475         self.cancel.store(true, Ordering::Relaxed);
 476         self.cancel = Arc::new(AtomicBool::new(false));
 477         self.scanning = true;
 478         self.scan_files = 0;
 479         self.scan_bytes = 0;
 480         self.error = None;
 481         self.tree = None;
 482         self.tiles.clear();
 483         self.breakdown = Breakdown::default();
 484         self.dominant.clear();
 485         self.hovered = None;
 486         self.scanned_dir = dir.to_path_buf();
 487         self.laid_out = (0.0, 0.0, 0.0, 0.0);
 488         self.cancel.clone()
 489     }
 490 
 491     pub fn scan_finished(&mut self, dir: PathBuf, tree: TreeNode) {
 492         self.scanning = false;
 493         self.dominant.clear();
 494         self.breakdown = tally(&tree, &dir, &mut self.dominant);
 495         self.scanned_dir = dir;
 496         self.tree = Some(tree);
 497         self.tiles.clear();
 498         self.laid_out = (0.0, 0.0, 0.0, 0.0);
 499         self.hovered = None;
 500     }
 501 
 502     pub fn scan_failed(&mut self, err: String) {
 503         self.scanning = false;
 504         self.tree = None;
 505         self.tiles.clear();
 506         self.breakdown = Breakdown::default();
 507         self.dominant.clear();
 508         self.error = Some(err);
 509     }
 510 
 511     /// The deepest tile under the cursor, which is the one the user means.
 512     pub fn tile_at(&self, x: f32, y: f32) -> Option<usize> {
 513         self.tiles.iter().rposition(|t| t.contains(x, y))
 514     }
 515 
 516     /// Recompute tiles for `rect` if the tree or the rect has changed.
 517     pub fn relayout(&mut self, rect: (f32, f32, f32, f32)) {
 518         if self.laid_out == rect && !self.tiles.is_empty() {
 519             return;
 520         }
 521         self.tiles.clear();
 522         self.laid_out = rect;
 523         let Some(tree) = self.tree.take() else { return };
 524         let root = self.scanned_dir.clone();
 525         place(&tree, &root, rect, 0, &self.dominant, &mut self.tiles);
 526         self.tree = Some(tree);
 527         // A relayout renumbers everything; the stale hover index would point
 528         // at an unrelated tile.
 529         self.hovered = None;
 530     }
 531 }
 532 
 533 /// Recursively lay `node` into `rect`, appending tiles. The node's own tile is
 534 /// pushed before its children so a reverse hit-test finds the deepest first.
 535 /// A directory none of whose children got a tile is marked an aggregate, in
 536 /// the colour `dominant` gives its path.
 537 fn place(
 538     node: &TreeNode,
 539     path: &Path,
 540     rect: (f32, f32, f32, f32),
 541     depth: u32,
 542     dominant: &HashMap<PathBuf, Category>,
 543     out: &mut Vec<Tile>,
 544 ) {
 545     let (w, h) = (rect.2, rect.3);
 546     if w < MIN_TILE || h < MIN_TILE || out.len() >= MAX_TILES {
 547         return;
 548     }
 549 
 550     let idx = out.len();
 551     out.push(Tile {
 552         path: path.to_path_buf(),
 553         name: node.name.clone(),
 554         size: node.size,
 555         is_dir: node.is_dir,
 556         depth,
 557         rect,
 558         aggregate: None,
 559         rest: 0,
 560     });
 561 
 562     if !node.is_dir {
 563         return;
 564     }
 565     place_children(node, path, rect, depth, dominant, out);
 566     if out.len() == idx + 1 {
 567         out[idx].aggregate = dominant.get(path).copied();
 568     }
 569 }
 570 
 571 /// Lay a directory's children inside its frame, when it is big enough to.
 572 fn place_children(
 573     node: &TreeNode,
 574     path: &Path,
 575     rect: (f32, f32, f32, f32),
 576     depth: u32,
 577     dominant: &HashMap<PathBuf, Category>,
 578     out: &mut Vec<Tile>,
 579 ) {
 580     let (x, y, w, h) = rect;
 581     if node.children.is_empty() {
 582         return;
 583     }
 584     // Too small to subdivide usefully: it stays one aggregate block.
 585     if w < MIN_RECURSE || h < MIN_RECURSE {
 586         return;
 587     }
 588 
 589     // Inset for the frame, plus a name strip when there is room for one.
 590     let label = h >= DIR_LABEL_MIN && w >= FILE_LABEL_MIN_W;
 591     let top = DIR_PAD + if label { DIR_LABEL_H } else { 0.0 };
 592     let inner = (
 593         x + DIR_PAD,
 594         y + top,
 595         (w - DIR_PAD * 2.0).max(0.0),
 596         (h - top - DIR_PAD).max(0.0),
 597     );
 598     if inner.2 < MIN_TILE || inner.3 < MIN_TILE {
 599         return;
 600     }
 601 
 602     // Zero-byte children have no area to occupy and would divide by zero in
 603     // the aspect-ratio test; they are simply not drawn.
 604     let kids: Vec<&TreeNode> = node.children.iter().filter(|c| c.size > 0).collect();
 605     if kids.is_empty() {
 606         return;
 607     }
 608 
 609     // Children are sorted largest first, so the ones under REST_AREA are a
 610     // tail. Two or more of them become one block; a lone one is laid out as
 611     // itself and stands or falls by MIN_TILE.
 612     let total: f64 = kids.iter().map(|c| c.size as f64).sum();
 613     let px_per_byte = (inner.2 * inner.3) as f64 / total;
 614     let mut keep = kids
 615         .iter()
 616         .position(|c| (c.size as f64 * px_per_byte) < REST_AREA as f64)
 617         .unwrap_or(kids.len());
 618     if kids.len() - keep < 2 {
 619         keep = kids.len();
 620     }
 621     let (shown, rest) = kids.split_at(keep);
 622 
 623     // squarify wants its values descending, and the lump may outweigh some
 624     // of the entries shown alone, so it takes its place among them.
 625     let rest_size: u64 = rest.iter().map(|c| c.size).sum();
 626     let mut slots: Vec<(u64, Option<&TreeNode>)> = shown.iter().map(|c| (c.size, Some(*c))).collect();
 627     if !rest.is_empty() {
 628         let at = slots.partition_point(|&(size, _)| size >= rest_size);
 629         slots.insert(at, (rest_size, None));
 630     }
 631     let values: Vec<f64> = slots.iter().map(|&(size, _)| size as f64).collect();
 632 
 633     for (&(_, child), r) in slots.iter().zip(squarify(&values, inner)) {
 634         match child {
 635             Some(child) => place(child, &path.join(&child.name), r, depth + 1, dominant, out),
 636             None => {
 637                 if r.2 < MIN_TILE || r.3 < MIN_TILE || out.len() >= MAX_TILES {
 638                     continue;
 639                 }
 640                 out.push(Tile {
 641                     path: path.to_path_buf(),
 642                     name: format!("{} smaller items", group_digits(rest.len() as u64)),
 643                     size: rest_size,
 644                     is_dir: true,
 645                     depth: depth + 1,
 646                     rect: r,
 647                     aggregate: rest_kind(rest, path, dominant),
 648                     rest: rest.len(),
 649                 });
 650             }
 651         }
 652     }
 653 }
 654 
 655 /// What fills a lump of small entries most, by bytes: a file by its own
 656 /// kind, a directory by its dominant one.
 657 fn rest_kind(rest: &[&TreeNode], path: &Path, dominant: &HashMap<PathBuf, Category>) -> Option<Category> {
 658     let mut b = Breakdown::default();
 659     for c in rest {
 660         let kind = if c.is_dir { dominant.get(&path.join(&c.name)).copied() } else { Some(Category::of(&c.name)) };
 661         if let Some(kind) = kind {
 662             b.bytes[kind.index()] += c.size;
 663         }
 664     }
 665     b.dominant()
 666 }
 667 
 668 // ── View ────────────────────────────────────────────────────────────
 669 
 670 pub fn view(
 671     state: &mut SpaceState,
 672     browse: &BrowseState,
 673     cx: f32,
 674     cy: f32,
 675     cw: f32,
 676     ch: f32,
 677     ctx: &mut cce_ui::context::UiContext,
 678 ) -> PageContent {
 679     let mut pc = PageContent::new();
 680 
 681     let top = crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, cx, cy, cw, ctx);
 682 
 683     let mut segments = Vec::new();
 684     for component in browse.current_dir.components() {
 685         let s = component.as_os_str().to_string_lossy().to_string();
 686         if s != "/" && !s.is_empty() {
 687             segments.push(s);
 688         }
 689     }
 690     ctx[state.breadcrumb].set_path(&segments);
 691 
 692     // The map occupies everything below the header, less the footer readout.
 693     let map = (cx, top, cw, (cy + ch - top - FOOTER_H).max(0.0));
 694     state.map_rect = map;
 695     // Fill and well share one rect AND one radius — `RowList::push_prims`'s
 696     // pairing, since this pane is the Browse list's opposite number across the
 697     // same split. It used to fill square (`pc.rect`) under a well carved at
 698     // `plate_corner_radius` (12.0), so the corners disagreed twice over: with
 699     // their own fill, and with the r=4 list the pane sits beside.
 700     //
 701     // The tiles stay square and unclipped — a treemap cannot follow a curve —
 702     // so a corner still contradicts the rim. Dropping 12.0 to 4.0 shrinks that
 703     // residual to what RowList already lives with for its square row overlays.
 704     // Rounding the fill alone would have been inert: the root directory tile
 705     // paints a full square rect over the whole map, so the fill is not visible
 706     // except in the 1px inset.
 707     let bg = cce_ui::color::list_bg_color();
 708     let radius = cce_ui::layout::list_corner_radius();
 709     {
 710         use cce_ui::layout::RenderTarget;
 711         pc.rect_with_radius_corners(bg, map.0, map.1, map.2, map.3, radius, (true, true, true, true));
 712     }
 713     if state.focused {
 714         pc.relief_recessed_focused(map.0, map.1, map.2, map.3, radius);
 715     } else {
 716         pc.relief_recessed(map.0, map.1, map.2, map.3, radius);
 717     }
 718 
 719     let text_dim = cce_ui::color::TEXT_DIM;
 720     let text_fg = cce_ui::color::TEXT_FG;
 721 
 722     if let Some(err) = &state.error {
 723         pc.text(err, map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
 724         return pc;
 725     }
 726 
 727     if state.scanning {
 728         let msg = format!(
 729             "Scanning {} — {} files, {}",
 730             browse.current_dir.display(),
 731             state.scan_files,
 732             format_size(state.scan_bytes)
 733         );
 734         pc.text(&msg, map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
 735         return pc;
 736     }
 737 
 738     // Inset one pixel so tiles do not sit on top of the well's rim.
 739     state.relayout((map.0 + 1.0, map.1 + 1.0, (map.2 - 2.0).max(0.0), (map.3 - 2.0).max(0.0)));
 740 
 741     // A root of zero bytes still gets a tile — an empty, dark map that read as
 742     // a scan that had not finished.
 743     if state.tiles.is_empty() || state.tree.as_ref().is_none_or(|t| t.size == 0) {
 744         pc.text("Nothing to show — nothing here takes up any space.", map.0 + cce_ui::layout::plate_padding(), map.1 + cce_ui::layout::plate_padding(), 11.0, text_dim);
 745         return pc;
 746     }
 747 
 748     let frame = cce_ui::color::parse_hex_rgba_linear("#20242b").unwrap_or([0.1, 0.1, 0.12, 1.0]);
 749     for tile in &state.tiles {
 750         let (tx, ty, tw, th) = tile.rect;
 751         if tile.is_dir {
 752             // A directory paints only its frame — its children cover the
 753             // inside, and where they do not, the gap reads as slack space.
 754             pc.rect(frame, tx, ty, tw, th);
 755             // One drawn as a block has no children over it: fill it, inside a
 756             // frame-coloured rim, in a dimmed copy of what fills it most.
 757             // An opened directory's name sits on its frame-coloured strip; a
 758             // block's sits on its fill.
 759             let mut label = text_dim;
 760             if let Some(cat) = tile.aggregate {
 761                 let fill = mix(cat.color(), frame, AGGREGATE_DIM);
 762                 if tw > 2.0 && th > 2.0 {
 763                     pc.rect(fill, tx + 1.0, ty + 1.0, tw - 2.0, th - 2.0);
 764                 }
 765                 label = label_on(fill, text_fg, frame);
 766             }
 767             if th >= DIR_LABEL_MIN && tw >= FILE_LABEL_MIN_W {
 768                 pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, label);
 769             }
 770         } else {
 771             let (fx, fy, fw, fh) = file_face(tile.rect);
 772             let fill = Category::of(&tile.name).color();
 773             pc.rect(fill, fx, fy, fw, fh);
 774             if tw >= FILE_LABEL_MIN_W && th >= FILE_LABEL_MIN_H {
 775                 pc.text(&elide(&tile.name, tw - 6.0), tx + 3.0, ty + 2.0, 10.0, label_on(fill, text_fg, frame));
 776             }
 777         }
 778     }
 779 
 780     // Selection and hover are drawn as outlines over the tiles. Under them,
 781     // fainter and heavier, the top-level folder the hovered tile lies in:
 782     // deep in the map, "which of my folders is this?" is the first question,
 783     // and a 1px frame on the dark between tiles did not answer it.
 784     if let Some(top) = state.hovered.and_then(|i| top_folder(&state.tiles, i)) {
 785         let [r, g, b, _] = cce_ui::color::TEXT_ACCENT;
 786         outline(&mut pc, state.tiles[top].rect, [r, g, b, TOP_FOLDER_ALPHA], 2.0);
 787     }
 788     if let Some(sel) = &state.selected_path {
 789         if let Some(t) = state.tiles.iter().find(|t| &t.path == sel) {
 790             outline(&mut pc, t.rect, cce_ui::color::TEXT_HEADER, 2.0);
 791         }
 792     }
 793     if let Some(idx) = state.hovered {
 794         if let Some(t) = state.tiles.get(idx) {
 795             outline(&mut pc, t.rect, cce_ui::color::TEXT_ACCENT, 1.0);
 796         }
 797     }
 798 
 799     // Footer, line one: whatever the cursor is over, else the summary. The
 800     // root tile covers the whole map, so "over the root" (a frame gap) counts
 801     // as over nothing.
 802     let left = cx + 8.0;
 803     let avail = (cw - 16.0).max(0.0);
 804     let line1 = map.1 + map.3 + 3.0;
 805     let total = state.tree.as_ref().map_or(0, |t| t.size);
 806     let hovered = state.hovered.and_then(|i| state.tiles.get(i)).filter(|t| t.depth > 0);
 807     let readout = match hovered {
 808         Some(t) => readout(t, &state.scanned_dir, total, avail),
 809         None => summary(total, state.breakdown.files, avail),
 810     };
 811     pc.text(&readout, left, line1, FOOTER_FONT, text_dim);
 812 
 813     // Line two: the legend, largest kind first, as many as fit. The hovered
 814     // tile's kind is lit, so the key answers "what colour is this?" in place.
 815     let lit = hovered.and_then(|t| if t.is_dir { t.aggregate } else { Some(Category::of(&t.name)) });
 816     let line2 = line1 + FOOTER_LINE;
 817     let mut x = left;
 818     for (cat, bytes) in state.breakdown.legend() {
 819         let label = format!("{} {}", cat.label(), format_size(bytes));
 820         let w = SWATCH + SWATCH_GAP + cce_ui::widget::display::measure_text_width(&label, FOOTER_FAMILY, FOOTER_FONT);
 821         if x + w > left + avail {
 822             break;
 823         }
 824         pc.rect(cat.color(), x, line2 + (FOOTER_LINE - 2.0 - SWATCH) / 2.0, SWATCH, SWATCH);
 825         let color = if lit == Some(cat) { text_fg } else { text_dim };
 826         pc.text(&label, x + SWATCH + SWATCH_GAP, line2, FOOTER_FONT, color);
 827         x += w + LEGEND_GAP;
 828     }
 829 
 830     pc
 831 }
 832 
 833 /// The top-level folder (depth 1) holding tile `idx`, when it lies deeper
 834 /// than that. Tiles are flattened depth-first, parents first, so the nearest
 835 /// depth-1 tile before it is its ancestor.
 836 fn top_folder(tiles: &[Tile], idx: usize) -> Option<usize> {
 837     if tiles.get(idx)?.depth < 2 {
 838         return None;
 839     }
 840     tiles[..idx].iter().rposition(|t| t.depth == 1)
 841 }
 842 
 843 /// The part of a file tile its colour fills: the tile less half of
 844 /// [`FILE_GAP`] on every side, so the frame behind shows as a seam between
 845 /// neighbours. Each axis on its own, so a sliver keeps its length.
 846 fn file_face(rect: (f32, f32, f32, f32)) -> (f32, f32, f32, f32) {
 847     let (x, y, w, h) = rect;
 848     let (gx, gy) = (
 849         if w >= FILE_GAP_MIN { FILE_GAP / 2.0 } else { 0.0 },
 850         if h >= FILE_GAP_MIN { FILE_GAP / 2.0 } else { 0.0 },
 851     );
 852     (x + gx, y + gy, w - gx * 2.0, h - gy * 2.0)
 853 }
 854 
 855 /// The hovered tile, for the footer: its path under the scanned directory
 856 /// (cut from the FRONT, so the name survives), size, share of the whole, and
 857 /// for a block, what fills it.
 858 fn readout(t: &Tile, root: &Path, total: u64, avail: f32) -> String {
 859     let mut tail = format!("  —  {}  ·  {}", format_size(t.size), share(t.size, total));
 860     if t.rest > 0 {
 861         tail.insert_str(0, &format!("  ·  {}", t.name));
 862     }
 863     if let Some(cat) = t.aggregate {
 864         tail.push_str(&format!("  ·  mostly {}", cat.label().to_lowercase()));
 865     }
 866     // A lump directly under the root has the root's own path: no relative
 867     // part to show, so it names the root.
 868     let mut rel = match t.path.strip_prefix(root) {
 869         Ok(r) if r.as_os_str().is_empty() => root.display().to_string(),
 870         Ok(r) => r.display().to_string(),
 871         Err(_) => t.path.display().to_string(),
 872     };
 873     if t.is_dir && !rel.ends_with('/') {
 874         rel.push('/');
 875     }
 876     let tail_w = cce_ui::widget::display::measure_text_width(&tail, FOOTER_FAMILY, FOOTER_FONT);
 877     let rel = truncate_px(&rel, FOOTER_FAMILY, FOOTER_FONT, (avail - tail_w).max(0.0), true);
 878     rel + &tail
 879 }
 880 
 881 /// The whole scan, for the footer when nothing is hovered — with the one
 882 /// gesture the map has, dropped first when the line is short.
 883 fn summary(total: u64, files: u64, avail: f32) -> String {
 884     let noun = if files == 1 { "file" } else { "files" };
 885     let line = format!("{} in {} {noun}", format_size(total), group_digits(files));
 886     let hinted = format!("{line}  ·  double-click a block to zoom in");
 887     if cce_ui::widget::display::measure_text_width(&hinted, FOOTER_FAMILY, FOOTER_FONT) <= avail {
 888         return hinted;
 889     }
 890     truncate_px(&line, FOOTER_FAMILY, FOOTER_FONT, avail, false)
 891 }
 892 
 893 /// `part` as a share of `total`: whole percent from 10 up, one decimal below,
 894 /// and a floor so a sliver never reads as "0%".
 895 fn share(part: u64, total: u64) -> String {
 896     if total == 0 {
 897         return "—".to_string();
 898     }
 899     let p = part as f64 / total as f64 * 100.0;
 900     if p >= 9.95 {
 901         format!("{p:.0}%")
 902     } else if p >= 0.1 {
 903         format!("{p:.1}%")
 904     } else {
 905         "<0.1%".to_string()
 906     }
 907 }
 908 
 909 /// `12408` → `12,408`.
 910 fn group_digits(n: u64) -> String {
 911     let digits = n.to_string();
 912     let mut out = String::with_capacity(digits.len() + digits.len() / 3);
 913     for (i, ch) in digits.chars().enumerate() {
 914         if i > 0 && (digits.len() - i) % 3 == 0 {
 915             out.push(',');
 916         }
 917         out.push(ch);
 918     }
 919     out
 920 }
 921 
 922 /// Whichever of `light` and `dark` reads better on `bg`, by WCAG contrast
 923 /// ratio. Light text on every tile was 1.6:1 on Code's yellow and under 3:1 on
 924 /// most of the palette; the frame's near-black runs 3.8:1 (Binaries) to 7.9:1
 925 /// (Code), and Other's grey, where light still wins at 3.9:1, keeps it.
 926 fn label_on(bg: [f32; 4], light: [f32; 4], dark: [f32; 4]) -> [f32; 4] {
 927     let l = luminance(bg);
 928     let contrast = |c: [f32; 4]| {
 929         let (a, b) = (luminance(c).max(l), luminance(c).min(l));
 930         (a + 0.05) / (b + 0.05)
 931     };
 932     if contrast(dark) > contrast(light) { dark } else { light }
 933 }
 934 
 935 /// Relative luminance of a colour already in linear space.
 936 fn luminance(c: [f32; 4]) -> f32 {
 937     0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
 938 }
 939 
 940 /// `a` pulled toward `b` by `t` (0 = a, 1 = b). Both linear, so is the mix.
 941 fn mix(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
 942     [
 943         a[0] + (b[0] - a[0]) * t,
 944         a[1] + (b[1] - a[1]) * t,
 945         a[2] + (b[2] - a[2]) * t,
 946         a[3],
 947     ]
 948 }
 949 
 950 /// Four thin rects making a border — `PageContent` has no stroke primitive.
 951 fn outline(pc: &mut PageContent, rect: (f32, f32, f32, f32), color: [f32; 4], t: f32) {
 952     let (x, y, w, h) = rect;
 953     if w <= 0.0 || h <= 0.0 {
 954         return;
 955     }
 956     let t = t.min(w / 2.0).min(h / 2.0);
 957     pc.rect(color, x, y, w, t);
 958     pc.rect(color, x, y + h - t, w, t);
 959     pc.rect(color, x, y + t, t, h - t * 2.0);
 960     pc.rect(color, x + w - t, y + t, t, h - t * 2.0);
 961 }
 962 
 963 /// Trim to what fits in `width` px at the ~10px tile font. An estimate, not a
 964 /// shaping pass — labels here are decoration over an exact rectangle, and
 965 /// running cosmic-text over thousands of tiles per rebuild would not pay.
 966 fn elide(s: &str, width: f32) -> String {
 967     const CHAR_W: f32 = 5.2;
 968     let max = (width / CHAR_W).floor().max(0.0) as usize;
 969     if max == 0 {
 970         return String::new();
 971     }
 972     if s.chars().count() <= max {
 973         return s.to_string();
 974     }
 975     if max <= 1 {
 976         return "…".to_string();
 977     }
 978     s.chars().take(max - 1).collect::<String>() + "…"
 979 }
 980 
 981 #[cfg(test)]
 982 mod tests {
 983     use super::*;
 984 
 985     fn area(r: (f32, f32, f32, f32)) -> f32 {
 986         r.2 * r.3
 987     }
 988 
 989     #[test]
 990     fn squarify_covers_the_rect_exactly_once() {
 991         let values = vec![600.0, 300.0, 100.0, 50.0, 25.0, 25.0];
 992         let rect = (10.0, 20.0, 400.0, 300.0);
 993         let out = squarify(&values, rect);
 994 
 995         assert_eq!(out.len(), values.len());
 996         let total_area: f32 = out.iter().map(|r| area(*r)).sum();
 997         assert!(
 998             (total_area - area(rect)).abs() < 1.0,
 999             "tiles should tile the rect: {total_area} vs {}",
1000             area(rect)
1001         );
1002 
1003         // Every tile stays inside the rect.
1004         for r in &out {
1005             assert!(r.0 >= rect.0 - 0.01 && r.1 >= rect.1 - 0.01, "{r:?}");
1006             assert!(r.0 + r.2 <= rect.0 + rect.2 + 0.01, "{r:?}");
1007             assert!(r.1 + r.3 <= rect.1 + rect.3 + 0.01, "{r:?}");
1008         }
1009     }
1010 
1011     #[test]
1012     fn squarify_areas_are_proportional_to_values() {
1013         let values = vec![500.0, 250.0, 250.0];
1014         let rect = (0.0, 0.0, 200.0, 100.0);
1015         let out = squarify(&values, rect);
1016 
1017         let total = area(rect);
1018         assert!((area(out[0]) - total * 0.5).abs() < 1.0);
1019         assert!((area(out[1]) - total * 0.25).abs() < 1.0);
1020         assert!((area(out[2]) - total * 0.25).abs() < 1.0);
1021     }
1022 
1023     #[test]
1024     fn squarify_keeps_tiles_roughly_square() {
1025         // 64 equal values in a square: a slice-and-dice layout would give
1026         // 64 slivers of aspect 64:1. Squarified should stay near 1:1.
1027         let values = vec![1.0; 64];
1028         let out = squarify(&values, (0.0, 0.0, 400.0, 400.0));
1029         for r in &out {
1030             let aspect = (r.2 / r.3).max(r.3 / r.2);
1031             assert!(aspect < 2.0, "tile too elongated: {r:?} aspect {aspect}");
1032         }
1033     }
1034 
1035     #[test]
1036     fn squarify_handles_degenerate_input() {
1037         assert!(squarify(&[], (0.0, 0.0, 10.0, 10.0)).is_empty());
1038         // A zero-area rect still returns one entry per value.
1039         assert_eq!(squarify(&[1.0, 2.0], (0.0, 0.0, 0.0, 10.0)).len(), 2);
1040         assert_eq!(squarify(&[0.0, 0.0], (0.0, 0.0, 10.0, 10.0)).len(), 2);
1041     }
1042 
1043     fn file(name: &str, size: u64) -> TreeNode {
1044         TreeNode { name: name.into(), size, is_dir: false, children: Vec::new() }
1045     }
1046 
1047     #[test]
1048     fn place_nests_children_inside_their_directory() {
1049         let tree = TreeNode {
1050             name: "root".into(),
1051             size: 1000,
1052             is_dir: true,
1053             children: vec![
1054                 TreeNode {
1055                     name: "sub".into(),
1056                     size: 800,
1057                     is_dir: true,
1058                     children: vec![file("big.bin", 800)],
1059                 },
1060                 file("small.txt", 200),
1061             ],
1062         };
1063 
1064         let mut tiles = Vec::new();
1065         place(&tree, Path::new("/root"), (0.0, 0.0, 400.0, 400.0), 0, &HashMap::new(), &mut tiles);
1066 
1067         // Root, sub, big.bin, small.txt.
1068         assert_eq!(tiles.len(), 4);
1069         assert_eq!(tiles[0].name, "root");
1070         assert_eq!(tiles[0].depth, 0);
1071 
1072         // Paths are rebuilt from the names on the way down.
1073         let big = tiles.iter().find(|t| t.name == "big.bin").unwrap();
1074         assert_eq!(big.path, PathBuf::from("/root/sub/big.bin"));
1075         assert_eq!(big.depth, 2);
1076 
1077         // The child sits strictly inside its parent.
1078         let sub = tiles.iter().find(|t| t.name == "sub").unwrap();
1079         assert!(big.rect.0 >= sub.rect.0 && big.rect.1 >= sub.rect.1);
1080         assert!(big.rect.0 + big.rect.2 <= sub.rect.0 + sub.rect.2 + 0.01);
1081         assert!(big.rect.1 + big.rect.3 <= sub.rect.1 + sub.rect.3 + 0.01);
1082     }
1083 
1084     #[test]
1085     fn place_culls_tiles_below_the_minimum() {
1086         // One huge file and a thousand tiny ones in a small rect: the tiny
1087         // ones fall under MIN_TILE and are dropped rather than emitted as
1088         // sub-pixel slivers.
1089         let mut children = vec![file("huge.bin", 10_000_000)];
1090         for i in 0..1000 {
1091             children.push(file(&format!("tiny{i}"), 1));
1092         }
1093         let tree = TreeNode { name: "root".into(), size: 10_001_000, is_dir: true, children };
1094 
1095         let mut tiles = Vec::new();
1096         place(&tree, Path::new("/root"), (0.0, 0.0, 100.0, 100.0), 0, &HashMap::new(), &mut tiles);
1097 
1098         assert!(tiles.len() < 50, "expected culling, got {} tiles", tiles.len());
1099         assert!(tiles.iter().any(|t| t.name == "huge.bin"));
1100     }
1101 
1102     #[test]
1103     fn hit_test_finds_the_deepest_tile() {
1104         let mut ui = UiContext::new();
1105         let tree = TreeNode {
1106             name: "root".into(),
1107             size: 1000,
1108             is_dir: true,
1109             children: vec![TreeNode {
1110                 name: "sub".into(),
1111                 size: 1000,
1112                 is_dir: true,
1113                 children: vec![file("leaf.bin", 1000)],
1114             }],
1115         };
1116         let mut state = SpaceState::new(&mut ui);
1117         state.scanned_dir = PathBuf::from("/root");
1118         state.tree = Some(tree);
1119         state.relayout((0.0, 0.0, 400.0, 400.0));
1120 
1121         // Dead centre is inside root, sub, and leaf — the leaf must win.
1122         let hit = state.tile_at(200.0, 200.0).unwrap();
1123         assert_eq!(state.tiles[hit].name, "leaf.bin");
1124 
1125         assert!(state.tile_at(-5.0, 200.0).is_none());
1126     }
1127 
1128     #[test]
1129     fn category_maps_extensions() {
1130         assert_eq!(Category::of("photo.JPG"), Category::Image);
1131         assert_eq!(Category::of("main.rs"), Category::Code);
1132         assert_eq!(Category::of("disk.qcow2"), Category::Binary);
1133         assert_eq!(Category::of("notes.md"), Category::Document);
1134         assert_eq!(Category::of("bundle.tar.gz"), Category::Archive);
1135         // No extension at all; and a dotfile, whose "extension" is its name.
1136         assert_eq!(Category::of("README"), Category::Other);
1137         assert_eq!(Category::of(".bashrc"), Category::Other);
1138         // A dotfile that really does carry one is still classified.
1139         assert_eq!(Category::of(".config.toml"), Category::Document);
1140     }
1141 
1142     #[test]
1143     fn tally_sums_kinds_and_names_each_directory_by_its_largest() {
1144         let tree = TreeNode {
1145             name: "root".into(),
1146             size: 1300,
1147             is_dir: true,
1148             children: vec![
1149                 TreeNode {
1150                     name: "films".into(),
1151                     size: 1000,
1152                     is_dir: true,
1153                     children: vec![file("a.mkv", 900), file("notes.txt", 100)],
1154                 },
1155                 file("b.png", 200),
1156                 file("c.png", 100),
1157             ],
1158         };
1159         let mut dominant = HashMap::new();
1160         let b = tally(&tree, Path::new("/root"), &mut dominant);
1161 
1162         assert_eq!(b.files, 4);
1163         assert_eq!(b.bytes[Category::Video.index()], 900);
1164         assert_eq!(b.bytes[Category::Image.index()], 300);
1165         assert_eq!(b.bytes[Category::Document.index()], 100);
1166         assert_eq!(
1167             b.legend(),
1168             vec![(Category::Video, 900), (Category::Image, 300), (Category::Document, 100)],
1169             "only the kinds present, largest first"
1170         );
1171         assert_eq!(dominant.get(Path::new("/root/films")), Some(&Category::Video));
1172         assert_eq!(dominant.get(Path::new("/root")), Some(&Category::Video));
1173         assert_eq!(Breakdown::default().dominant(), None);
1174     }
1175 
1176     #[test]
1177     fn a_directory_drawn_as_one_block_wears_its_dominant_kind() {
1178         let mut ui = UiContext::new();
1179         // `sub` gets far less than MIN_RECURSE across, so it is not opened.
1180         let mut children = vec![file("huge.bin", 1_000_000)];
1181         children.push(TreeNode {
1182             name: "sub".into(),
1183             size: 20_000,
1184             is_dir: true,
1185             children: vec![file("x.mp3", 15_000), file("y.txt", 5_000)],
1186         });
1187         let tree = TreeNode { name: "root".into(), size: 1_020_000, is_dir: true, children };
1188         let mut state = SpaceState::new(&mut ui);
1189         state.scan_finished(PathBuf::from("/root"), tree);
1190         state.relayout((0.0, 0.0, 200.0, 200.0));
1191 
1192         let sub = state.tiles.iter().find(|t| t.name == "sub").expect("sub is big enough to show");
1193         assert_eq!(sub.aggregate, Some(Category::Audio));
1194         assert!(!state.tiles.iter().any(|t| t.name == "x.mp3"), "its children are not placed");
1195         // An opened directory, and any file, is not an aggregate.
1196         assert!(state.tiles.iter().filter(|t| t.name != "sub").all(|t| t.aggregate.is_none()));
1197     }
1198 
1199     #[test]
1200     fn a_folders_smallest_entries_become_one_block() {
1201         // Ten thousand 1-byte files beside one big one, in 200 x 200: each
1202         // alone is far under REST_AREA, together they are a tenth of the map.
1203         let mut children = vec![file("huge.bin", 90_000)];
1204         children.extend((0..10_000).map(|i| file(&format!("t{i}.png"), 1)));
1205         let tree = TreeNode { name: "root".into(), size: 100_000, is_dir: true, children };
1206         let mut tiles = Vec::new();
1207         place(&tree, Path::new("/root"), (0.0, 0.0, 200.0, 200.0), 0, &HashMap::new(), &mut tiles);
1208 
1209         assert_eq!(tiles.len(), 3, "root, huge.bin, and one lump: {:?}", tiles.iter().map(|t| &t.name).collect::<Vec<_>>());
1210         let lump = &tiles[2];
1211         assert_eq!(lump.rest, 10_000);
1212         assert_eq!(lump.name, "10,000 smaller items");
1213         assert_eq!(lump.size, 10_000);
1214         assert_eq!(lump.path, PathBuf::from("/root"), "it opens the folder it is in");
1215         assert!(lump.is_dir);
1216         assert_eq!(lump.aggregate, Some(Category::Image));
1217         // It gets the area of what it stands for, about a tenth of the map.
1218         let share = lump.rect.2 * lump.rect.3 / (198.0 * 198.0);
1219         assert!((share - 0.1).abs() < 0.02, "{share}");
1220     }
1221 
1222     #[test]
1223     fn a_single_small_entry_is_not_lumped() {
1224         let tree = TreeNode {
1225             name: "root".into(),
1226             size: 1_000_001,
1227             is_dir: true,
1228             children: vec![file("huge.bin", 1_000_000), file("tiny.txt", 1)],
1229         };
1230         let mut tiles = Vec::new();
1231         place(&tree, Path::new("/root"), (0.0, 0.0, 200.0, 200.0), 0, &HashMap::new(), &mut tiles);
1232         assert!(tiles.iter().all(|t| t.rest == 0));
1233     }
1234 
1235     #[test]
1236     fn top_folder_is_the_hovered_tiles_depth_one_ancestor() {
1237         let tree = TreeNode {
1238             name: "root".into(),
1239             size: 1000,
1240             is_dir: true,
1241             children: vec![
1242                 TreeNode {
1243                     name: "a".into(),
1244                     size: 600,
1245                     is_dir: true,
1246                     children: vec![TreeNode {
1247                         name: "deep".into(),
1248                         size: 600,
1249                         is_dir: true,
1250                         children: vec![file("x.bin", 400), file("y.bin", 200)],
1251                     }],
1252                 },
1253                 TreeNode { name: "b".into(), size: 300, is_dir: true, children: vec![file("z.bin", 300)] },
1254                 file("loose.txt", 100),
1255             ],
1256         };
1257         let mut tiles = Vec::new();
1258         place(&tree, Path::new("/root"), (0.0, 0.0, 400.0, 400.0), 0, &HashMap::new(), &mut tiles);
1259         let at = |name: &str| tiles.iter().position(|t| t.name == name).unwrap();
1260 
1261         assert_eq!(top_folder(&tiles, at("y.bin")), Some(at("a")));
1262         assert_eq!(top_folder(&tiles, at("deep")), Some(at("a")));
1263         // Laid out after all of `a`, z must not be credited to it.
1264         assert_eq!(top_folder(&tiles, at("z.bin")), Some(at("b")));
1265         // At depth 1 or above there is nothing more to frame.
1266         assert_eq!(top_folder(&tiles, at("a")), None);
1267         assert_eq!(top_folder(&tiles, at("loose.txt")), None);
1268         assert_eq!(top_folder(&tiles, 0), None);
1269     }
1270 
1271     #[test]
1272     fn neighbouring_files_are_parted_by_a_seam() {
1273         // Two tiles sharing an edge at x = 50 leave FILE_GAP between faces.
1274         let a = file_face((0.0, 0.0, 50.0, 40.0));
1275         let b = file_face((50.0, 0.0, 50.0, 40.0));
1276         assert!((b.0 - (a.0 + a.2) - FILE_GAP).abs() < 1e-4);
1277         // A sliver keeps its thin axis whole and loses the gap only along its length.
1278         assert_eq!(file_face((10.0, 10.0, 3.0, 40.0)), (10.0, 10.0 + FILE_GAP / 2.0, 3.0, 40.0 - FILE_GAP));
1279     }
1280 
1281     #[test]
1282     fn labels_take_whichever_text_colour_reads_better() {
1283         let light = cce_ui::color::TEXT_FG;
1284         let dark = cce_ui::color::parse_hex_rgba_linear("#20242b").unwrap();
1285         // Code's yellow, the worst case for light text, takes dark.
1286         assert_eq!(label_on(Category::Code.color(), light, dark), dark);
1287         // The frame itself, and a block's dimmed fill, keep light text.
1288         assert_eq!(label_on(dark, light, dark), light);
1289         assert_eq!(label_on(mix(Category::Video.color(), dark, AGGREGATE_DIM), light, dark), light);
1290     }
1291 
1292     #[test]
1293     fn share_and_digit_grouping() {
1294         assert_eq!(share(18, 100), "18%");
1295         assert_eq!(share(42, 1000), "4.2%");
1296         assert_eq!(share(1, 1_000_000), "<0.1%");
1297         assert_eq!(share(5, 0), "—");
1298         assert_eq!(group_digits(7), "7");
1299         assert_eq!(group_digits(1000), "1,000");
1300         assert_eq!(group_digits(12408), "12,408");
1301         assert_eq!(group_digits(1234567), "1,234,567");
1302     }
1303 
1304     #[test]
1305     fn elide_respects_width() {
1306         assert_eq!(elide("hi", 0.0), "");
1307         assert_eq!(elide("short", 200.0), "short");
1308         let long = elide("a-very-long-file-name.txt", 30.0);
1309         assert!(long.ends_with('…'));
1310         assert!(long.chars().count() <= 6);
1311     }
1312 }