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 & 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 }