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