diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2af62..185a31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,33 @@ Release notes for the GitHub Release: [`docs/release-notes/v0.7.0.md`](docs/rele with an error naming the folder, and can't be approved. Before, only your approval stopped it. A project's build output inside them (`node_modules`, `target/`, `.next/`, …) may still go. +- **Path bar** under the toolbar: the map's folder as clickable segments (current one bold), Back + and Up buttons, Backspace / ⌘↑ for the enclosing folder and ⌘[ for Back. It is always shown, not + only after zooming. Esc steps up one folder. +- **Pinned parent rows** in the folder list: while you scroll, the ancestors of the top row (up to + four) stay pinned. Clicking one selects it and scrolls the list to it. +- **Colour legends** for Age and Depth: a gradient bar with labelled ends and ticks (today, 1 mo, + 1 y, 3 y+; levels 1 to 5+), drawn with the same colour functions as the map, in light and dark. +- **A new app icon** (`assets/icon/icon.svg`, rendered to `AppIcon.icns` by + `scripts/make-icon.sh`), also used as the window icon. The bundle had no icon before. +- `examples/bench.rs` times a scan and the per-frame UI cost on a real folder. + +### Fixed +- **The folder list's triangle did nothing.** The row's click area was registered after the triangle + and took the click, so it selected the row and never toggled it. +- Hovering the map picks the block drawn on top, not the first one under the pointer. + ### Changed +- **Faster map and list.** The folder list is virtualised (fixed-height rows, only visible ones laid + out) and the map merges blocks smaller than a square point into one, in the colour of the largest + file in them. On `~/Library` (275,000 files; 5,200 folders open) a frame went from 46 ms to 0.8 ms + and map blocks from 219,000 to 17,000. +- The pointer is a hand over rows, triangles, map blocks, buttons, checkboxes and path segments. +- Age colouring is a log scale from today to three years (it ended at one year before), and Depth + counts levels from the folder the map shows and ends at level 5 (lighter steps than before). +- Copy: explanatory sentences removed from Cleanup ("Nothing is ticked until you tick it.", the + subtitle under the title and the column notes), History, Full Disk Access, the Help tips, the + Privacy text and tooltips. - **New look: the survey register.** Book typography on paper: Source Serif 4 (bundled, SIL Open Font License) for all text with a real italic for notes, ink on paper, hairline rules, and one oxblood accent for the main action in each window. Treemap fills are muted earth and slate tints. diff --git a/README.md b/README.md index 04dda77..6018543 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Treemap directory-size visualizer for macOS. Rust + [egui](https://github.com/em - **History, as small multiples.** The History… button shows the scanned folder over its last six scans, side by side: one small treemap per scan, blocks in the same order in every panel, each panel as tall as the space used, so you see what grew. - **Folder list with a ledger**, synced to the treemap. Each folder shows its exact size, its **item count**, and a length bar scaled to the largest row at its level, so you compare sizes by length instead of by area. A folder's own files get one row, so a level's bars add up. Hover a row and the map outlines that folder; hover the map and the row lights up. Zoom into a folder and the list starts there. Cycle the sort between **Size · Name · Items · Recent**, and navigate the whole list from the keyboard. - **File-type breakdown** showing total bytes, percentage, and **file count** per extension; sort by **Size · Count · Name**; click a row to dim non-matching files. -- **Cleanup, as an inventory.** Two columns: what a tool can rebuild (Xcode DerivedData / Archives / Simulators / DeviceSupport, `node_modules`, `target`, Application Caches, Docker / Cargo / rustup / Gradle / Maven / Next.js / Turbo, …), each with what it takes to get it back and when it last changed; and what only you have (Photos, Documents, Movies, Music, Mail), shown with sizes and never offered for deletion. Nothing is ticked for you. Tick what you trust, confirm once, and a short account says what went to the Trash, how much of it was rebuildable, and that the Trash is not emptied yet. +- **Cleanup, as an inventory.** Two columns: what a tool can rebuild (Xcode DerivedData / Archives / Simulators / DeviceSupport, `node_modules`, `target`, Application Caches, Docker / Cargo / rustup / Gradle / Maven / Next.js / Turbo, …), each with what it takes to get it back and when it last changed; and what only you have (Photos, Documents, Movies, Music, Mail), shown with sizes and never offered for deletion. Tick what you trust, confirm once, and a short account says what went to the Trash, how much of it was rebuildable, and that the Trash is not emptied yet. - **Refresh subtree**: right-click a folder (or `⇧⌘R` on a selected one) to re-scan only that subtree without losing your current view. - **Right-click menu** on every node — **Open**, Reveal in Finder, **Open Terminal Here**, **Get Info**, Copy Path, Refresh, Zoom Into, Move to Trash. - **Free & unknown space** — scanning any volume root (`/` or a mounted volume under `/Volumes`) adds `` and `` blocks so the treemap accounts for the whole disk. diff --git a/assets/icon/AppIcon.icns b/assets/icon/AppIcon.icns new file mode 100644 index 0000000..10aed65 Binary files /dev/null and b/assets/icon/AppIcon.icns differ diff --git a/assets/icon/icon-256.png b/assets/icon/icon-256.png new file mode 100644 index 0000000..656068e Binary files /dev/null and b/assets/icon/icon-256.png differ diff --git a/assets/icon/icon.svg b/assets/icon/icon.svg new file mode 100644 index 0000000..07aba12 --- /dev/null +++ b/assets/icon/icon.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/release-notes/v0.7.0.md b/docs/release-notes/v0.7.0.md index 38bb17f..a91c76d 100644 --- a/docs/release-notes/v0.7.0.md +++ b/docs/release-notes/v0.7.0.md @@ -19,7 +19,7 @@ The Cleanup window has two columns. - **Can be rebuilt.** Caches, build output, `node_modules`, simulators (their apps and data are not rebuilt) and the like, grouped by rule. Each rule says what it takes to get it back (`npm install`, the next Xcode build, the next - `cargo build`) and when anything inside last changed. Nothing is ticked for you. + `cargo build`) and when anything inside last changed. - **Only you have these.** Your Photos library, Documents, Desktop, Pictures, Movies, Music, Mail and Messages, with their sizes. They have no tick boxes. MacDirStat never offers them for deletion; they are there so you know where the space is. @@ -46,6 +46,26 @@ no file names or lists, and it never sends anything anywhere. The summaries live in `~/Library/Application Support/mac-dir-stat/history.json`; delete that file to forget them. +## Getting around + +- **Path bar.** Under the toolbar: the folder the map shows, as clickable segments (the current one + in bold), with Back and Up buttons. Backspace or ⌘↑ goes up a folder; ⌘[ goes back to where you were. +- **Pinned parents.** Scroll deep into the folder list and the folders above stay pinned at the top + (up to four). Click one to jump to it; its triangle closes it. +- **The triangle works.** Clicking ▶ in the folder list opens a folder and ▼ closes it. Before, the + click selected the row instead. +- **The pointer changes** to a hand over anything you can click, and to a resize arrow on the + dividers between the three panels. +- **Colour legends.** With Age or Depth selected, a bar at the right of the path bar shows what the + colours mean: new to old (today, 1 mo, 1 y, 3 y or older) or shallow to deep (levels 1 to 5+, + counted from the folder shown). Age now runs on a three-year log scale instead of saturating at one + year. +- **Faster.** The folder list draws only the rows in view, and the map merges blocks smaller than a + pixel. On `~/Library` (275,000 files, 5,200 folders open) a frame went from 46 ms to 0.8 ms. +- **Fewer words.** Explanatory sentences are gone from the Cleanup, History and Full Disk Access + windows and the tooltips. +- **A new icon.** A treemap of nested blocks on paper, as a macOS icon. + ## A new look Serif type (Source Serif 4, bundled), ink on paper, thin rules, and one dark-red accent for the diff --git a/examples/bench.rs b/examples/bench.rs new file mode 100644 index 0000000..80bb8a9 --- /dev/null +++ b/examples/bench.rs @@ -0,0 +1,89 @@ +//! Measures scan time and per-frame UI cost on a real folder. +//! cargo run --release --example bench -- ~/Library +use egui::{Pos2, ThemePreference}; +use mac_dir_stat::app::{App, AppState}; +use mac_dir_stat::scanner::{tree::{NodeId, NodeKind}, ScanProgress}; +use mac_dir_stat::treemap::color::ColorMode; +use std::path::PathBuf; +use std::time::Instant; + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).expect("path")); + let (tx, rx) = crossbeam_channel::unbounded(); + let t0 = Instant::now(); + mac_dir_stat::scanner::scan(root.clone(), tx); + let tree = loop { + match rx.recv().unwrap() { + ScanProgress::Done(t) => break t, + ScanProgress::Error(e) => panic!("{e}"), + _ => {} + } + }; + let scan = t0.elapsed(); + let r = tree.root(); + println!("scan: {:.2}s files={} dirs={}", scan.as_secs_f64(), tree.node(r).file_count, tree.node(r).subdir_count); + + let mut s = AppState::new(root, true, ColorMode::Extension, mac_dir_stat::flywheel::disabled().clone()); + s.request_rescan = false; + s.extension_stats = tree.collect_extensions(r); + s.view_root = Some(r); + s.zoom_stack = vec![r]; + // expand every directory: worst case for the list + s.tree = Some(tree); + let n_expand = expand_all(&mut s, 3); + println!("expanded dirs to depth 3: {n_expand}"); + { + let t = s.tree.as_ref().unwrap(); + let r = t.root(); + let t0 = Instant::now(); + let rows = mac_dir_stat::ui::dir_tree::flatten(t, r, s.dir_sort); + println!("flatten: {:.2}ms ({} rows)", t0.elapsed().as_secs_f64() * 1000.0, rows.len()); + let b = mac_dir_stat::treemap::squarify::Rect { x: 0.0, y: 0.0, w: 700.0, h: 700.0 }; + let t0 = Instant::now(); + let l = mac_dir_stat::treemap::compute_treemap(t, r, b, ColorMode::Extension); + println!("treemap layout: {:.2}ms ({} blocks)", t0.elapsed().as_secs_f64() * 1000.0, l.file_rects.len()); + } + let mut app = App::from_state(s); + let ctx = egui::Context::default(); + ctx.set_theme(ThemePreference::Light); + mac_dir_stat::ui::theme::apply_theme(&ctx); + let _ = ctx.run(Default::default(), |_| {}); + let mut frame = eframe::Frame::_new_kittest(); + let mut run = |label: &str, n: usize, ptr: Option, app: &mut App| { + let mut times = vec![]; + for i in 0..n { + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1280.0, 800.0))), + time: Some(i as f64 * 0.016), + ..Default::default() + }; + if let Some(p) = ptr { + input.events.push(egui::Event::PointerMoved(Pos2::new(p.x + (i % 7) as f32, p.y))); + } + let t = Instant::now(); + let out = ctx.run(input, |c| eframe::App::update(app, c, &mut frame)); + let _ = ctx.tessellate(out.shapes, out.pixels_per_point); + times.push(t.elapsed().as_secs_f64() * 1000.0); + } + let first = times[0]; + let mut rest: Vec = times[1..].to_vec(); + rest.sort_by(|a, b| a.partial_cmp(b).unwrap()); + println!("{label}: first {first:.1}ms median {:.2}ms p95 {:.2}ms", rest[rest.len() / 2], rest[rest.len() * 95 / 100]); + }; + run("idle frame", 60, None, &mut app); + run("hover list", 60, Some(Pos2::new(120.0, 200.0)), &mut app); + run("hover map", 60, Some(Pos2::new(700.0, 400.0)), &mut app); +} + +fn expand_all(s: &mut AppState, max_depth: u16) -> usize { + let t = s.tree.as_mut().unwrap(); + let mut n = 0; + let mut stack = vec![t.root()]; + while let Some(id) = stack.pop() { + if t.node(id).depth > max_depth { continue; } + let kids: Vec = t.node(id).children().to_vec(); + if let NodeKind::Directory { expanded, .. } = &mut t.node_mut(id).kind { *expanded = true; n += 1; } + stack.extend(kids.into_iter().filter(|&k| t.node(k).is_dir())); + } + n +} diff --git a/examples/screenshots.rs b/examples/screenshots.rs index 281ebee..a9a2633 100644 --- a/examples/screenshots.rs +++ b/examples/screenshots.rs @@ -42,12 +42,23 @@ fn shots() -> Vec { Shot { name: "ledger", dark: false, setup: ledger, pointer: Some(Pos2::new(120.0, 120.0)) }, // Pointer on the map, over a Docker block: the list marks Containers. Shot { name: "ledger-dark", dark: true, setup: ledger, pointer: Some(Pos2::new(780.0, 300.0)) }, + // Scrolled deep into the list: the ancestors stay pinned at the top. + Shot { name: "sticky-light", dark: false, setup: sticky, pointer: None }, + Shot { name: "sticky-dark", dark: true, setup: sticky, pointer: None }, + // Colour by age and by depth, with their legends; the map zoomed so + // the path bar shows a path. + Shot { name: "age-light", dark: false, setup: age, pointer: None }, + Shot { name: "age-dark", dark: true, setup: age, pointer: None }, + Shot { name: "depth-light", dark: false, setup: depth, pointer: None }, + Shot { name: "depth-dark", dark: true, setup: depth, pointer: None }, ] } fn main() { let only: Vec = std::env::args().skip(1).collect(); - let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots"); + let out = std::env::var_os("SHOTS_OUT") + .map(PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots")); std::fs::create_dir_all(&out).unwrap(); for shot in shots() { if !only.is_empty() && !only.iter().any(|o| o == shot.name) { @@ -182,6 +193,44 @@ fn inventory(s: &mut AppState) { s.cleanup_selected.extend(ids); } +fn expand_all(s: &mut AppState) { + let ids: Vec = { + let t = s.tree.as_ref().unwrap(); + let mut out = vec![]; + let mut stack = vec![t.root()]; + while let Some(id) = stack.pop() { + if t.node(id).is_dir() { + out.push(id); + stack.extend(t.node(id).children().iter().copied()); + } + } + out + }; + for id in ids { + expand(s, id); + } +} + +fn sticky(s: &mut AppState) { + expand_all(s); + let t = s.tree.as_ref().unwrap(); + let devices = t.find_path(Path::new("/Users/pavol/Library/Developer/CoreSimulator/Devices")).unwrap(); + s.scroll_dir_tree_top = Some(devices); +} + +fn age(s: &mut AppState) { + expand_all(s); + s.color_mode = ColorMode::Age; + let x = s.tree.as_ref().unwrap().find_path(Path::new("/Users/pavol/Library/Developer")).unwrap(); + mac_dir_stat::nav::navigate_to(s, x); +} + +fn depth(s: &mut AppState) { + s.color_mode = ColorMode::Depth; + let x = s.tree.as_ref().unwrap().find_path(Path::new("/Users/pavol/Library")).unwrap(); + mac_dir_stat::nav::navigate_to(s, x); +} + fn ledger(s: &mut AppState) { // Expand Library so the list shows a second level with its loose files. let lib = s.tree.as_ref().and_then(|t| t.find_path(Path::new("/Users/pavol/Library"))); @@ -362,7 +411,7 @@ fn write_png(img: &[[f32; 4]], w: usize, h: usize, path: &Path) { } } let tmp = path.with_extension("ppm"); - std::fs::write(&tmp, ppm).unwrap(); + std::fs::write(&tmp, &ppm).unwrap(); // Downscale to 1x points so the PNGs stay small in the repo. let ok = std::process::Command::new("convert") .arg(&tmp) @@ -372,5 +421,66 @@ fn write_png(img: &[[f32; 4]], w: usize, h: usize, path: &Path) { .map(|s| s.success()) .unwrap_or(false); let _ = std::fs::remove_file(&tmp); - assert!(ok, "ImageMagick `convert` is needed to write PNGs"); + if !ok { + // No ImageMagick: write an uncompressed PNG, then let macOS `sips` + // downscale and compress it. + std::fs::write(path, raw_png(&ppm[ppm.len() - w * h * 3..], w, h)).unwrap(); + let ok = std::process::Command::new("sips") + .args(["-Z", &((w / 2).to_string())]) + .arg(path) + .stdout(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "needs ImageMagick `convert` or macOS `sips` to write PNGs"); + } +} + +/// A valid PNG using stored (uncompressed) deflate blocks. +fn raw_png(rgb: &[u8], w: usize, h: usize) -> Vec { + fn crc(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 }; + } + } + !c + } + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend((data.len() as u32).to_be_bytes()); + let mut body = kind.to_vec(); + body.extend(data); + out.extend(&body); + out.extend(crc(&body).to_be_bytes()); + } + let mut raw = Vec::with_capacity((w * 3 + 1) * h); + for row in rgb.chunks(w * 3) { + raw.push(0); + raw.extend(row); + } + let mut z = vec![0x78, 0x01]; + let mut chunks = raw.chunks(65_535).peekable(); + while let Some(c) = chunks.next() { + z.push(chunks.peek().is_none() as u8); + z.extend((c.len() as u16).to_le_bytes()); + z.extend((!(c.len() as u16)).to_le_bytes()); + z.extend(c); + } + let (mut a, mut b) = (1u32, 0u32); + for &x in &raw { + a = (a + x as u32) % 65_521; + b = (b + a) % 65_521; + } + z.extend(((b << 16) | a).to_be_bytes()); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend((w as u32).to_be_bytes()); + ihdr.extend((h as u32).to_be_bytes()); + ihdr.extend([8, 2, 0, 0, 0]); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &z); + chunk(&mut out, b"IEND", &[]); + out } diff --git a/macos/Info.plist.in b/macos/Info.plist.in index 6688191..ca77f1a 100644 --- a/macos/Info.plist.in +++ b/macos/Info.plist.in @@ -7,6 +7,7 @@ CFBundleIdentifiersk.dravecky.mac-dir-stat CFBundleVersion@VERSION@ CFBundleShortVersionString@VERSION@ + CFBundleIconFileAppIcon CFBundleExecutablemac-dir-stat CFBundlePackageTypeAPPL CFBundleInfoDictionaryVersion6.0 diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 5e13ca8..8302bea 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -21,6 +21,7 @@ lipo -create -output "$APP/Contents/MacOS/$BIN_NAME" \ "$ROOT/target/x86_64-apple-darwin/release/$BIN_NAME" \ "$ROOT/target/aarch64-apple-darwin/release/$BIN_NAME" +cp "$ROOT/assets/icon/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" sed "s/@VERSION@/$VERSION/g" "$ROOT/macos/Info.plist.in" > "$APP/Contents/Info.plist" # Code-sign with a Developer ID + hardened runtime when an identity is provided diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..7ee1498 --- /dev/null +++ b/scripts/make-icon.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Renders assets/icon/icon.svg to assets/icon/AppIcon.icns and the 256px window icon. +# macOS only: qlmanage draws the SVG, sips resizes, iconutil builds the .icns. +# The outputs are committed; run this only after editing icon.svg. +set -euo pipefail + +DIR="$(cd "$(dirname "$0")/../assets/icon" && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +qlmanage -t -s 1024 -o "$WORK" "$DIR/icon.svg" >/dev/null 2>&1 +BASE="$WORK/icon.svg.png" +[ -f "$BASE" ] || { echo "qlmanage did not render icon.svg" >&2; exit 1; } + +SET="$WORK/AppIcon.iconset" +mkdir "$SET" +for s in 16 32 128 256 512; do + sips -z $s $s "$BASE" --out "$SET/icon_${s}x${s}.png" >/dev/null + sips -z $((s * 2)) $((s * 2)) "$BASE" --out "$SET/icon_${s}x${s}@2x.png" >/dev/null +done +iconutil -c icns "$SET" -o "$DIR/AppIcon.icns" +sips -z 256 256 "$BASE" --out "$DIR/icon-256.png" >/dev/null +echo "Wrote $DIR/AppIcon.icns and $DIR/icon-256.png" diff --git a/site/favicon.svg b/site/favicon.svg index 9c531aa..e6a4906 100644 --- a/site/favicon.svg +++ b/site/favicon.svg @@ -1,8 +1,8 @@ - - - - - - + + + + + + diff --git a/src/app.rs b/src/app.rs index 1644a4f..5e0401a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,10 @@ pub struct AppState { pub color_mode: ColorMode, pub view_root: Option, pub zoom_stack: Vec, + /// Folders shown before the last jumps; Back (Cmd+[) pops. + pub nav_back: Vec, + /// Arrow hit boxes of the rows drawn last frame; the tree test clicks them. + pub ledger_arrows: Vec<(NodeId, egui::Rect)>, // Selection pub selected_node: Option, @@ -47,6 +51,8 @@ pub struct AppState { // Reveal-in-tree request — set when user clicks a treemap rect, consumed // by dir_tree which scrolls and clears. pub scroll_dir_tree_to: Option, + /// Same, but the folder goes to the top of the list (a pinned row was clicked). + pub scroll_dir_tree_top: Option, // Captured at right-click time so the context menu always operates on the // node that was under the pointer when opened, not whatever happens to be @@ -268,6 +274,7 @@ impl App { self.state.hovered_dir = None; self.state.view_root = None; self.state.zoom_stack.clear(); + self.state.nav_back.clear(); // dir_tree indexes the tree with it; an old-tree id may be out of range. self.state.scroll_dir_tree_to = None; // The Cleanup window stays open during a scan; its NodeIds belong to @@ -392,6 +399,7 @@ impl App { ); self.state.view_root = Some(root); self.state.zoom_stack = vec![root]; + self.state.nav_back.clear(); self.state.tree = Some(tree); self.state.scan_progress.scanning = false; self.state.treemap_dirty = true; @@ -684,12 +692,15 @@ impl AppState { color_mode, view_root: None, zoom_stack: vec![], + nav_back: vec![], + ledger_arrows: vec![], selected_node: None, hovered_node: None, hovered_dir: None, ledger_hover: None, selected_extension: None, scroll_dir_tree_to: None, + scroll_dir_tree_top: None, context_menu_target: None, cleanup_window_open: false, cleanup_candidates: Vec::new(), @@ -938,6 +949,21 @@ impl eframe::App for App { } } } + // Up the hierarchy: Backspace or Cmd+Up; Cmd+[ goes back. + if !ctx.wants_keyboard_input() && self.state.pending_action.is_none() { + let (up, back) = ctx.input(|i| { + ( + (i.key_pressed(egui::Key::Backspace) && i.modifiers.is_none()) + || (i.key_pressed(egui::Key::ArrowUp) && i.modifiers.command), + i.key_pressed(egui::Key::OpenBracket) && i.modifiers.command, + ) + }); + if up { + crate::nav::go_up(&mut self.state); + } else if back { + crate::nav::go_back(&mut self.state); + } + } if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { if self.state.help_window_open { self.state.help_window_open = false; @@ -948,10 +974,7 @@ impl eframe::App for App { } else if self.state.search_active { self.state.search_active = false; self.state.search_query.clear(); - } else if self.state.zoom_stack.len() > 1 { - self.state.zoom_stack.pop(); - self.state.view_root = self.state.zoom_stack.last().copied(); - self.state.treemap_dirty = true; + } else if crate::nav::go_up(&mut self.state) { } else { self.state.selected_node = None; self.state.selected_extension = None; @@ -965,8 +988,8 @@ impl eframe::App for App { // Arrow-key navigation of the directory list (WinDirStat parity). // Gated so it never steals keys from the search field or other input. if !ctx.wants_keyboard_input() && self.state.tree.is_some() && !self.state.search_active { - let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp)); - let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown)); + let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp) && !i.modifiers.command); + let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown) && !i.modifiers.command); let left = ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)); let right = ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)); if up || down { @@ -993,6 +1016,19 @@ impl eframe::App for App { ui::toolbar::show(ui, &mut self.state); }); + // Path bar: where you are, how to go up, what the colours mean + if self.state.tree.is_some() { + egui::TopBottomPanel::top("path_bar") + .frame( + egui::Frame::new() + .fill(ui::theme::bg_panel()) + .inner_margin(egui::Margin::symmetric(14, 4)), + ) + .show(ctx, |ui| { + ui::pathbar::show(ui, &mut self.state); + }); + } + // Bottom status bar egui::TopBottomPanel::bottom("status_bar") .frame( diff --git a/src/lib.rs b/src/lib.rs index 7979476..8dd8c0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod cleanup; pub mod flywheel; pub mod history; +pub mod nav; pub mod plan; pub mod platform; pub mod relocate; diff --git a/src/main.rs b/src/main.rs index bf7cf64..5dbd706 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,11 @@ fn main() -> eframe::Result<()> { viewport: egui::ViewportBuilder::default() .with_inner_size([1280.0, 800.0]) .with_min_inner_size([800.0, 600.0]) - .with_title("MacDirStat"), + .with_title("MacDirStat") + .with_icon( + eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon/icon-256.png")[..]) + .expect("window icon"), + ), persist_window: true, ..Default::default() }; diff --git a/src/nav.rs b/src/nav.rs new file mode 100644 index 0000000..b2c191c --- /dev/null +++ b/src/nav.rs @@ -0,0 +1,63 @@ +//! Moving the map's root up and down the hierarchy: breadcrumb, Up, Back. + +use crate::app::AppState; +use crate::scanner::tree::{FileTree, NodeId}; + +/// Scan root down to `node`, inclusive. +pub fn chain(tree: &FileTree, node: NodeId) -> Vec { + let mut c: Vec = std::iter::successors(Some(node), |&n| tree.node(n).parent).collect(); + c.reverse(); + c +} + +fn live_dir(state: &AppState, id: NodeId) -> bool { + state.tree.as_ref().is_some_and(|t| t.is_alive(id) && t.node(id).is_dir()) +} + +/// Show `node` (a folder) as the map's root. The folder left is remembered +/// for Back. `zoom_stack` mirrors the breadcrumb so Esc steps up one level. +pub fn navigate_to(state: &mut AppState, node: NodeId) { + if !live_dir(state, node) || state.view_root == Some(node) { + return; + } + if let Some(prev) = state.view_root { + state.nav_back.push(prev); + } + set_root(state, node); +} + +fn set_root(state: &mut AppState, node: NodeId) { + let Some(tree) = &state.tree else { return }; + state.zoom_stack = chain(tree, node); + state.view_root = Some(node); + state.treemap_dirty = true; +} + +/// The folder above the current root, if any. +pub fn parent_of_root(state: &AppState) -> Option { + let tree = state.tree.as_ref()?; + let vr = state.view_root?; + tree.node(vr).parent.filter(|&p| tree.is_alive(p)) +} + +pub fn go_up(state: &mut AppState) -> bool { + match parent_of_root(state) { + Some(p) => { + navigate_to(state, p); + true + } + None => false, + } +} + +/// Return to the folder shown before the last jump; skips folders that no +/// longer exist. +pub fn go_back(state: &mut AppState) -> bool { + while let Some(prev) = state.nav_back.pop() { + if live_dir(state, prev) { + set_root(state, prev); + return true; + } + } + false +} diff --git a/src/treemap/color.rs b/src/treemap/color.rs index 4326cb5..030ad19 100644 --- a/src/treemap/color.rs +++ b/src/treemap/color.rs @@ -50,8 +50,12 @@ pub fn extension_color(ext: &str) -> GradientPair { } pub fn depth_color(hue_index: usize, depth: u16) -> GradientPair { + depth_color_f(hue_index, depth as f32) +} + +fn depth_color_f(hue_index: usize, depth: f32) -> GradientPair { let base = &PALETTE[hue_index % PALETTE.len()]; - let factor = 1.0 - (depth as f32 * 0.12).min(0.6); + let factor = 1.0 - (depth * 0.1).min(0.5); let lighten = |c: Color| -> Color { [ (c[0] as f32 * factor) as u8, @@ -63,14 +67,22 @@ pub fn depth_color(hue_index: usize, depth: u16) -> GradientPair { (lighten(base.0), lighten(base.1)) } -pub fn age_color(modified: SystemTime) -> GradientPair { - let age_secs = SystemTime::now() - .duration_since(modified) - .unwrap_or_default() - .as_secs(); - let one_year = 365 * 24 * 3600; - let t = (age_secs as f64 / one_year as f64).min(1.0); - // Recent: madder and rust; a year or older: slate. +/// Age at which the age scale ends: this old or older is the cool end. +pub const AGE_SPAN_DAYS: u64 = 3 * 365; +/// Labelled points on the age legend, in days. +pub const AGE_TICKS: [(&str, u64); 4] = [("today", 0), ("1 mo", 30), ("1 y", 365), ("3 y+", AGE_SPAN_DAYS)]; +/// Depth at which the depth scale stops getting darker. +pub const DEPTH_SPAN: u16 = 5; + +/// Position on the age scale, 0 (today) to 1 (three years or older). +/// Logarithmic: last week and last year both get room. +pub fn age_t(age_days: u64) -> f64 { + (((1 + age_days) as f64).ln() / ((1 + AGE_SPAN_DAYS) as f64).ln()).min(1.0) +} + +/// Colour at position `t` of the age scale: madder and rust when new, slate +/// when old. +pub fn age_color_at(t: f64) -> GradientPair { let warm_start: [f64; 3] = [176.0, 104.0, 80.0]; let warm_end: [f64; 3] = [208.0, 146.0, 120.0]; let cool_start: [f64; 3] = [116.0, 130.0, 150.0]; @@ -91,6 +103,27 @@ pub fn age_color(modified: SystemTime) -> GradientPair { (start, end) } +pub fn age_color(modified: SystemTime) -> GradientPair { + let age_secs = SystemTime::now() + .duration_since(modified) + .unwrap_or_default() + .as_secs(); + age_color_at(age_t(age_secs / 86_400)) +} + +/// The colour a legend bar shows at position `t` (0..1) of the active mode: +/// the middle of the block gradient, from the same functions the map uses. +/// Depth runs from level 1 to `DEPTH_SPAN`, age from today to `AGE_SPAN_DAYS`. +pub fn legend_color(mode: ColorMode, t: f32) -> Option { + let pair = match mode { + ColorMode::Extension => return None, + ColorMode::Depth => depth_color_f(0, 1.0 + t * (DEPTH_SPAN - 1) as f32), + ColorMode::Age => age_color_at(t as f64), + }; + let mid = |a: u8, b: u8| ((a as u16 + b as u16) / 2) as u8; + Some([mid(pair.0[0], pair.1[0]), mid(pair.0[1], pair.1[1]), mid(pair.0[2], pair.1[2]), 255]) +} + fn simple_hash(s: &str) -> usize { let mut h: usize = 5381; for b in s.bytes() { diff --git a/src/treemap/mod.rs b/src/treemap/mod.rs index d752416..33a8c44 100644 --- a/src/treemap/mod.rs +++ b/src/treemap/mod.rs @@ -35,13 +35,52 @@ pub fn compute_treemap( ) -> TreemapLayout { let mut file_rects = Vec::new(); let mut dir_rects = Vec::new(); - layout_recursive(tree, view_root, &bounds, color_mode, 0, &mut file_rects, &mut dir_rects); + let base = tree.node(view_root).depth; + layout_recursive(tree, view_root, base, &bounds, color_mode, 0, &mut file_rects, &mut dir_rects); TreemapLayout { file_rects, dir_rects } } +/// Directories smaller than this many square points are drawn as one block. +pub const TINY_AREA: f64 = 4.0; + +/// `base` is the depth of the folder the map shows: levels count from it. +fn block_color(tree: &FileTree, id: NodeId, mode: ColorMode, hue_index: usize, base: u16) -> color::GradientPair { + let node = tree.node(id); + match mode { + ColorMode::Extension => extension_color(tree.extension(id).unwrap_or("")), + ColorMode::Depth => depth_color(hue_index, node.depth.saturating_sub(base)), + ColorMode::Age => age_color(node.modified), + } +} + +/// The largest file under `id` (found by following the largest child), or +/// `id` itself when it holds none. +fn biggest_leaf(tree: &FileTree, id: NodeId) -> NodeId { + let mut leaf = id; + while let Some(&big) = tree + .node(leaf) + .children() + .iter() + .filter(|&&c| tree.is_alive(c) && tree.node(c).size > 0) + .max_by_key(|&&c| tree.node(c).size) + { + leaf = big; + if !tree.node(leaf).is_dir() { + break; + } + } + leaf +} + +/// Blocks below this many square points are merged into one. +pub const TINY_BLOCK: f64 = 1.0; +/// Fewer sub-pixel blocks than this are drawn as they are. +const MIN_MERGE: usize = 8; + fn layout_recursive( tree: &FileTree, node_id: NodeId, + base: u16, bounds: &Rect, color_mode: ColorMode, hue_index: usize, @@ -58,13 +97,7 @@ fn layout_recursive( match &node.kind { NodeKind::File { .. } => { - let (cs, ce) = match color_mode { - ColorMode::Extension => { - extension_color(tree.extension(node_id).unwrap_or("")) - } - ColorMode::Depth => depth_color(hue_index, node.depth), - ColorMode::Age => age_color(node.modified), - }; + let (cs, ce) = block_color(tree, node_id, color_mode, hue_index, base); file_rects.push(ColoredRect { node_id, rect: *bounds, @@ -80,6 +113,17 @@ fn layout_recursive( depth: node.depth, }); + // Too small to show its contents: one block in the colour of its + // largest file. A disk has hundreds of thousands of files and a + // screen a few hundred thousand pixels; sub-pixel blocks only + // cost layout, mesh and hover time. + if bounds.w * bounds.h < TINY_AREA { + let leaf = biggest_leaf(tree, node_id); + let (cs, ce) = block_color(tree, leaf, color_mode, hue_index, base); + file_rects.push(ColoredRect { node_id: leaf, rect: *bounds, color_start: cs, color_end: ce }); + return; + } + let mut kids: Vec = children .iter() .copied() @@ -102,7 +146,33 @@ fn layout_recursive( let depth = node.depth; let gap = if depth <= 1 { 1.5 } else if depth <= 3 { 0.5 } else { 0.0 }; + // Sub-pixel blocks (a folder with thousands of small files) are + // merged into one block under the rest, in the colour of the + // largest of them. + let tiny = |r: &Rect| r.w * r.h < TINY_BLOCK; + let n_tiny = placed.iter().filter(|p| tiny(&p.rect)).count(); + let merge = n_tiny >= MIN_MERGE; + if merge { + let tail: Vec<_> = placed.iter().filter(|p| tiny(&p.rect)).collect(); + let x0 = tail.iter().map(|p| p.rect.x).fold(f64::MAX, f64::min); + let y0 = tail.iter().map(|p| p.rect.y).fold(f64::MAX, f64::min); + let x1 = tail.iter().map(|p| p.rect.x + p.rect.w).fold(f64::MIN, f64::max); + let y1 = tail.iter().map(|p| p.rect.y + p.rect.h).fold(f64::MIN, f64::max); + let big = tail.iter().map(|p| p.id).min().unwrap(); + let leaf = biggest_leaf(tree, kids[big]); + let (cs, ce) = block_color(tree, leaf, color_mode, hue_index, base); + file_rects.push(ColoredRect { + node_id: leaf, + rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }, + color_start: cs, + color_end: ce, + }); + } + for p in &placed { + if merge && tiny(&p.rect) { + continue; + } let child_id = kids[p.id]; let mut child_bounds = p.rect; if gap > 0.0 { @@ -114,7 +184,7 @@ fn layout_recursive( if child_bounds.w <= 0.0 || child_bounds.h <= 0.0 { continue; } - let child_hue = if tree.node(child_id).depth <= 1 { + let child_hue = if tree.node(child_id).depth <= base + 1 { p.id } else { hue_index @@ -122,6 +192,7 @@ fn layout_recursive( layout_recursive( tree, child_id, + base, &child_bounds, color_mode, child_hue, diff --git a/src/ui/cleanup_window.rs b/src/ui/cleanup_window.rs index b89732e..897b682 100644 --- a/src/ui/cleanup_window.rs +++ b/src/ui/cleanup_window.rs @@ -29,15 +29,6 @@ pub fn show(ctx: &Context, state: &mut AppState) { .color(theme::ink()) .size(18.0), ); - ui.label( - RichText::new( - "Nothing is deleted until you tick items and choose Move to Trash. \ - If you are unsure about one, Reveal shows it in Finder.", - ) - .family(theme::italic()) - .color(theme::note()) - .size(11.5), - ); ui.add_space(4.0); widgets::ink_rule(ui); ui.add_space(6.0); @@ -122,11 +113,11 @@ fn rebuildable_column(ui: &mut Ui, state: &mut AppState) { column_head( ui, "Can be rebuilt", - "Tools will make these again. Removing them costs time, not data.", + "Tools make these again.", ); if state.cleanup_candidates.is_empty() { ui.label( - RichText::new("Nothing a tool can rebuild was found in this scan.") + RichText::new("Nothing found.") .color(theme::note()) .size(12.0), ); @@ -209,6 +200,7 @@ fn item_row(ui: &mut Ui, state: &mut AppState, c: &CleanupCandidate) { if ui .checkbox(&mut on, RichText::new(path).color(theme::ink_soft()).size(11.0)) .on_hover_text(c.path.display().to_string()) + .on_hover_cursor(egui::CursorIcon::PointingHand) .changed() { set_selected(state, &[c.node_id], on); @@ -233,13 +225,12 @@ fn only_yours_column(ui: &mut Ui, state: &mut AppState) { column_head( ui, "Only you have these", - "Never suggested for removal. Shown so you know where the space is.", + "Never offered for removal.", ); if state.only_yours.is_empty() { ui.label( RichText::new( - "None of your usual folders (Documents, Desktop, Pictures, Movies, Music, \ - Mail, Messages) is in this scan.", + "None in this scan.", ) .color(theme::note()) .size(12.0), @@ -286,7 +277,7 @@ fn action_bar(ui: &mut Ui, state: &mut AppState) { let items: Vec<_> = picked.iter().map(|c| (c.node_id, c.path.clone())).collect(); ui.label( RichText::new(if items.is_empty() { - "Nothing is ticked until you tick it.".to_string() + String::new() } else { format!( "{} ticked, {}.", @@ -354,8 +345,7 @@ fn closing_account(ui: &mut Ui, state: &mut AppState, a: &Account) { ui.add_space(4.0); ui.label( RichText::new( - "The Trash is not emptied. Until you empty it, everything above can be taken \ - back out, and the space is not free yet.", + "Trash not emptied. The space frees when you empty it.", ) .family(theme::italic()) .color(theme::note()) diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs index dec2628..fccaeb2 100644 --- a/src/ui/context_menu.rs +++ b/src/ui/context_menu.rs @@ -78,9 +78,7 @@ pub fn show(ui: &mut egui::Ui, state: &mut AppState, node: NodeId) { ui.close_menu(); } if ui.button("Zoom Into").clicked() { - state.view_root = Some(node); - state.zoom_stack.push(node); - state.treemap_dirty = true; + crate::nav::navigate_to(state, node); ui.close_menu(); } } diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index 86f9425..d0f0bff 100644 --- a/src/ui/dir_tree.rs +++ b/src/ui/dir_tree.rs @@ -1,7 +1,7 @@ use crate::app::AppState; use crate::scanner::tree::{FileTree, NodeId, NodeKind}; use crate::ui::{theme, widgets}; -use egui::{Ui, Vec2}; +use egui::{CursorIcon, Pos2, Rect, Sense, Ui, Vec2}; /// Sort a directory's live children by the active sort mode. fn sorted_children(tree: &FileTree, parent: NodeId, sort: crate::app::DirSortMode) -> Vec { @@ -114,7 +114,134 @@ fn map_hover_chain(state: &AppState) -> Vec { chain } +/// Height of one ledger row, points. Fixed, so only visible rows are laid out. +pub const ROW_H: f32 = 20.0; +/// Most ancestor rows pinned at the top while scrolling. +pub const MAX_STICKY: usize = 4; +/// Width of the ledger's length bar at full scale, in points. +const BAR_W: f32 = 64.0; +const INDENT: f32 = 10.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RowKind { + Dir, + /// The folder's own files as one row; `id` is the folder. + Loose { bytes: u64, n: usize }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LedgerRow { + pub id: NodeId, + /// Levels below the list's top row. + pub depth: u16, + /// Size of the largest row at this level, for the length bar. + pub scale: u64, + pub kind: RowKind, + /// A folder with something inside: gets a triangle. + pub has_kids: bool, +} + +/// Every row the list shows, top to bottom. Only rows in view are drawn. +pub fn flatten(tree: &FileTree, root: NodeId, sort: crate::app::DirSortMode) -> Vec { + fn rec( + tree: &FileTree, + id: NodeId, + depth: u16, + scale: u64, + sort: crate::app::DirSortMode, + out: &mut Vec, + ) { + if !tree.is_alive(id) || !tree.node(id).is_dir() { + return; + } + let node = tree.node(id); + out.push(LedgerRow { + id, + depth, + scale, + kind: RowKind::Dir, + has_kids: node.children().iter().any(|&c| tree.is_alive(c)), + }); + let expanded = depth == 0 + || matches!(&node.kind, NodeKind::Directory { expanded: true, .. }); + if !expanded { + return; + } + let loose = loose_files(tree, id); + let child_scale = level_max(tree, id, loose.0); + for c in sorted_children(tree, id, sort) { + rec(tree, c, depth + 1, child_scale, sort, out); + } + if loose.1 > 0 { + out.push(LedgerRow { + id, + depth: depth + 1, + scale: child_scale, + kind: RowKind::Loose { bytes: loose.0, n: loose.1 }, + has_kids: false, + }); + } + } + let mut out = Vec::new(); + rec(tree, root, 0, tree.node(root).size, sort, &mut out); + out +} + +/// Flip a folder open or shut. Returns the new state, `None` for a file. +pub fn toggle_expanded(tree: &mut FileTree, id: NodeId) -> Option { + match &mut tree.node_mut(id).kind { + NodeKind::Directory { expanded, .. } => { + *expanded = !*expanded; + Some(*expanded) + } + NodeKind::File { .. } => None, + } +} + +/// Row indices of the ancestors of `rows[i]`, outermost first. +fn ancestors(rows: &[LedgerRow], i: usize) -> Vec { + let mut out = Vec::new(); + let mut d = rows[i].depth; + for j in (0..i).rev() { + if d == 0 { + break; + } + if rows[j].depth < d && rows[j].kind == RowKind::Dir { + out.push(j); + d = rows[j].depth; + } + } + out.reverse(); + out +} + +/// The ancestor rows to pin above the viewport when row `first` is its top +/// edge. The pinned rows hide the next ones, so the chain is taken for the +/// first row they leave visible. +pub fn sticky_chain(rows: &[LedgerRow], first: usize) -> Vec { + if rows.is_empty() { + return vec![]; + } + let last = rows.len() - 1; + for n in 0..=MAX_STICKY { + let c = ancestors(rows, (first + n).min(last)); + if c.len() == n { + return c; + } + } + let mut c = ancestors(rows, (first + MAX_STICKY).min(last)); + c.drain(..c.len() - MAX_STICKY.min(c.len())); + c +} + +/// Where the triangle of a row sits. +pub fn arrow_rect(row: Rect, depth: u16) -> Rect { + let x = row.left() + 3.0 + depth as f32 * INDENT; + Rect::from_min_size(Pos2::new(x - 2.0, row.top()), Vec2::new(16.0, row.height())) +} + pub fn show(ui: &mut Ui, state: &mut AppState) { + state.ledger_arrows.clear(); ui.vertical(|ui| { ui.horizontal(|ui| { widgets::section_header(ui, "Folders"); @@ -123,6 +250,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if ui .small_button(egui::RichText::new(label).size(10.0)) .on_hover_text("Cycle sort order (Size · Name · Items · Recent)") + .on_hover_cursor(CursorIcon::PointingHand) .clicked() { state.dir_sort = state.dir_sort.next(); @@ -147,238 +275,251 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let inside = tree.is_alive(target) && std::iter::successors(Some(target), |&n| tree.node(n).parent).any(|n| n == lr); if !inside && lr != tree.root() { - state.view_root = Some(tree.root()); - state.zoom_stack = vec![tree.root()]; - state.treemap_dirty = true; + let r = tree.root(); + crate::nav::navigate_to(state, r); } } let tree = state.tree.as_ref().unwrap(); let root = list_root(state).unwrap_or_else(|| tree.root()); - let root_scale = tree.node(root).size; // Recomputed every frame, so it never holds an id from an old tree. state.ledger_hover = None; let map_chain = map_hover_chain(state); + let rows = flatten(tree, root, state.dir_sort); - // Determine which directory to highlight: - // If selected node is a file, highlight its parent directory - let highlighted_dir = state.selected_node.and_then(|sel| { - let tree = state.tree.as_ref()?; - if tree.node(sel).is_dir() { - Some(sel) - } else { - tree.node(sel).parent + // The selected file's folder is the one to highlight. + let highlighted_dir = state + .selected_node + .filter(|&s| tree.is_alive(s)) + .and_then(|sel| if tree.node(sel).is_dir() { Some(sel) } else { tree.node(sel).parent }); + + let index_of = |id: NodeId| rows.iter().position(|r| r.id == id && r.kind == RowKind::Dir); + let mut area = egui::ScrollArea::vertical().auto_shrink([false, false]); + if let Some(i) = state.scroll_dir_tree_top.take().and_then(index_of) { + let pinned = ancestors(&rows, i).len().min(MAX_STICKY); + area = area.vertical_scroll_offset(((i - pinned.min(i)) as f32 * ROW_H).max(0.0)); + } else if let Some(i) = state.scroll_dir_tree_to.take().and_then(index_of) { + let off = i as f32 * ROW_H - (ui.available_height() - ROW_H) / 2.0; + area = area.vertical_scroll_offset(off.max(0.0)); + } + + let cx = Ctx { highlighted_dir, map_chain: &map_chain }; + let mut clicked: Vec<(NodeId, Click)> = Vec::new(); + // show_rows derives its row stride from this, so set it before. + ui.spacing_mut().item_spacing.y = 0.0; + ui.visuals_mut().clip_rect_margin = 0.0; + let out = area.show_rows(ui, ROW_H, rows.len(), |ui, range| { + for i in range { + let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_H), Sense::hover()); + if let Some(c) = row_ui(ui, state, &rows[i], rect, i as u64, &cx) { + clicked.push(c); + } } }); - egui::ScrollArea::both() - .auto_shrink([false, false]) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - ui.spacing_mut().interact_size.y = 16.0; - let ctx = Row { highlighted_dir, map_chain: &map_chain, list_root: root }; - show_node(ui, state, root, root_scale, &ctx); - }); + // Pinned ancestors: drawn over the top of the viewport. + let first = (out.state.offset.y / ROW_H).floor().max(0.0) as usize; + let pinned = sticky_chain(&rows, first); + if !pinned.is_empty() { + let top = out.inner_rect.top(); + let layer = ui.painter().with_clip_rect(out.inner_rect); + let bottom = top + pinned.len() as f32 * ROW_H; + layer.rect_filled( + Rect::from_min_max(out.inner_rect.left_top(), Pos2::new(out.inner_rect.right(), bottom)), + 0.0, + theme::bg_raised(), + ); + layer.hline(out.inner_rect.x_range(), bottom, egui::Stroke::new(1.0, theme::rule())); + for (k, &ri) in pinned.iter().enumerate() { + let rect = Rect::from_min_size( + Pos2::new(out.inner_rect.left(), top + k as f32 * ROW_H), + Vec2::new(out.inner_rect.width(), ROW_H), + ); + if let Some(mut c) = row_ui(ui, state, &rows[ri], rect, (1 << 40) + ri as u64, &cx) { + // A pinned row's click takes you to the folder. + if matches!(c.1, Click::Row) { + c.1 = Click::RowScroll; + } + clicked.push(c); + } + } + } + + for (id, click) in clicked { + match click { + Click::Arrow => { + if let Some(t) = &mut state.tree { + toggle_expanded(t, id); + } + ui.ctx().request_repaint(); + } + Click::Row | Click::RowScroll => { + state.selected_node = Some(id); + state.selected_extension = None; + if matches!(click, Click::RowScroll) { + state.scroll_dir_tree_top = Some(id); + ui.ctx().request_repaint(); + } + } + Click::Open => { + crate::nav::navigate_to(state, id); + ui.ctx().request_repaint(); + } + } + } }); } -struct Row<'a> { +struct Ctx<'a> { highlighted_dir: Option, /// The hovered map block and its ancestors. map_chain: &'a [NodeId], - list_root: NodeId, } -/// Width of the ledger's length bar at full scale, in points. -const BAR_W: f32 = 64.0; +enum Click { + Arrow, + Row, + /// Click on a pinned row: select and scroll the list to it. + RowScroll, + Open, +} -/// One ledger row: name, length bar scaled to the largest row of its level, -/// exact size. `scale` is that largest row's size. -fn show_node(ui: &mut Ui, state: &mut AppState, id: NodeId, scale: u64, ctx: &Row) { - let tree = match &state.tree { - Some(t) => t, - None => return, - }; - if !tree.is_alive(id) { - return; +/// Paint one ledger row into `rect` and read its clicks. `salt` keeps the +/// widget ids of a pinned copy apart from the row it repeats. +fn row_ui( + ui: &mut Ui, + state: &mut AppState, + row: &LedgerRow, + rect: Rect, + salt: u64, + cx: &Ctx, +) -> Option<(NodeId, Click)> { + let tree = state.tree.as_ref()?; + let painter = ui.painter(); + let node = tree.node(row.id); + let indent = row.depth as f32 * INDENT; + + if let RowKind::Loose { bytes, n } = row.kind { + let label = if n == 1 { "1 file".to_string() } else { format!("{n} files") }; + painter.text( + Pos2::new(rect.left() + 3.0 + indent + 14.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::new(12.0, theme::italic()), + theme::note(), + ); + size_and_bar(painter, rect, bytes, row.scale, theme::faint()); + return None; } - let node = tree.node(id); - let is_root = id == ctx.list_root; - let (children, expanded) = match &node.kind { - NodeKind::Directory { children, expanded } => (children.clone(), *expanded || is_root), - NodeKind::File { .. } => return, - }; - let is_selected = ctx.highlighted_dir == Some(id); + let is_root = row.depth == 0; + let expanded = is_root || matches!(&node.kind, NodeKind::Directory { expanded: true, .. }); + let resp = ui.interact(rect, ui.id().with(("row", salt)), Sense::click()); + let arrow = (row.has_kids && !is_root).then(|| arrow_rect(rect, row.depth)); + // Added after the row so it wins the click where they overlap. + let arrow_resp = arrow.map(|a| ui.interact(a, ui.id().with(("arrow", salt)), Sense::click())); + if let Some(a) = arrow { + if salt < (1 << 40) { + state.ledger_arrows.push((row.id, a)); + } + } + let over_arrow = arrow_resp.as_ref().is_some_and(|r| r.hovered()); + if resp.hovered() || over_arrow { + ui.ctx().set_cursor_icon(CursorIcon::PointingHand); + state.ledger_hover = Some(row.id); + } + + let tree = state.tree.as_ref()?; + let painter = ui.painter(); + let is_selected = cx.highlighted_dir == Some(row.id); // The map's pointer is inside this folder, and no deeper listed row // shows it. - let map_here = ctx.map_chain.contains(&id) + let map_here = cx.map_chain.contains(&row.id) && !(expanded - && children + && node + .children() .iter() - .any(|c| ctx.map_chain.contains(c) && tree.node(*c).is_dir())); - let name = tree.name(id).to_string(); - let size = node.size; - let depth = node.depth.saturating_sub(tree.node(ctx.list_root).depth); - let items = node.items(); - let has_children = children.iter().any(|&c| tree.is_alive(c)); - - // The row's fill depends on hover, known only after layout. - let bg = ui.painter().add(egui::Shape::Noop); - let frame = egui::Frame::new() - .inner_margin(egui::Margin { left: 3, right: 3, top: 2, bottom: 2 }) - .corner_radius(theme::RADIUS_SM); - - let frame_response = frame.show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - ui.add_space(depth as f32 * 10.0); - - if has_children && !is_root { - let arrow = if expanded { "▼" } else { "▶" }; - let arrow_resp = ui.add( - egui::Label::new( - egui::RichText::new(arrow).color(theme::note()).size(9.0), - ) - .sense(egui::Sense::click()), - ); - if arrow_resp.clicked() { - if let Some(tree) = &mut state.tree { - if let NodeKind::Directory { - expanded: ref mut exp, - .. - } = tree.node_mut(id).kind - { - *exp = !*exp; - } - } - return; - } - } else { - ui.add_space(10.0); - } - - let name_color = if is_selected || map_here { - theme::ink() - } else { - theme::ink_soft() - }; - // Reserve room for size, bar and item count. - let avail = (ui.available_width() - (BAR_W + 84.0)).max(30.0); - let max_chars = (avail / 6.5) as usize; - let display_name = if name.chars().count() > max_chars && max_chars > 3 { - let s: String = name.chars().take(max_chars - 1).collect(); - format!("{}…", s) - } else { - name.clone() - }; - ui.label(egui::RichText::new(&display_name).color(name_color).size(12.5)); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 6.0; - size_and_bar(ui, size, scale, theme::highlight()); - if ui.available_width() > 48.0 { - ui.label( - egui::RichText::new(format_count(items)) - .color(theme::faint()) - .size(9.5), - ); - } - }); - }); - }); - - let row_response = frame_response.response.interact(egui::Sense::click()); - if row_response.hovered() { - state.ledger_hover = Some(id); - } + .any(|c| cx.map_chain.contains(c) && tree.node(*c).is_dir())); let fill = if is_selected { theme::bg_selection() - } else if map_here || row_response.hovered() { + } else if map_here || resp.hovered() { theme::bg_hover() } else { egui::Color32::TRANSPARENT }; - ui.painter().set( - bg, - egui::Shape::rect_filled(row_response.rect, theme::RADIUS_SM, fill), - ); - if row_response.clicked() { - state.selected_node = Some(id); - state.selected_extension = None; - } - if row_response.double_clicked() && !is_root { - state.view_root = Some(id); - state.zoom_stack.push(id); - state.treemap_dirty = true; + painter.rect_filled(rect, theme::RADIUS_SM, fill); + + if let Some(a) = arrow { + let glyph = if expanded { "▼" } else { "▶" }; + let color = if over_arrow { theme::ink() } else { theme::note() }; + painter.text(a.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(9.0), color); } - if state.scroll_dir_tree_to == Some(id) { - row_response.scroll_to_me(Some(egui::Align::Center)); - state.scroll_dir_tree_to = None; + let name_color = if is_selected || map_here { theme::ink() } else { theme::ink_soft() }; + let name_x = rect.left() + 3.0 + indent + 14.0; + let right = size_and_bar(painter, rect, node.size, row.scale, theme::highlight()); + let mut text_right = right; + if right - name_x > 90.0 { + let c = painter.text( + Pos2::new(right - 4.0, rect.center().y), + egui::Align2::RIGHT_CENTER, + format_count(node.items()), + egui::FontId::proportional(9.5), + theme::faint(), + ); + text_right = c.left(); } + let name = tree.name(row.id); + let max_chars = (((text_right - name_x) - 6.0) / 6.5).max(0.0) as usize; + let shown = if name.chars().count() > max_chars && max_chars > 3 { + let s: String = name.chars().take(max_chars - 1).collect(); + format!("{s}…") + } else { + name.to_string() + }; + painter.text( + Pos2::new(name_x, rect.center().y), + egui::Align2::LEFT_CENTER, + shown, + egui::FontId::proportional(12.5), + name_color, + ); - row_response.context_menu(|ui| { + let id = row.id; + resp.context_menu(|ui| { crate::ui::context_menu::show(ui, state, id); }); - - if expanded { - let (order, child_scale, loose) = match &state.tree { - Some(t) => { - let loose = loose_files(t, id); - (sorted_children(t, id, state.dir_sort), level_max(t, id, loose.0), loose) - } - None => return, - }; - for child_id in order { - show_node(ui, state, child_id, child_scale, ctx); - } - if loose.1 > 0 { - loose_row(ui, depth + 1, loose, child_scale); - } + if arrow_resp.as_ref().is_some_and(|r| r.clicked()) { + return Some((id, Click::Arrow)); } + if resp.double_clicked() && !is_root { + return Some((id, Click::Open)); + } + if resp.clicked() { + return Some((id, Click::Row)); + } + None } -/// The folder's own files, as one row, so a level's lengths add up to it. -fn loose_row(ui: &mut Ui, depth: u16, (bytes, n): (u64, usize), scale: u64) { - egui::Frame::new() - .inner_margin(egui::Margin { left: 3, right: 3, top: 2, bottom: 2 }) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - ui.add_space(depth as f32 * 10.0 + 10.0); - let label = if n == 1 { "1 file".to_string() } else { format!("{n} files") }; - ui.label( - egui::RichText::new(label) - .family(theme::italic()) - .color(theme::note()) - .size(12.0), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 6.0; - size_and_bar(ui, bytes, scale, theme::faint()); - }); - }); - }); -} - -fn size_and_bar(ui: &mut Ui, size: u64, scale: u64, color: egui::Color32) { - ui.add_sized( - Vec2::new(54.0, 14.0), - egui::Label::new( - egui::RichText::new(theme::format_size(size)) - .color(theme::ink_soft()) - .size(10.5), - ), +/// Paints size and bar at the right edge of `rect`; returns the x where the +/// next element to the left must stop. +fn size_and_bar(painter: &egui::Painter, rect: Rect, size: u64, scale: u64, color: egui::Color32) -> f32 { + let mut x = rect.right() - 6.0; + painter.text( + Pos2::new(x, rect.center().y), + egui::Align2::RIGHT_CENTER, + theme::format_size(size), + egui::FontId::proportional(10.5), + theme::ink_soft(), ); - if ui.available_width() > BAR_W + 8.0 { - let (rect, _) = ui.allocate_exact_size(Vec2::new(BAR_W, 5.0), egui::Sense::hover()); + x -= 54.0 + 6.0; + if x - BAR_W > rect.left() + 60.0 { let frac = if scale > 0 { (size as f32 / scale as f32).min(1.0) } else { 0.0 }; - let filled = egui::Rect::from_min_size( - rect.left_top(), - Vec2::new((BAR_W * frac).max(if size > 0 { 1.0 } else { 0.0 }), rect.height()), - ); - ui.painter().rect_filled(filled, 0.0, color); + let w = (BAR_W * frac).max(if size > 0 { 1.0 } else { 0.0 }); + let bar = Rect::from_min_size(Pos2::new(x - BAR_W, rect.center().y - 2.5), Vec2::new(w, 5.0)); + painter.rect_filled(bar, 0.0, color); + x -= BAR_W + 6.0; } + x } /// Compact item-count formatting: 1234 → "1.2k", 12 → "12". diff --git a/src/ui/ext_list.rs b/src/ui/ext_list.rs index c59f021..a835a5f 100644 --- a/src/ui/ext_list.rs +++ b/src/ui/ext_list.rs @@ -109,7 +109,9 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { }); let row_rect = response.response.rect; - let row_sense = ui.interact(row_rect, ext_id, egui::Sense::click()); + let row_sense = ui + .interact(row_rect, ext_id, egui::Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); if row_sense.clicked() { if is_selected { state.selected_extension = None; diff --git a/src/ui/fda_window.rs b/src/ui/fda_window.rs index f65db3c..4a73f2f 100644 --- a/src/ui/fda_window.rs +++ b/src/ui/fda_window.rs @@ -26,12 +26,9 @@ pub fn show(ctx: &Context, state: &mut AppState) { ui.add_space(6.0); ui.label( RichText::new( - "macOS hides some folders (Mail, Messages, Photos, and other \ - protected data) from apps unless you grant Full Disk Access. \ - Without it MacDirStat still scans everything else — those \ - areas just show up as “Hidden / Skipped.”\n\n\ - To include them, add MacDirStat under Full Disk Access, then \ - re-scan. You'll only be asked this once.", + "Without Full Disk Access, macOS hides Mail, Messages, Photos and \ + other protected folders; they show as “Hidden / Skipped.” Add \ + MacDirStat under Full Disk Access, then re-scan.", ) .color(theme::ink_soft()) .size(11.0), diff --git a/src/ui/help_window.rs b/src/ui/help_window.rs index 43efba2..f60961d 100644 --- a/src/ui/help_window.rs +++ b/src/ui/help_window.rs @@ -33,6 +33,8 @@ pub fn show(ctx: &Context, state: &mut AppState) { .size(12.0), ); ui.add_space(4.0); + shortcut(ui, "⌫ ⌘↑", "Enclosing folder"); + shortcut(ui, "⌘[", "Back"); shortcut(ui, "⌘O", "Pick a directory to scan"); shortcut(ui, "⌘R", "Re-scan current root"); shortcut(ui, "⇧⌘R", "Re-scan only the selected subtree"); @@ -44,7 +46,7 @@ pub fn show(ctx: &Context, state: &mut AppState) { shortcut(ui, "→ ←", "Expand / collapse the selected directory"); shortcut(ui, "⌘⌫", "Move selected node to Trash"); shortcut(ui, "↩", "Reveal selected node in Finder"); - shortcut(ui, "Esc", "Close help/cleanup/search · pop zoom · clear selection"); + shortcut(ui, "Esc", "Close window · up one folder · clear selection"); shortcut(ui, "?", "Toggle this help"); ui.add_space(10.0); @@ -58,11 +60,9 @@ pub fn show(ctx: &Context, state: &mut AppState) { .size(12.0), ); ui.add_space(4.0); - tip(ui, "• Click any treemap rect — even tiny — to select what's there or its containing folder."); - tip(ui, "• Right-click for Reveal · Refresh · Zoom · Trash actions."); + tip(ui, "• Right-click a block or row for actions."); + tip(ui, "• Double-click a folder row to show it as the map."); tip(ui, "• Drag a folder onto the window to scan it."); - tip(ui, "• The Cleanup button surfaces regenerable directories (caches, build outputs, simulators)."); - tip(ui, "• Hover any rect for path · size · type · modified-time tooltip."); ui.add_space(10.0); widgets::subtle_divider(ui); @@ -159,9 +159,8 @@ fn privacy_section(ui: &mut egui::Ui, state: &mut AppState) { ui.add_space(4.0); ui.label( RichText::new( - "MacDirStat sends anonymous, cookieless usage counts (app opens, scans, \ - space reclaimed) to help prioritize features. No file names or paths are \ - ever sent. You can turn this off.", + "Sends anonymous, cookieless usage counts (app opens, scans, space \ + reclaimed). Never file names or paths.", ) .color(theme::ink_soft()) .size(11.0), @@ -170,6 +169,7 @@ fn privacy_section(ui: &mut egui::Ui, state: &mut AppState) { let mut opted_out = crate::flywheel::telemetry_opted_out(); if ui .checkbox(&mut opted_out, "Don't send anonymous usage data") + .on_hover_cursor(egui::CursorIcon::PointingHand) .changed() { crate::flywheel::set_telemetry_opt_out(opted_out); @@ -178,10 +178,9 @@ fn privacy_section(ui: &mut egui::Ui, state: &mut AppState) { ui.add_space(4.0); ui.label( RichText::new( - "History keeps a short summary of each scan (date, total, the names and \ - sizes of the largest top-level folders; never file names or lists) in ~/Library/Application Support/\ - mac-dir-stat/history.json, six per folder. It never leaves this Mac. \ - Delete the file to forget it.", + "History keeps date, total and the largest top-level folders of each scan \ + in ~/Library/Application Support/mac-dir-stat/history.json, six per \ + folder. It stays on this Mac.", ) .color(theme::ink_soft()) .size(11.0), diff --git a/src/ui/history_window.rs b/src/ui/history_window.rs index 8b3fb08..624c468 100644 --- a/src/ui/history_window.rs +++ b/src/ui/history_window.rs @@ -36,8 +36,7 @@ pub fn show(ctx: &Context, state: &mut AppState) { ui.label(RichText::new(title).color(theme::ink()).size(18.0)); ui.label( RichText::new(format!( - "{} · same order in every panel; height is space used. \ - Kept on this Mac only: totals and top folders, no file lists.", + "{} · panel height is space used", state.scan_root.display() )) .family(theme::italic()) @@ -52,8 +51,7 @@ pub fn show(ctx: &Context, state: &mut AppState) { RichText::new(if scans.is_empty() { "No finished scan of this folder yet." } else { - "One scan so far. Scan this folder again another day and the \ - panels appear here, one per scan, up to six." + "One scan so far. Scan again later to compare." }) .color(theme::ink_soft()) .size(13.0), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e2722cd..fbab0b8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod ext_list; pub mod fda_window; pub mod help_window; pub mod history_window; +pub mod pathbar; pub mod relocate_window; pub mod search; pub mod theme; diff --git a/src/ui/pathbar.rs b/src/ui/pathbar.rs new file mode 100644 index 0000000..eefef6e --- /dev/null +++ b/src/ui/pathbar.rs @@ -0,0 +1,161 @@ +//! The path bar under the toolbar: Back, Up, the folder path as clickable +//! segments, and the legend of the active colour mode. + +use crate::app::AppState; +use crate::scanner::tree::NodeId; +use crate::treemap::color::{self, ColorMode, AGE_TICKS, DEPTH_SPAN}; +use crate::ui::{theme, widgets}; +use egui::{CursorIcon, Pos2, Rect, RichText, Sense, Ui, Vec2}; + +/// Path segments shown; a longer path keeps the first and the last ones. +const MAX_SEGMENTS: usize = 6; + +pub fn show(ui: &mut Ui, state: &mut AppState) { + let Some(tree) = &state.tree else { return }; + let Some(view) = state.view_root.filter(|&v| tree.is_alive(v)) else { return }; + let chain = crate::nav::chain(tree, view); + let names: Vec = chain.iter().map(|&id| tree.name(id).to_string()).collect(); + let can_up = crate::nav::parent_of_root(state).is_some(); + let can_back = !state.nav_back.is_empty(); + let mut go: Option = None; + let (mut up, mut back) = (false, false); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let btn = |ui: &mut Ui, on: bool, label: &str, tip: &str| { + ui.add_enabled_ui(on, |ui| widgets::ghost_button(ui, label).on_hover_text(tip)) + .inner + .clicked() + }; + back = btn(ui, can_back, "←", "Back ⌘["); + up = btn(ui, can_up, "↑", "Enclosing folder ⌘↑"); + ui.add_space(6.0); + + let hidden = chain.len().saturating_sub(MAX_SEGMENTS); + for (i, (&id, name)) in chain.iter().zip(&names).enumerate() { + if hidden > 0 && i > 0 && i <= hidden { + if i == 1 { + sep(ui); + ui.label(RichText::new("…").color(theme::note())) + .on_hover_text(names[1..=hidden].join(" › ")); + } + continue; + } + if i > 0 { + sep(ui); + } + let last = i + 1 == chain.len(); + if last { + ui.label(RichText::new(name).strong().color(theme::ink()).size(13.0)); + } else { + let b = egui::Button::new(RichText::new(name).color(theme::ink_soft()).size(13.0)) + .frame(false); + let r = ui.add(b).on_hover_text(format!("Show {name}")); + if r.clicked() { + go = Some(id); + } + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + legend(ui, state.color_mode); + }); + }); + + if let Some(id) = go { + crate::nav::navigate_to(state, id); + } else if up { + crate::nav::go_up(state); + } else if back { + crate::nav::go_back(state); + } +} + +fn sep(ui: &mut Ui) { + ui.label(RichText::new("›").color(theme::faint()).size(13.0)); +} + +/// What the colours mean: a gradient bar with its labelled ends and ticks. +/// Nothing for Extension, where the key is the file-type list. +pub fn legend(ui: &mut Ui, mode: ColorMode) { + let (lo, hi, ticks): (&str, &str, Vec<(f32, String)>) = match mode { + ColorMode::Extension => return, + ColorMode::Depth => ( + "shallow", + "deep", + (1..=DEPTH_SPAN) + .map(|d| { + let t = (d - 1) as f32 / (DEPTH_SPAN - 1) as f32; + (t, if d == DEPTH_SPAN { format!("{d}+") } else { d.to_string() }) + }) + .collect(), + ), + ColorMode::Age => ( + "new", + "old", + AGE_TICKS + .iter() + .map(|&(l, days)| (color::age_t(days) as f32, l.to_string())) + .collect(), + ), + }; + let tip = match mode { + ColorMode::Depth => "Folder level, counted from the folder shown. Blocks get darker with each level.", + _ => "Last modified, on a log scale. Hover a block for its date.", + }; + let bar = Vec2::new(200.0, 9.0); + // Laid out right to left: the old end first. + ui.label(RichText::new(hi).color(theme::note()).size(11.0)); + let resp = ui + .allocate_ui_with_layout( + Vec2::new(bar.x, bar.y + 14.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.spacing_mut().item_spacing.y = 1.0; + let (rect, resp) = ui.allocate_exact_size(bar, Sense::hover()); + paint_bar(ui, rect, mode); + let (lab, _) = ui.allocate_exact_size(Vec2::new(bar.x, 11.0), Sense::hover()); + for (t, text) in &ticks { + let x = rect.left() + t * bar.x; + ui.painter().vline(x, rect.bottom()..=rect.bottom() + 2.0, egui::Stroke::new(1.0, theme::faint())); + let align = if *t <= 0.0 { + egui::Align2::LEFT_TOP + } else if *t >= 1.0 { + egui::Align2::RIGHT_TOP + } else { + egui::Align2::CENTER_TOP + }; + ui.painter().text( + Pos2::new(x, lab.top()), + align, + text, + egui::FontId::proportional(9.0), + theme::note(), + ); + } + resp + }) + .inner; + ui.label(RichText::new(lo).color(theme::note()).size(11.0)); + resp.on_hover_text(tip).on_hover_cursor(CursorIcon::Help); +} + +fn paint_bar(ui: &Ui, rect: Rect, mode: ColorMode) { + const N: usize = 30; + let mut mesh = egui::Mesh::default(); + for i in 0..=N { + let t = i as f32 / N as f32; + let Some(c) = color::legend_color(mode, t) else { return }; + let c = theme::map_fill(c, 255); + let x = rect.left() + t * rect.width(); + mesh.colored_vertex(Pos2::new(x, rect.top()), c); + mesh.colored_vertex(Pos2::new(x, rect.bottom()), c); + if i > 0 { + let b = (i * 2) as u32; + mesh.add_triangle(b - 2, b - 1, b); + mesh.add_triangle(b - 1, b, b + 1); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); + ui.painter().rect_stroke(rect, 0.0, egui::Stroke::new(1.0, theme::rule()), egui::StrokeKind::Inside); +} diff --git a/src/ui/relocate_window.rs b/src/ui/relocate_window.rs index 86ac214..d9702ae 100644 --- a/src/ui/relocate_window.rs +++ b/src/ui/relocate_window.rs @@ -107,7 +107,8 @@ pub fn show(ctx: &egui::Context, d: &mut RelocateDialog) -> Option { ui.checkbox( &mut d.link, RichText::new("Leave a link behind at the original location").size(12.0), - ); + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); if let Some(m) = &d.measure { let c = relocate::check_with(&d.path, &vol.path, m); match &c { diff --git a/src/ui/search.rs b/src/ui/search.rs index 98ad1f5..1a5d79f 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -36,6 +36,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { ) .sense(egui::Sense::click()), ) + .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() { state.search_active = false; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 87ed278..90cfa1d 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -254,6 +254,7 @@ fn style(c: &Palette, dark: bool) -> egui::Style { } else { egui::Visuals::light() }; + v.interact_cursor = Some(egui::CursorIcon::PointingHand); v.override_text_color = Some(c.ink); v.panel_fill = c.panel; v.window_fill = c.base; diff --git a/src/ui/toolbar.rs b/src/ui/toolbar.rs index ff8b06e..99fa7e0 100644 --- a/src/ui/toolbar.rs +++ b/src/ui/toolbar.rs @@ -33,10 +33,8 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if !state.has_fda { let resp = widgets::ghost_button(ui, "⚠ Grant Full Disk Access") .on_hover_text( - "macOS asks permission for each protected folder it scans \ - (Downloads, Desktop, Photos, removable drives…). Grant Full \ - Disk Access once to stop all of those prompts. Opens System \ - Settings — add MacDirStat, then re-scan.", + "Stops the per-folder permission prompts. Opens System \ + Settings: add MacDirStat, then re-scan.", ); if resp.clicked() { crate::platform::fda::open_settings(); @@ -53,17 +51,14 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { }; let resp = widgets::ghost_button(ui, &label); let resp = resp.on_hover_text( - "Opens a review window listing safe-to-delete folders \ - (caches, Xcode build artifacts, node_modules, simulators, …). \ - Nothing is deleted until you tick items and confirm a Trash action.", + "Caches, build output, node_modules, simulators: folders a tool rebuilds.", ); if resp.clicked() { state.cleanup_window_open = !state.cleanup_window_open; } let past = crate::history::for_root(&state.history, &state.scan_root).len(); let resp = widgets::ghost_button(ui, "History…").on_hover_text(format!( - "This folder over its last {} scans, side by side. \ - Summaries stay on this Mac.", + "This folder over its last {} scans", past.max(1) )); if resp.clicked() { diff --git a/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index dfbbf2b..7c67a83 100644 --- a/src/ui/treemap_view.rs +++ b/src/ui/treemap_view.rs @@ -5,42 +5,6 @@ use crate::ui::theme; use egui::{Color32, Pos2, Rect, StrokeKind, Ui, Vec2}; pub fn show(ui: &mut Ui, state: &mut AppState) { - // Breadcrumb bar if zoomed - if state.zoom_stack.len() > 1 { - let mut breadcrumb_click: Option<(usize, usize)> = None; - let zoom_snapshot: Vec = state.zoom_stack.clone(); - ui.horizontal(|ui| { - if let Some(tree) = &state.tree { - for (i, &node_id) in zoom_snapshot.iter().enumerate() { - if i > 0 { - ui.label( - egui::RichText::new(" \u{203A} ") - .color(theme::note()) - .size(11.0), - ); - } - let name = tree.name(node_id).to_string(); - let label = ui.add( - egui::Label::new( - egui::RichText::new(&name) - .color(theme::highlight()) - .size(11.0), - ) - .sense(egui::Sense::click()), - ); - if label.clicked() { - breadcrumb_click = Some((i, node_id)); - } - } - } - }); - if let Some((i, node_id)) = breadcrumb_click { - state.view_root = Some(node_id); - state.zoom_stack.truncate(i + 1); - state.treemap_dirty = true; - } - } - let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); let canvas_rect = response.rect; @@ -212,11 +176,9 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let is_large = w > 3.0 && h > 3.0; - // Hover detection only for visible-sized rects - if is_large && state.hovered_node.is_none() { - if pointer_pos.map_or(false, |p| rect.contains(p)) { - state.hovered_node = Some(cr.node_id); - } + // Hover: the last block drawn at the pointer is the topmost. + if is_large && pointer_pos.map_or(false, |p| rect.contains(p)) { + state.hovered_node = Some(cr.node_id); } // Compute alpha for filtering @@ -468,6 +430,10 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { } } + if state.hovered_node.or(state.hovered_dir).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + // Click handling — falls back to the deepest directory under the cursor // when the file rect is too small to be hover-targeted (the is_large // filter above only registers rects > 3px). Without this fallback, @@ -493,9 +459,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if let Some(tree) = &state.tree { if let Some(pid) = tree.node(hovered).parent { if tree.node(pid).is_dir() { - state.view_root = Some(pid); - state.zoom_stack.push(pid); - state.treemap_dirty = true; + crate::nav::navigate_to(state, pid); } } } @@ -563,14 +527,10 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { .size(11.5), ); let body = if is_free_space { - "Available space on this volume according to the \ - filesystem (statvfs). Not stored in any file." + "Free space on this volume." } else { - "Bytes the scanner couldn't enumerate: TCC-protected \ - paths (Photos library, Mail, Calendar, Reminders, …), \ - macOS-internal directories that block readdir, and \ - per-file permission errors. Not double-counted — the \ - gap between disk total and (scanned + free)." + "Space the scanner could not read: protected folders \ + (Photos, Mail, Calendar, …) and permission errors." }; ui.label( egui::RichText::new(body) diff --git a/tests/color_tests.rs b/tests/color_tests.rs index 9c342a2..23b82e8 100644 --- a/tests/color_tests.rs +++ b/tests/color_tests.rs @@ -69,3 +69,37 @@ fn test_palette_has_enough_colors() { .collect(); assert!(unique.len() >= 6, "Expected at least 6 unique colors, got {}", unique.len()); } + +#[test] +fn age_scale_is_monotonic_and_ends_at_three_years() { + use mac_dir_stat::treemap::color::{age_t, AGE_SPAN_DAYS, AGE_TICKS}; + assert_eq!(age_t(0), 0.0); + assert_eq!(age_t(AGE_SPAN_DAYS), 1.0); + assert_eq!(age_t(AGE_SPAN_DAYS * 10), 1.0); + let ts: Vec = AGE_TICKS.iter().map(|t| age_t(t.1)).collect(); + assert!(ts.windows(2).all(|w| w[0] < w[1]), "ticks out of order: {ts:?}"); +} + +#[test] +fn a_file_of_a_ticks_age_gets_the_legends_colour_at_that_tick() { + use mac_dir_stat::treemap::color::{age_color_at, age_t, AGE_TICKS}; + use std::time::{Duration, SystemTime}; + for (_, days) in AGE_TICKS { + // an hour inside the day, so clock drift between the calls cannot cross it + let modified = SystemTime::now() - Duration::from_secs(days * 86_400 + 3600); + assert_eq!(age_color(modified), age_color_at(age_t(days)), "{days} d"); + } +} + +#[test] +fn legend_ends_are_the_map_colours_at_the_labelled_ends() { + use mac_dir_stat::treemap::color::{legend_color, ColorMode, DEPTH_SPAN}; + let mid = |p: GradientPair| [((p.0[0] as u16 + p.1[0] as u16) / 2) as u8, ((p.0[1] as u16 + p.1[1] as u16) / 2) as u8, ((p.0[2] as u16 + p.1[2] as u16) / 2) as u8, 255]; + assert_eq!(legend_color(ColorMode::Depth, 0.0), Some(mid(depth_color(0, 1)))); + assert_eq!(legend_color(ColorMode::Depth, 1.0), Some(mid(depth_color(0, DEPTH_SPAN)))); + assert_eq!(legend_color(ColorMode::Age, 0.0), Some(mid(age_color(std::time::SystemTime::now())))); + assert_eq!(legend_color(ColorMode::Extension, 0.5), None); + // depth keeps darkening up to the last level, then stops + assert_eq!(depth_color(0, DEPTH_SPAN + 1), depth_color(0, DEPTH_SPAN + 3)); + assert_ne!(depth_color(0, DEPTH_SPAN - 1), depth_color(0, DEPTH_SPAN)); +} diff --git a/tests/navigation_tests.rs b/tests/navigation_tests.rs new file mode 100644 index 0000000..9d02eee --- /dev/null +++ b/tests/navigation_tests.rs @@ -0,0 +1,178 @@ +//! Folder list clicks, going up the hierarchy, and the pinned parent rows. + +use egui::{Event, PointerButton, Pos2}; +use mac_dir_stat::app::{App, AppState, DirSortMode}; +use mac_dir_stat::nav; +use mac_dir_stat::scanner::tree::{FileTree, NodeId, NodeKind}; +use mac_dir_stat::treemap::color::ColorMode; +use mac_dir_stat::ui::dir_tree::{flatten, sticky_chain, toggle_expanded, RowKind, MAX_STICKY}; +use std::path::PathBuf; +use std::time::SystemTime; + +const MB: u64 = 1024 * 1024; + +/// /r/a/b/c/d/e, each level holding a file, plus a sibling /r/z. +fn deep() -> (FileTree, Vec) { + let t0 = SystemTime::UNIX_EPOCH; + let mut t = FileTree::new(); + t.rename_root(b"/r"); + let mut ids = vec![t.root()]; + for (i, n) in ["a", "b", "c", "d", "e"].iter().enumerate() { + let d = t.add_dir(*ids.last().unwrap(), n.as_bytes(), false, t0, i as u16 + 1); + t.add_file(d, b"f.bin", 10 * MB, Some("bin"), t0, i as u16 + 2); + ids.push(d); + } + let z = t.add_dir(t.root(), b"z", false, t0, 1); + t.add_file(z, b"g.bin", MB, Some("bin"), t0, 2); + t.compute_sizes(); + (t, ids) +} + +fn state(t: FileTree) -> AppState { + let mut s = AppState::new( + PathBuf::from("/r"), + false, + ColorMode::Extension, + mac_dir_stat::flywheel::disabled().clone(), + ); + s.request_rescan = false; + s.view_root = Some(t.root()); + s.zoom_stack = vec![t.root()]; + s.tree = Some(t); + s +} + +fn open_all(s: &mut AppState, dirs: &[NodeId]) { + let t = s.tree.as_mut().unwrap(); + for &id in dirs { + if let NodeKind::Directory { expanded, .. } = &mut t.node_mut(id).kind { + *expanded = true; + } + } +} + +#[test] +fn up_goes_to_the_parent_and_back_returns() { + let (t, ids) = deep(); + let mut s = state(t); + nav::navigate_to(&mut s, ids[3]); // /r/a/b/c + assert_eq!(s.view_root, Some(ids[3])); + assert_eq!(s.zoom_stack, vec![ids[0], ids[1], ids[2], ids[3]], "breadcrumb is the whole path"); + assert!(nav::go_up(&mut s)); + assert_eq!(s.view_root, Some(ids[2])); + assert!(nav::go_back(&mut s)); + assert_eq!(s.view_root, Some(ids[3]), "back to where Up was pressed"); +} + +#[test] +fn back_returns_to_the_folder_left_and_skips_dead_ones() { + let (t, ids) = deep(); + let mut s = state(t); + nav::navigate_to(&mut s, ids[2]); + nav::navigate_to(&mut s, ids[4]); + assert_eq!(s.nav_back, vec![ids[0], ids[2]]); + s.tree.as_mut().unwrap().remove_node(ids[2]); + assert!(nav::go_back(&mut s), "falls through the dead folder to the root"); + assert_eq!(s.view_root, Some(ids[0])); + assert!(!nav::go_back(&mut s), "nothing left"); +} + +#[test] +fn up_at_the_scan_root_does_nothing() { + let (t, ids) = deep(); + let mut s = state(t); + assert!(!nav::go_up(&mut s)); + assert_eq!(s.view_root, Some(ids[0])); + assert!(s.nav_back.is_empty()); +} + +#[test] +fn flatten_lists_open_folders_depth_first_with_loose_files_last() { + let (t, ids) = deep(); + let mut s = state(t); + let t = s.tree.as_mut().unwrap(); + toggle_expanded(t, ids[1]); + let rows = flatten(t, ids[0], DirSortMode::Size); + let shape: Vec<(NodeId, u16, bool)> = + rows.iter().map(|r| (r.id, r.depth, matches!(r.kind, RowKind::Loose { .. }))).collect(); + // r, a (open), b, a's loose file, z, (r has no loose files) + assert_eq!(shape[0], (ids[0], 0, false)); + assert_eq!(shape[1], (ids[1], 1, false)); + assert_eq!(shape[2], (ids[2], 2, false)); + assert_eq!(shape[3], (ids[1], 2, true)); + let z = t.find_path(std::path::Path::new("/r/z")).unwrap(); + assert!(shape.contains(&(z, 1, false)), "z listed at level 1"); + assert_eq!(toggle_expanded(t, ids[1]), Some(false)); + assert_eq!(flatten(t, ids[0], DirSortMode::Size).len(), 3, "r, a, z"); +} + +#[test] +fn pinned_rows_are_the_ancestors_of_the_first_visible_row() { + let (t, ids) = deep(); + let mut s = state(t); + open_all(&mut s, &ids); + let rows = flatten(s.tree.as_ref().unwrap(), ids[0], DirSortMode::Size); + let at = |id: NodeId| rows.iter().position(|r| r.id == id && r.kind == RowKind::Dir).unwrap(); + assert!(sticky_chain(&rows, 0).is_empty(), "scrolled to the top: nothing pinned"); + // Top edge is "d": r, a, b, c are above it; c's chain is r > a > b > c. + let chain = sticky_chain(&rows, at(ids[4])); + let pinned: Vec = chain.iter().map(|&i| rows[i].id).collect(); + assert!(pinned.len() <= MAX_STICKY); + assert_eq!(pinned.last(), Some(&ids[3]), "the nearest parent is always pinned"); + assert!(pinned.windows(2).all(|w| w[0] < w[1]), "outermost first"); + assert!(!pinned.contains(&ids[4]), "a row is not its own parent"); +} + +fn frame(a: &mut App, ctx: &egui::Context, events: Vec) { + let mut f = eframe::Frame::_new_kittest(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1280.0, 800.0))), + events, + ..Default::default() + }; + let _ = ctx.run(input, |ctx| eframe::App::update(a, ctx, &mut f)); +} + +fn click(a: &mut App, ctx: &egui::Context, p: Pos2) { + frame(a, ctx, vec![Event::PointerMoved(p)]); + let btn = |pressed| Event::PointerButton { + pos: p, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + frame(a, ctx, vec![btn(true)]); + frame(a, ctx, vec![btn(false)]); + frame(a, ctx, vec![]); +} + +fn is_open(a: &App, id: NodeId) -> bool { + matches!(a.state.tree.as_ref().unwrap().node(id).kind, NodeKind::Directory { expanded: true, .. }) +} + +/// The bug: the row's own click area sat on top of the triangle, so a click +/// on the triangle only selected the row and the folder never opened. +#[test] +fn journey_clicking_the_triangle_opens_and_closes_the_folder() { + let (t, ids) = deep(); + let mut a = App::from_state(state(t)); + let ctx = egui::Context::default(); + mac_dir_stat::ui::theme::apply_theme(&ctx); + frame(&mut a, &ctx, vec![]); + frame(&mut a, &ctx, vec![]); + let arrow = |a: &App, id: NodeId| a.state.ledger_arrows.iter().find(|x| x.0 == id).map(|x| x.1.center()); + let p = arrow(&a, ids[1]).expect("row a has a triangle"); + assert!(!is_open(&a, ids[1])); + + click(&mut a, &ctx, p); + assert!(is_open(&a, ids[1]), "triangle opens the folder"); + assert_ne!(a.state.selected_node, Some(ids[1]), "and does not just select it"); + click(&mut a, &ctx, p); + assert!(!is_open(&a, ids[1]), "second click closes it"); + + // The name is not the triangle: it selects, and leaves the folder shut. + let p_name = Pos2::new(p.x + 60.0, p.y); + click(&mut a, &ctx, p_name); + assert_eq!(a.state.selected_node, Some(ids[1])); + assert!(!is_open(&a, ids[1])); +} diff --git a/tests/squarify_tests.rs b/tests/squarify_tests.rs index bd337fd..d2e3c89 100644 --- a/tests/squarify_tests.rs +++ b/tests/squarify_tests.rs @@ -99,3 +99,20 @@ fn test_empty_input() { let rects = layout(&items, &bounds); assert!(rects.is_empty()); } + +#[test] +fn sub_pixel_blocks_are_merged_into_one() { + use mac_dir_stat::scanner::tree::FileTree; + use mac_dir_stat::treemap::{compute_treemap, color::ColorMode, squarify::Rect}; + let t0 = std::time::SystemTime::UNIX_EPOCH; + let mut t = FileTree::new(); + let r = t.root(); + t.add_file(r, b"big.bin", 1_000_000, Some("bin"), t0, 1); + for i in 0..500 { + t.add_file(r, format!("s{i}.txt").as_bytes(), 10, Some("txt"), t0, 1); + } + t.compute_sizes(); + let l = compute_treemap(&t, r, Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 }, ColorMode::Extension); + assert!(l.file_rects.len() < 20, "got {}", l.file_rects.len()); + assert!(l.file_rects.iter().any(|c| c.node_id != r && t.name(c.node_id) == "big.bin")); +}