diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..da63ec2 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -553,6 +553,62 @@ mod tests { assert!(rects.iter().all(|r| r.id != 2), "depth-2 file not emitted"); } + /// A cap hides deeper rects without moving the ones that survive: the + /// capped layout is exactly the prefix of a deeper one. The UI can change + /// depth without reflowing a single rectangle. + #[test] + fn a_depth_cap_only_hides_deeper_rects() { + let tree = nested_tree(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let capped = layout(&tree, 0, vp, &capped_at(2)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8)) + .into_iter() + .filter(|r| r.depth <= 2) + .collect(); + + assert_eq!(capped, deep); + assert!(capped.iter().any(|r| r.id == 3), "depth-2 dir is emitted"); + assert!(capped.iter().all(|r| r.id != 5), "depth-3 dir is not"); + } + + /// Depth counts from the laid-out root, not the scan root — otherwise + /// drilling into a folder would leave its own children permanently out of + /// reach. + #[test] + fn depth_counts_from_the_laid_out_root() { + let tree = nested_tree(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let one = layout(&tree, 1, vp, &capped_at(1)); + assert!(one.iter().any(|r| r.id == 3), "a child of the root shows"); + assert!(one.iter().all(|r| r.id != 5), "a grandchild does not"); + } + + /// root { d1 { d2 { d3 { f_c 50 } + f_b 100 } + f_a 200 } + f_big 400 }, + /// so d1..d3 sit at depths 1..3 with f_c at 4. + fn nested_tree() -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("f_big", entry(2, 0, FILE, 400)); + b.push("d2", entry(3, 1, DIR, 0)); + b.push("f_a", entry(4, 1, FILE, 200)); + b.push("d3", entry(5, 3, DIR, 0)); + b.push("f_b", entry(6, 3, FILE, 100)); + b.push("f_c", entry(7, 5, FILE, 50)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + fn capped_at(max_depth: u8) -> TreemapOptions { + TreemapOptions { + max_depth, + ..no_padding() + } + } + #[test] fn drill_down_layouts_from_a_subdirectory() { let mut b = EntryBatch::default(); diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 1f4f8a2..16de234 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -338,6 +338,29 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } +/// The deepest the treemap lays out. Each level insets its children by +/// 2×`padding_px`, so a real viewport runs out of pixels well before this. +/// Mirrored as `LAYOUT_ALL_DEPTH` in `ui/src/lib/prefs.ts`. +const MAX_TREEMAP_DEPTH: u8 = 24; + +/// Resolves the UI's `maxDepth` preference into a layout cap, relative to the +/// laid-out root. `None` is "as deep as the layout goes" — the pre-existing +/// behaviour. +/// +/// The floor of 1 is a contract with the UI, not a nicety: at a cap of 0 no +/// rect has depth 1, and double-click, wheel and the context menu's zoom-in all +/// hit-test `depth == 1` rects, so every way in but the breadcrumbs would go +/// dead. The ceiling keeps the recursion — and the IPC payload — bounded on +/// hostile input. +fn treemap_max_depth(requested: Option) -> u8 { + requested.map_or(MAX_TREEMAP_DEPTH, |depth| { + depth.clamp(1, MAX_TREEMAP_DEPTH as u32) as u8 + }) +} + +// The argument list mirrors the UI's query; grouping it into a struct would +// only move the same list one level down. +#[allow(clippy::too_many_arguments)] #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, @@ -347,6 +370,7 @@ pub fn get_treemap( height: f32, hide_system: bool, filter: Option, + max_depth: Option, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -360,7 +384,7 @@ pub fn get_treemap( let opts = TreemapOptions { min_area_px: 3.0, padding_px: 1.0, - max_depth: 24, + max_depth: treemap_max_depth(max_depth), hide_system, }; let viewport = Viewport { @@ -964,6 +988,22 @@ mod tests { assert!(!filters_by_extension_only(None)); } + #[test] + fn a_requested_depth_is_clamped_into_the_layout_contract() { + assert_eq!(treemap_max_depth(None), MAX_TREEMAP_DEPTH); + assert_eq!(treemap_max_depth(Some(1)), 1); + assert_eq!(treemap_max_depth(Some(3)), 3); + assert_eq!( + treemap_max_depth(Some(MAX_TREEMAP_DEPTH as u32)), + MAX_TREEMAP_DEPTH + ); + + // 0 would leave the UI's depth-1 hit-tests with nothing to hit; the + // ceiling bounds recursion on input the UI would never send itself. + assert_eq!(treemap_max_depth(Some(0)), 1); + assert_eq!(treemap_max_depth(Some(u32::MAX)), MAX_TREEMAP_DEPTH); + } + #[test] fn deletes_are_blocked_while_scanning() { assert!(delete_block_reason(ScanState::Scanning, "C:\\Users\\me\\big.iso").is_some()); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..226bdc0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,11 @@ import { } from "./lib/api"; import { copyText } from "./lib/clipboard"; import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors"; +import { + type DepthPref, + loadTreemapDepth, + saveTreemapDepth, +} from "./lib/prefs"; const TREE_PANE_MIN = 320; const TREEMAP_PANE_MIN = 280; @@ -56,6 +61,7 @@ export default function App() { const [revealId, setRevealId] = useState(null); const [treeWidth, setTreeWidth] = useState(560); const [typePanelOpen, setTypePanelOpen] = useState(true); + const [maxDepth, setMaxDepth] = useState(loadTreemapDepth); const [uiError, setUiError] = useState(null); const [menu, setMenu] = useState<{ x: number; @@ -80,6 +86,10 @@ export default function App() { .catch(() => setElevation(null)); // unknown — don't nag }, []); + useEffect(() => { + saveTreemapDepth(maxDepth); + }, [maxDepth]); + useEffect(() => { let timer = 0; const off = onUiError((msg) => { @@ -359,6 +369,7 @@ export default function App() { hideSystem={scan.hideSystem} filter={scan.filter} typePanelOpen={typePanelOpen} + maxDepth={maxDepth} themePref={theme.pref} accent={theme.accent} onScan={handleScan} @@ -367,6 +378,7 @@ export default function App() { onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} + onMaxDepth={setMaxDepth} onThemePref={theme.setPref} onAccent={theme.setAccent} /> @@ -422,6 +434,7 @@ export default function App() { themeRev={theme.themeRev} hideSystem={scan.hideSystem} filter={scan.filter} + maxDepth={maxDepth} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/SettingsMenu.tsx b/ui/src/components/SettingsMenu.tsx index 211aa0f..349e03b 100644 --- a/ui/src/components/SettingsMenu.tsx +++ b/ui/src/components/SettingsMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { DEPTH_OPTIONS, type DepthPref } from "../lib/prefs"; import { ACCENTS, type AccentName, @@ -15,18 +16,22 @@ const THEME_OPTIONS: { value: ThemePref; label: string }[] = [ interface SettingsMenuProps { hideSystem: boolean; + maxDepth: DepthPref; themePref: ThemePref; accent: AccentName; onToggleHideSystem: () => void; + onMaxDepth: (depth: DepthPref) => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } export function SettingsMenu({ hideSystem, + maxDepth, themePref, accent, onToggleHideSystem, + onMaxDepth, onThemePref, onAccent, }: SettingsMenuProps) { @@ -53,8 +58,8 @@ export function SettingsMenu({
+ ))} +
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..0f23b38 100644 --- a/ui/src/components/Toolbar.tsx +++ b/ui/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import type { SearchHit } from "../lib/api"; +import type { DepthPref } from "../lib/prefs"; import type { AccentName, ThemePref } from "../lib/theme"; import { ExportMenu } from "./ExportMenu"; import { ScanMenu } from "./ScanMenu"; @@ -15,6 +16,7 @@ interface ToolbarProps { hideSystem: boolean; filter: string | null; typePanelOpen: boolean; + maxDepth: DepthPref; themePref: ThemePref; accent: AccentName; onScan: (path: string) => void; @@ -23,6 +25,7 @@ interface ToolbarProps { onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; + onMaxDepth: (depth: DepthPref) => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } @@ -35,6 +38,7 @@ export function Toolbar({ hideSystem, filter, typePanelOpen, + maxDepth, themePref, accent, onScan, @@ -43,6 +47,7 @@ export function Toolbar({ onToggleTypePanel, onSearchSelect, onApplyFilter, + onMaxDepth, onThemePref, onAccent, }: ToolbarProps) { @@ -97,9 +102,11 @@ export function Toolbar({ /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..c863ce6 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -26,16 +26,18 @@ import { import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; import { PALETTE, canvasColors } from "../lib/palette"; +import { LAYOUT_ALL_DEPTH, layoutCap } from "../lib/prefs"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; -// Grain tile for directory plates. By the layout's contract, culled children -// still consume their share of space, so every bare plate pixel is real bytes -// too small to draw — the grain makes that read as "many small files" instead -// of dead space. Drawn tiles paint over it, so it shows only where content -// was culled. +// Directory plates read two ways. Grain means the plate's children are real +// bytes too small to draw: the layout culls them but still gives them their +// share of space, and drawn tiles paint over it, so it survives only where +// content was culled. A plate at the depth cap gets no grain — its contents +// are hidden by the depth preference rather than by their size — and is +// marked as somewhere to drill into instead. const GRAIN_PITCH = 4; let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; @@ -106,6 +108,8 @@ export interface TreemapProps { hideSystem: boolean; /** Active view filter (search grammar) or null. */ filter: string | null; + /** Levels to lay out below the focused folder; null lays out all of them. */ + maxDepth: number | null; selected: number | null; hoveredId: number | null; onSelect: (rect: TreemapRect) => void; @@ -128,6 +132,7 @@ export function Treemap({ themeRev, hideSystem, filter, + maxDepth, selected, hoveredId, onSelect, @@ -141,6 +146,8 @@ export function Treemap({ const tooltipRef = useRef(null); const rectsRef = useRef([]); + // The cap `rectsRef` was laid out with; see the collapsed-plate pass in bake. + const rectsCapRef = useRef(LAYOUT_ALL_DEPTH); const byIdRef = useRef>(new Map()); const offscreenRef = useRef(null); const rootIdRef = useRef(0); @@ -166,6 +173,8 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); filterRef.current = filter; + const maxDepthRef = useRef(maxDepth); + maxDepthRef.current = maxDepth; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -244,13 +253,32 @@ export function Treemap({ ctx.fillStyle = theme.background; ctx.fillRect(0, 0, off.width, off.height); + // Grain on every plate that was expanded. By the layout's contract culled + // children still eat their share of the plate, so bare grain reads as + // "many small files" rather than as dead space. The cap comes from the + // rects' own layout, not the live preference — a re-bake can land while a + // depth change is still in flight. + const cap = rectsCapRef.current; ctx.fillStyle = ctx.createPattern( getGrainTile(theme.plate, theme.plateGrain), "repeat", )!; ctx.beginPath(); for (const r of rects) { - if (!r.isDir) continue; + if (!r.isDir || r.depth === cap) continue; + const s = snap(r, dpr, 0); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); + + // A plate *at* the cap means something else: its contents are hidden by + // the depth preference, not by their size. Paint it bare — which has to + // happen after the grain, or an ancestor's pass would texture it right + // back — and mark it further down as somewhere to drill into. + ctx.fillStyle = theme.plate; + ctx.beginPath(); + for (const r of rects) { + if (!r.isDir || r.depth !== cap) continue; const s = snap(r, dpr, 0); if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } @@ -279,6 +307,27 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } + // Bare plates would read as empty; a "+" says there is more inside. Two + // strokes rather than a glyph: text metrics blur at fractional dpr. + ctx.strokeStyle = theme.plateMark; + ctx.lineWidth = Math.max(1, Math.round(2 * dpr)); + ctx.beginPath(); + for (const r of rects) { + if (!r.isDir || r.depth !== cap) continue; + const s = snap(r, dpr, 0); + // Scales with the plate, so a small one gets a small mark rather than + // none: the plate is bare either way, and a bare plate with no mark at + // all would read as empty. + const arm = Math.min(8 * dpr, Math.min(s.w, s.h) / 3); + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + ctx.moveTo(cx - arm, cy); + ctx.lineTo(cx + arm, cy); + ctx.moveTo(cx, cy - arm); + ctx.lineTo(cx, cy + arm); + } + ctx.stroke(); + if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -327,9 +376,11 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, + maxDepthRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; + rectsCapRef.current = layoutCap(maxDepthRef.current); byIdRef.current = new Map(rects.map((r) => [r.id, r])); hitFrozenRef.current = false; setHasRects(rects.length > 0); @@ -421,9 +472,12 @@ export function Treemap({ bake(); // repaint the baked layout with the new theme's canvas colors }, [themeRev, bake]); + // Depth only drops rects the cap hides; the ones that stay keep their exact + // geometry (see the treemap layout's cap test), so there is nothing to freeze + // hit-testing against while the new layout is in flight. useEffect(() => { void fetchLayout(); - }, [hideSystem, filter, fetchLayout]); + }, [hideSystem, filter, maxDepth, fetchLayout]); const prevStateRef = useRef(undefined); useEffect(() => { diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..9ed3c1a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -11,6 +11,7 @@ --color-edge-strong: #3f3f46; --color-plate: #30323a; /* treemap directory plate */ --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ + --color-plate-mark: #8b93a7; /* "+" on a plate the depth cap collapsed */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -46,6 +47,7 @@ --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; --color-plate-grain: #bfc0c8; + --color-plate-mark: #6b7280; /* ≥3:1 on the plate: the mark carries meaning */ --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3a7e806..ae7937b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -159,6 +159,7 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, + maxDepth: number | null, ) => invoke("get_treemap", { generation, @@ -167,6 +168,7 @@ export const api = { height, hideSystem, filter, + maxDepth, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..4f0dd96 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -22,6 +22,7 @@ export function canvasColors() { background: v("--color-app"), plate: v("--color-plate"), plateGrain: v("--color-plate-grain"), + plateMark: v("--color-plate-mark"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; diff --git a/ui/src/lib/prefs.ts b/ui/src/lib/prefs.ts new file mode 100644 index 0000000..963cd88 --- /dev/null +++ b/ui/src/lib/prefs.ts @@ -0,0 +1,46 @@ +// View preferences that outlive a scan. Values the back end also has to agree +// on are mirrored here, same as palette.ts mirrors mathom-core's categories. + +/** Mirrors `MAX_TREEMAP_DEPTH` in src-tauri/src/scan.rs. */ +export const LAYOUT_ALL_DEPTH = 24; + +/** Levels the treemap expands below the focused folder; `null` is "all". */ +export type DepthPref = number | null; + +const DEPTH_KEY = "mathom:treemapDepth"; + +/** Levels the treemap can be pinned to; the settings menu offers exactly these. */ +const DEPTHS = [1, 2, 3]; + +export const DEPTH_OPTIONS: { value: DepthPref; label: string }[] = [ + { value: null, label: "All" }, + ...DEPTHS.map((depth) => ({ value: depth, label: String(depth) })), +]; + +/** + * Total, and its domain is exactly the options above: a stored level that no + * button can show would leave the control with nothing selected, so anything + * else reads as "all". + */ +export function parseDepth(raw: string | null): DepthPref { + const n = Number(raw); + return DEPTHS.includes(n) ? n : null; +} + +/** + * The cap the back end lays out for `depth`, mirroring its own clamp. The + * treemap needs it to tell a plate the cap collapsed from one whose children + * were merely culled by size, and that reading has to match the layout. + */ +export function layoutCap(depth: DepthPref): number { + if (depth === null) return LAYOUT_ALL_DEPTH; + return Math.min(Math.max(depth, 1), LAYOUT_ALL_DEPTH); +} + +export function loadTreemapDepth(): DepthPref { + return parseDepth(localStorage.getItem(DEPTH_KEY)); +} + +export function saveTreemapDepth(depth: DepthPref) { + localStorage.setItem(DEPTH_KEY, depth === null ? "all" : String(depth)); +}