From 2716fdd081d986ada894f3135966057b76db3d0a Mon Sep 17 00:00:00 2001 From: gitRasheed Date: Wed, 23 Sep 2026 19:07:28 +0100 Subject: [PATCH 1/4] Type panel percentages add up to the rows shown Percentages divided by the filtered total, so with a type filter active a row could read above 100%. They now divide by the sum of the displayed rows, and emptiness is judged from those rows too. The active ext: list normalizes dots and case the way the Rust parser does, so shift-clicking .pdf while ext:.PDF is active removes it instead of adding a duplicate. --- src-tauri/src/scan.rs | 6 +----- ui/src/App.tsx | 8 +++++++- ui/src/components/TypePanel.tsx | 13 ++++++++----- ui/src/lib/api.ts | 2 -- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 1f4f8a2..e7f3d38 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -399,8 +399,6 @@ pub struct TypeStatDto { #[serde(rename_all = "camelCase")] pub struct TypePanelData { types: Vec, - total_bytes: u64, - total_files: u64, top_files: Vec, } @@ -424,7 +422,7 @@ pub fn get_type_stats( let overlay = overlay_for(&session, tree, filter.as_deref(), hide_system); let visible = overlay.as_deref().map(|o| o.visible.as_slice()); let filtered = mathom_core::stats::type_breakdown(tree, root_id, hide_system, visible); - let (total_bytes, total_files) = (filtered.total_bytes, filtered.total_files); + let total_bytes = filtered.total_bytes; let top_files = mathom_core::stats::largest_files(tree, root_id, TOP_FILES, hide_system, visible) .into_iter() @@ -445,8 +443,6 @@ pub fn get_type_stats( files: t.files, }) .collect(), - total_bytes, - total_files, top_files, }) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..1703d05 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -201,7 +201,13 @@ export default function App() { // Only a pure `ext:` filter is treated as the active type list; other queries start fresh. const { filter } = scan; const activeExts = useMemo( - () => filter?.match(/^ext:([^\s]+)$/)?.[1].split(",") ?? [], + () => + filter + ?.trim() + .match(/^ext:([^\s]+)$/)?.[1] + .split(",") + .map((ext) => ext.replace(/^\.+/, "").toLowerCase()) + .filter(Boolean) ?? [], [filter], ); diff --git a/ui/src/components/TypePanel.tsx b/ui/src/components/TypePanel.tsx index 8a948b9..86f0b83 100644 --- a/ui/src/components/TypePanel.tsx +++ b/ui/src/components/TypePanel.tsx @@ -107,7 +107,7 @@ export function TypePanel({ Appears during a scan ) : null - ) : data.totalFiles === 0 ? ( + ) : data.types.length === 0 ? (
No files here
) : ( sum + type.bytes, + 0, + ), activeExts, canFilter, onFilterType, @@ -153,7 +156,7 @@ export function TypePanel({ interface TypeRowsProps { types: TypeStat[]; - totalBytes: number; + displayedBytes: number; activeExts: string[]; canFilter: boolean; onFilterType: (ext: string, additive: boolean) => void; @@ -163,7 +166,7 @@ function TypeRow({ index, style, types, - totalBytes, + displayedBytes, activeExts, canFilter, onFilterType, @@ -202,7 +205,7 @@ function TypeRow({ {formatBytes(t.bytes)} - {formatPercent(totalBytes > 0 ? t.bytes / totalBytes : 0)} + {formatPercent(displayedBytes > 0 ? t.bytes / displayedBytes : 0)} ); diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3a7e806..dd3b149 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -81,8 +81,6 @@ export interface TypeStat { export interface TypePanelData { /** Every extension in the subtree, largest first. */ types: TypeStat[]; - totalBytes: number; - totalFiles: number; topFiles: Row[]; } From 882e16742ce72435879459b4fd5e55aef5bfe6a1 Mon Sep 17 00:00:00 2001 From: gitRasheed <99349713+gitRasheed@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:42:03 +0000 Subject: [PATCH 2/4] File names decode through as_chunks Current stable clippy rejects chunks_exact with a constant size. --- crates/scanner-ntfs/src/record.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/scanner-ntfs/src/record.rs b/crates/scanner-ntfs/src/record.rs index aefd6a3..814ba4b 100644 --- a/crates/scanner-ntfs/src/record.rs +++ b/crates/scanner-ntfs/src/record.rs @@ -273,8 +273,10 @@ fn file_name(attr: &[u8], arena: &mut String, facts: &mut RecordFacts) -> Result if facts.name.is_none_or(|n| rank < n.rank) { let off = arena.len() as u32; let units = v[0x42..0x42 + 2 * chars] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])); + .as_chunks::<2>() + .0 + .iter() + .map(|&c| u16::from_le_bytes(c)); for r in char::decode_utf16(units) { arena.push(r.unwrap_or(char::REPLACEMENT_CHARACTER)); } From 1d8d3ea22711bddddea13a845cd7f4d3edf58de0 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:21:17 +0800 Subject: [PATCH 3/4] The treemap lays out what fits instead of everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A treemap that draws every file it can subdivide is a mosaic: on a real drive most of the pixels are specks too small to identify, and finding out what any of them is costs a hover, one block at a time. That is the whole of the "visual overload" complaint, and no depth setting fixes it — the setting the issue asked for decides how much of the tree is drawn, not whether what is drawn can be read. Three changes, all in the layout: Culling is by side, not area. `min_area_px: 3.0` accepted a 40×0.1 sliver because 4 px² cleared the floor; `min_side_px` requires both dimensions, so what survives is something a person can actually see. Culled children keep eating their share of the parent, as before — that contract is what makes the parent's area honest, and the test that pinned it still does. A directory reserves a strip at the top of its interior for its label, and its children tile what is left. The strip is taken from the *children's* frame and never from the directory's own rect: that rect has to stay a function of its own frame alone, or a depth cap that stops recursion early would shift the rects that survive it, and the UI switches depth live. The test that pins that prefix property runs with a strip on. Depth became adaptive. A directory subdivides only while its interior can still hold a legible child — padding, strip and padding again come out of every level, so a branch runs out of room and stops on its own. Big folders go deep because they have the pixels; small ones stay a plate. max_depth is now only an upper bound on top of that, which leaves room for the explicit depth preference to land on top of it without fighting it. The rect carries the text too. Names come from `tree.name(id)` at the DTO boundary rather than through `TreemapRect`, which stays `Copy` and geometry-only for the layout's own tests; one string per visible rect is also far cheaper than the round trip per node it would otherwise take to label a block. --- crates/core/src/treemap.rs | 194 ++++++++++++++++++++++++++++++++++--- src-tauri/src/scan.rs | 23 ++++- 2 files changed, 203 insertions(+), 14 deletions(-) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..a51fbfc 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -2,7 +2,8 @@ //! //! Rects are emitted parents-before-children: forward iteration is painter's //! order for drawing, reverse is deepest-first for hit-testing. Children -//! below `min_area_px` are culled but still consume their share of space. +//! below `min_side_px` are culled but still consume their share of space, and +//! a directory only subdivides while its own interior can hold a legible one. use crate::category::categorize; use crate::entry::EntryFlags; @@ -16,8 +17,16 @@ pub struct Viewport { #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { - pub min_area_px: f32, + /// Legibility floor: a child is drawn only if it is at least this wide + /// *and* this tall. An area test alone lets a 40×0.1 sliver through, which + /// is how the map ends up as a mosaic of specks nothing can be read from. + /// Culled children still consume their share of the parent. + pub min_side_px: f32, pub padding_px: f32, + /// Vertical strip a directory reserves at the top of its interior for its + /// label; zero reserves nothing. Only the *children's* frame shrinks — + /// see `emit` for why a directory's own rect must not depend on this. + pub label_px: f32, pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, @@ -26,8 +35,9 @@ pub struct TreemapOptions { impl Default for TreemapOptions { fn default() -> Self { TreemapOptions { - min_area_px: 1.0, + min_side_px: 1.0, padding_px: 1.0, + label_px: 0.0, max_depth: 32, hide_system: false, } @@ -161,6 +171,15 @@ impl Frame { h: self.h - 2.0 * pad, } } + + /// Takes `pad` off the top edge alone: the strip a label sits in. + fn below(&self, pad: f64) -> Frame { + Frame { + y: self.y + pad, + h: self.h - pad, + ..*self + } + } } fn emit( @@ -186,11 +205,26 @@ fn emit( if !node.is_dir() || depth >= opts.max_depth { return; } + // Everything below changes only the *children's* frame. The rect pushed + // above must stay a function of this directory's own frame and nothing + // else, or a cap that stops recursion early would move the rects that + // survive it — the UI switches depth live and relies on them holding + // still. let inner = frame.inset(opts.padding_px as f64); if inner.w <= 0.0 || inner.h <= 0.0 { return; } - lay_children(tree, id, inner, depth + 1, opts, visible, out); + let body = inner.below(opts.label_px as f64); + // Adaptive depth: subdivide only where a child could still be legible. + // Going one level deeper costs padding, a label strip and the padding + // again, so a branch runs out of room on its own and stops — which is + // what keeps a big folder from dissolving into specks, and a small one + // from showing levels nobody can see. `max_depth` is only an upper bound + // on top of that. + if body.w < opts.min_side_px as f64 || body.h < opts.min_side_px as f64 { + return; + } + lay_children(tree, id, body, depth + 1, opts, visible, out); } fn lay_children( @@ -214,7 +248,7 @@ fn lay_children( let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); let scale = frame.area() / total; - let min_area = opts.min_area_px as f64; + let min_side = opts.min_side_px as f64; let mut remaining = frame; let mut i = 0; @@ -248,7 +282,7 @@ fn lay_children( sum, &mut remaining, depth, - min_area, + min_side, opts, visible, out, @@ -271,7 +305,7 @@ fn lay_row( row_area: f64, remaining: &mut Frame, depth: u8, - min_area: f64, + min_side: f64, opts: &TreemapOptions, visible: Option<&[u64]>, out: &mut Vec, @@ -303,7 +337,7 @@ fn lay_row( } }; offset += len; - if frame.area() >= min_area { + if frame.w >= min_side && frame.h >= min_side { emit(tree, id, frame, depth, opts, visible, out); } } @@ -350,10 +384,13 @@ mod tests { builder.finish() } + /// Nothing culled, nothing reserved: the pure squarified geometry the + /// exact-area assertions below are written against. fn no_padding() -> TreemapOptions { TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 0.0, + label_px: 0.0, max_depth: 32, hide_system: false, } @@ -485,8 +522,9 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 2.0, + label_px: 0.0, max_depth: 32, hide_system: false, }; @@ -502,11 +540,13 @@ mod tests { #[test] fn tiny_children_are_culled_without_inflating_the_rest() { - // 1,000,000 vs 1: in 100×100 the small file gets ~0.01 px² < 1 px². + // 1,000,000 vs 1: in 100×100 the small file gets a 100×0.0001 sliver, + // too thin to be legible at any width. let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); let opts = TreemapOptions { - min_area_px: 1.0, + min_side_px: 1.0, padding_px: 0.0, + label_px: 0.0, max_depth: 32, hide_system: false, }; @@ -518,6 +558,133 @@ mod tests { assert!((area(&big) - 10_000.0 * (1_000_000.0 / 1_000_001.0)).abs() < 1.0); } + /// The floor is a side, not an area: 10,000 vs 1 in 100×100 gives the small + /// file a 100×0.01 slice worth a whole square pixel, which the old area + /// test happily drew as an invisible line. + #[test] + fn a_sliver_is_culled_even_though_its_area_clears_the_floor() { + let tree = flat_tree(&[("big", 10_000), ("thin", 1)]); + let opts = TreemapOptions { + min_side_px: 2.0, + padding_px: 0.0, + label_px: 0.0, + max_depth: 32, + hide_system: false, + }; + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); + + assert!(rects.iter().all(|r| r.id != 2), "sliver must be culled"); + assert!((area(&rect_of(&rects, 1)) - 10_000.0 * (10_000.0 / 10_001.0)).abs() < 1.0); + } + + /// Each level takes its strip out of its own children's frame, so a nested + /// directory starts one strip lower than its parent did. That compounding + /// is what makes depth cost pixels — and why it stops on its own. + #[test] + fn every_level_takes_its_label_strip_from_its_children() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("d2", entry(2, 1, DIR, 0)); + b.push("f", entry(3, 2, FILE, 100)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &labelled(10.0)); + + // The root's own rect is the whole viewport: the strip is taken below + // it, not out of it. + assert!((rect_of(&rects, 0).y).abs() < 0.01); + assert!((rect_of(&rects, 0).h - 100.0).abs() < 0.01); + for (id, top, height) in [(1u32, 10.0, 90.0), (2, 20.0, 80.0), (3, 30.0, 70.0)] { + let r = rect_of(&rects, id); + assert!((r.y - top).abs() < 0.01, "id {id} starts a strip lower"); + assert!( + (r.h - height).abs() < 0.01, + "id {id} lost a strip off its height" + ); + assert!((r.w - 100.0).abs() < 0.01, "a strip is vertical"); + } + } + + /// Depth changes stay live because a capped layout is a prefix of a deeper + /// one. The strip is the one addition that could break it — it has to + /// shrink frames the cap then hides anyway, never the rect a shallower + /// layout keeps. + #[test] + fn a_label_strip_does_not_move_the_rects_a_cap_leaves_behind() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("big", entry(2, 0, FILE, 400)); + b.push("d2", entry(3, 1, DIR, 0)); + b.push("f", entry(4, 1, FILE, 200)); + b.push("g", entry(5, 3, FILE, 100)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let capped = layout(&tree, 0, vp, &capped_at(2, 10.0)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8, 10.0)) + .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 file is not"); + } + + /// Depth follows the pixels available: the same subtree expands when it has + /// room and stops when it does not, with `max_depth` nowhere near either. + #[test] + fn a_directory_stops_expanding_when_its_interior_is_too_small() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("wide", entry(1, 0, FILE, 800)); + b.push("mid", entry(2, 0, DIR, 0)); + b.push("deep", entry(3, 2, FILE, 200)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let opts = TreemapOptions { + min_side_px: 25.0, + ..labelled(10.0) + }; + + // A 40px-tall viewport leaves `mid` 30px, and its own strip takes 10: + // the 20px left cannot hold a child, so it stays a plain plate. + let cramped = layout(&tree, 0, Viewport { w: 200.0, h: 40.0 }, &opts); + assert!(cramped.iter().any(|r| r.id == 2), "the dir itself is drawn"); + assert!( + cramped.iter().all(|r| r.id != 3), + "its interior cannot hold a legible child" + ); + + // The same tree given more height expands, and nobody raised a cap. + let roomy = layout(&tree, 0, Viewport { w: 200.0, h: 100.0 }, &opts); + assert!(roomy.iter().any(|r| r.id == 3), "now there is room"); + assert!(opts.max_depth > 1, "the cap never came into it"); + } + + fn labelled(label_px: f32) -> TreemapOptions { + TreemapOptions { + label_px, + ..no_padding() + } + } + + fn capped_at(max_depth: u8, label_px: f32) -> TreemapOptions { + TreemapOptions { + max_depth, + label_px, + ..no_padding() + } + } + #[test] fn zero_size_children_emit_nothing_and_nothing_is_nan() { let tree = flat_tree(&[("empty", 0), ("real", 10)]); @@ -542,8 +709,9 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_area_px: 0.0, + min_side_px: 0.0, padding_px: 0.0, + label_px: 0.0, max_depth: 1, hide_system: false, }; diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index e7f3d38..c70103c 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -168,6 +168,9 @@ pub struct TreemapRectDto { depth: u8, is_dir: bool, category: u8, + /// What the block is called; the canvas draws it inside the rect. + name: String, + size: u64, } #[derive(Clone, Serialize)] @@ -338,6 +341,17 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } +/// Legibility floor for a treemap rect, in CSS pixels: below this on either +/// side the block is not drawn, so the map never fills with specks. Mirrored +/// as `TREEMAP_MIN_SIDE_PX` in `ui/src/lib/prefs.ts`. +const TREEMAP_MIN_SIDE_PX: f32 = 6.0; + +/// The strip a directory reserves at the top of its interior for its label — +/// and therefore the height the UI has to draw that label in. Mirrored as +/// `TREEMAP_LABEL_PX` in `ui/src/lib/prefs.ts`: the two have to agree or the +/// text lands on the children instead of above them. +const TREEMAP_LABEL_PX: f32 = 15.0; + #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, @@ -358,8 +372,9 @@ pub fn get_treemap( return Ok(Vec::new()); } let opts = TreemapOptions { - min_area_px: 3.0, + min_side_px: TREEMAP_MIN_SIDE_PX, padding_px: 1.0, + label_px: TREEMAP_LABEL_PX, max_depth: 24, hide_system, }; @@ -371,6 +386,10 @@ pub fn get_treemap( Some(o) => treemap::layout_with_filter(tree, root_id, viewport, &opts, &o.bytes), None => treemap::layout(tree, root_id, viewport, &opts), }; + // The label's text rides along with the geometry: a rect the user can see + // is a rect they can read, and asking per node would be one round trip per + // rectangle. The culling above is what keeps the payload honest — what is + // sent is roughly what fits on screen, not the whole subtree. Ok(rects .into_iter() .map(|r| TreemapRectDto { @@ -382,6 +401,8 @@ pub fn get_treemap( depth: r.depth, is_dir: r.is_dir, category: r.category, + name: tree.name(r.id).to_string(), + size: tree.node(r.id).size, }) .collect()) } From 5850f2cc63ecf72ffd0e9869321346b965d702f1 Mon Sep 17 00:00:00 2001 From: gitRasheed <99349713+gitRasheed@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:00:22 +0000 Subject: [PATCH 4/4] The treemap can draw a few levels at a time Settings gets Treemap style (Default or SpaceSniffer) and Treemap depth (All, 1, 2 or custom). Default with All is the old treemap. This replaces the fit-based layout from the previous commit, so the default stays as it was. With a depth cap, folders below the cap draw as closed, labelled blocks. In SpaceSniffer style a click opens a folder in place and folds the one opened before; Default still zooms. Changing the view folds everything back to the cap. A clicked folder whose contents are too small to draw zooms instead of opening to nothing. SpaceSniffer has no All, since a map with every level drawn has nothing to open; it reads a stored All as 1 so switching back to Default restores it. The layout takes one node to keep open past the cap and reports which folders had their children drawn, so the UI never infers that from rect order. Opening a folder only adds rects; nothing already drawn moves. Hovering outlines the folder the block sits in. The Export menu shares the depth picker. --- crates/core/src/lib.rs | 2 +- crates/core/src/treemap.rs | 659 +++++++++++++++-------------- src-tauri/src/scan.rs | 60 ++- ui/src/App.tsx | 26 +- ui/src/components/DepthPicker.tsx | 71 ++++ ui/src/components/ExportMenu.tsx | 57 +-- ui/src/components/SettingsMenu.tsx | 57 +++ ui/src/components/Toolbar.tsx | 7 + ui/src/components/Treemap.tsx | 150 +++++-- ui/src/index.css | 2 + ui/src/lib/api.ts | 33 +- ui/src/lib/palette.ts | 1 + ui/src/lib/prefs.ts | 35 ++ 13 files changed, 707 insertions(+), 453 deletions(-) create mode 100644 ui/src/components/DepthPicker.tsx create mode 100644 ui/src/lib/prefs.ts diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5c17928..105dadf 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,4 +13,4 @@ pub mod treemap; pub use category::{CATEGORY_COUNT, Category, categorize}; pub use entry::{EntryBatch, EntryFlags, FileEntry}; pub use tree::{Node, NodeId, Removed, Tree, TreeBuilder}; -pub use treemap::{TreemapOptions, TreemapRect, Viewport}; +pub use treemap::{MAX_LAYOUT_DEPTH, TreemapOptions, TreemapRect, Viewport}; diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index a51fbfc..2a97275 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -2,8 +2,7 @@ //! //! Rects are emitted parents-before-children: forward iteration is painter's //! order for drawing, reverse is deepest-first for hit-testing. Children -//! below `min_side_px` are culled but still consume their share of space, and -//! a directory only subdivides while its own interior can hold a legible one. +//! below `min_area_px` are culled but still consume their share of space. use crate::category::categorize; use crate::entry::EntryFlags; @@ -15,31 +14,28 @@ pub struct Viewport { pub h: f32, } +/// The deepest level any layout draws: tree depth is unbounded and `emit` recurses. +pub const MAX_LAYOUT_DEPTH: u8 = 24; + #[derive(Clone, Copy, Debug)] pub struct TreemapOptions { - /// Legibility floor: a child is drawn only if it is at least this wide - /// *and* this tall. An area test alone lets a 40×0.1 sliver through, which - /// is how the map ends up as a mosaic of specks nothing can be read from. - /// Culled children still consume their share of the parent. - pub min_side_px: f32, + pub min_area_px: f32, pub padding_px: f32, - /// Vertical strip a directory reserves at the top of its interior for its - /// label; zero reserves nothing. Only the *children's* frame shrinks — - /// see `emit` for why a directory's own rect must not depend on this. - pub label_px: f32, pub max_depth: u8, /// Omit SYSTEM entries and proportion tiles by visible bytes. pub hide_system: bool, + /// Kept visible past `max_depth`: its ancestors draw open, and so does it if a directory. + pub reveal: Option, } impl Default for TreemapOptions { fn default() -> Self { TreemapOptions { - min_side_px: 1.0, + min_area_px: 1.0, padding_px: 1.0, - label_px: 0.0, - max_depth: 32, + max_depth: MAX_LAYOUT_DEPTH, hide_system: false, + reveal: None, } } } @@ -54,6 +50,8 @@ pub struct TreemapRect { pub depth: u8, pub is_dir: bool, pub category: u8, + /// A directory whose children were laid out; false for files. + pub expanded: bool, } /// Lays out any directory subtree into `viewport`. @@ -107,10 +105,38 @@ fn layout_impl( } None => None, }; - emit(tree, root, frame, 0, opts, visible, &mut out); + let pass = Pass { + tree, + opts, + visible, + max_depth: opts.max_depth.min(MAX_LAYOUT_DEPTH), + reveal_path: reveal_path(tree, root, opts.reveal), + }; + pass.emit(root, frame, 0, &mut out); out } +/// Root first, so index `d` is the node revealed at depth `d`. Empty unless +/// `reveal` is live and under `root`. +fn reveal_path(tree: &Tree, root: NodeId, reveal: Option) -> Vec { + let Some(reveal) = reveal.filter(|&id| tree.is_live(id)) else { + return Vec::new(); + }; + let mut path = vec![reveal]; + let mut cur = reveal; + while cur != root { + match tree.node(cur).parent() { + Some(parent) => { + path.push(parent); + cur = parent; + } + None => return Vec::new(), + } + } + path.reverse(); + path +} + /// Fills `visible` with subtree sizes that omit SYSTEM entries, for the /// subtree under `root` only. SYSTEM subtrees are never visited, so `visible` /// must arrive zeroed. Explicit stack: tree depth is unbounded. @@ -171,186 +197,149 @@ impl Frame { h: self.h - 2.0 * pad, } } - - /// Takes `pad` off the top edge alone: the strip a label sits in. - fn below(&self, pad: f64) -> Frame { - Frame { - y: self.y + pad, - h: self.h - pad, - ..*self - } - } } -fn emit( - tree: &Tree, - id: NodeId, - frame: Frame, - depth: u8, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let node = tree.node(id); - out.push(TreemapRect { - id, - x: frame.x as f32, - y: frame.y as f32, - w: frame.w as f32, - h: frame.h as f32, - depth, - is_dir: node.is_dir(), - category: categorize(tree.name(id), node.is_dir()) as u8, - }); - if !node.is_dir() || depth >= opts.max_depth { - return; - } - // Everything below changes only the *children's* frame. The rect pushed - // above must stay a function of this directory's own frame and nothing - // else, or a cap that stops recursion early would move the rects that - // survive it — the UI switches depth live and relies on them holding - // still. - let inner = frame.inset(opts.padding_px as f64); - if inner.w <= 0.0 || inner.h <= 0.0 { - return; - } - let body = inner.below(opts.label_px as f64); - // Adaptive depth: subdivide only where a child could still be legible. - // Going one level deeper costs padding, a label strip and the padding - // again, so a branch runs out of room on its own and stops — which is - // what keeps a big folder from dissolving into specks, and a small one - // from showing levels nobody can see. `max_depth` is only an upper bound - // on top of that. - if body.w < opts.min_side_px as f64 || body.h < opts.min_side_px as f64 { - return; - } - lay_children(tree, id, body, depth + 1, opts, visible, out); +struct Pass<'a> { + tree: &'a Tree, + opts: &'a TreemapOptions, + visible: Option<&'a [u64]>, + max_depth: u8, + reveal_path: Vec, } -fn lay_children( - tree: &Tree, - dir: NodeId, - frame: Frame, - depth: u8, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let mut items: Vec<(NodeId, u64)> = tree - .children(dir) - .map(|c| (c, effective_size(tree, c, visible))) - .filter(|&(_, size)| size > 0) - .collect(); - if items.is_empty() { - return; - } - items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - - let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); - let scale = frame.area() / total; - let min_side = opts.min_side_px as f64; - - let mut remaining = frame; - let mut i = 0; - while i < items.len() { - if remaining.w <= 0.0 || remaining.h <= 0.0 { +impl Pass<'_> { + fn emit(&self, id: NodeId, frame: Frame, depth: u8, out: &mut Vec) { + let node = self.tree.node(id); + let index = out.len(); + out.push(TreemapRect { + id, + x: frame.x as f32, + y: frame.y as f32, + w: frame.w as f32, + h: frame.h as f32, + depth, + is_dir: node.is_dir(), + category: categorize(self.tree.name(id), node.is_dir()) as u8, + expanded: false, + }); + let limit = if self.reveal_path.get(depth as usize) == Some(&id) { + MAX_LAYOUT_DEPTH + } else { + self.max_depth + }; + if !node.is_dir() || depth >= limit { return; } - let side = remaining.w.min(remaining.h); - - let first = items[i].1 as f64 * scale; - let (mut sum, mut max, mut min) = (first, first, first); - let mut worst = worst_aspect(sum, max, min, side); - let mut j = i + 1; - while j < items.len() { - let a = items[j].1 as f64 * scale; - let candidate = worst_aspect(sum + a, max.max(a), min.min(a), side); - if candidate > worst { - break; - } - worst = candidate; - sum += a; - max = max.max(a); - min = min.min(a); - j += 1; + let inner = frame.inset(self.opts.padding_px as f64); + if inner.w <= 0.0 || inner.h <= 0.0 { + return; } - - lay_row( - tree, - &items[i..j], - scale, - sum, - &mut remaining, - depth, - min_side, - opts, - visible, - out, - ); - i = j; + self.lay_children(id, inner, depth + 1, out); + out[index].expanded = out.len() > index + 1; } -} -fn worst_aspect(sum: f64, max: f64, min: f64, side: f64) -> f64 { - let s2 = sum * sum; - let w2 = side * side; - (w2 * max / s2).max(s2 / (w2 * min)) -} + fn lay_children(&self, dir: NodeId, frame: Frame, depth: u8, out: &mut Vec) { + let mut items: Vec<(NodeId, u64)> = self + .tree + .children(dir) + .map(|c| (c, effective_size(self.tree, c, self.visible))) + .filter(|&(_, size)| size > 0) + .collect(); + if items.is_empty() { + return; + } + items.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); -#[allow(clippy::too_many_arguments)] -fn lay_row( - tree: &Tree, - row: &[(NodeId, u64)], - scale: f64, - row_area: f64, - remaining: &mut Frame, - depth: u8, - min_side: f64, - opts: &TreemapOptions, - visible: Option<&[u64]>, - out: &mut Vec, -) { - let horizontal = remaining.w < remaining.h; // row spans the full width - let side = if horizontal { remaining.w } else { remaining.h }; - let thickness = (row_area / side).min(if horizontal { remaining.h } else { remaining.w }); - - let mut offset = 0.0; - for (k, &(id, size)) in row.iter().enumerate() { - let len = if k == row.len() - 1 { - side - offset - } else { - (size as f64 * scale) / thickness - }; - let frame = if horizontal { - Frame { - x: remaining.x + offset, - y: remaining.y, - w: len, - h: thickness, + let total: f64 = items.iter().map(|&(_, s)| s as f64).sum(); + let scale = frame.area() / total; + + let mut remaining = frame; + let mut i = 0; + while i < items.len() { + if remaining.w <= 0.0 || remaining.h <= 0.0 { + return; } - } else { - Frame { - x: remaining.x, - y: remaining.y + offset, - w: thickness, - h: len, + let side = remaining.w.min(remaining.h); + + let first = items[i].1 as f64 * scale; + let (mut sum, mut max, mut min) = (first, first, first); + let mut worst = worst_aspect(sum, max, min, side); + let mut j = i + 1; + while j < items.len() { + let a = items[j].1 as f64 * scale; + let candidate = worst_aspect(sum + a, max.max(a), min.min(a), side); + if candidate > worst { + break; + } + worst = candidate; + sum += a; + max = max.max(a); + min = min.min(a); + j += 1; } - }; - offset += len; - if frame.w >= min_side && frame.h >= min_side { - emit(tree, id, frame, depth, opts, visible, out); + + self.lay_row(&items[i..j], scale, sum, &mut remaining, depth, out); + i = j; } } - if horizontal { - remaining.y += thickness; - remaining.h -= thickness; - } else { - remaining.x += thickness; - remaining.w -= thickness; + fn lay_row( + &self, + row: &[(NodeId, u64)], + scale: f64, + row_area: f64, + remaining: &mut Frame, + depth: u8, + out: &mut Vec, + ) { + let horizontal = remaining.w < remaining.h; // row spans the full width + let side = if horizontal { remaining.w } else { remaining.h }; + let thickness = (row_area / side).min(if horizontal { remaining.h } else { remaining.w }); + + let mut offset = 0.0; + for (k, &(id, size)) in row.iter().enumerate() { + let len = if k == row.len() - 1 { + side - offset + } else { + (size as f64 * scale) / thickness + }; + let frame = if horizontal { + Frame { + x: remaining.x + offset, + y: remaining.y, + w: len, + h: thickness, + } + } else { + Frame { + x: remaining.x, + y: remaining.y + offset, + w: thickness, + h: len, + } + }; + offset += len; + if frame.area() >= self.opts.min_area_px as f64 { + self.emit(id, frame, depth, out); + } + } + + if horizontal { + remaining.y += thickness; + remaining.h -= thickness; + } else { + remaining.x += thickness; + remaining.w -= thickness; + } } } +fn worst_aspect(sum: f64, max: f64, min: f64, side: f64) -> f64 { + let s2 = sum * sum; + let w2 = side * side; + (w2 * max / s2).max(s2 / (w2 * min)) +} + #[cfg(test)] mod tests { use super::*; @@ -384,15 +373,11 @@ mod tests { builder.finish() } - /// Nothing culled, nothing reserved: the pure squarified geometry the - /// exact-area assertions below are written against. fn no_padding() -> TreemapOptions { TreemapOptions { - min_side_px: 0.0, + min_area_px: 0.0, padding_px: 0.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, + ..Default::default() } } @@ -522,11 +507,8 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_side_px: 0.0, padding_px: 2.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -540,15 +522,11 @@ mod tests { #[test] fn tiny_children_are_culled_without_inflating_the_rest() { - // 1,000,000 vs 1: in 100×100 the small file gets a 100×0.0001 sliver, - // too thin to be legible at any width. + // 1,000,000 vs 1: in 100×100 the small file gets ~0.01 px² < 1 px². let tree = flat_tree(&[("big", 1_000_000), ("tiny", 1)]); let opts = TreemapOptions { - min_side_px: 1.0, - padding_px: 0.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, + min_area_px: 1.0, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); @@ -558,133 +536,6 @@ mod tests { assert!((area(&big) - 10_000.0 * (1_000_000.0 / 1_000_001.0)).abs() < 1.0); } - /// The floor is a side, not an area: 10,000 vs 1 in 100×100 gives the small - /// file a 100×0.01 slice worth a whole square pixel, which the old area - /// test happily drew as an invisible line. - #[test] - fn a_sliver_is_culled_even_though_its_area_clears_the_floor() { - let tree = flat_tree(&[("big", 10_000), ("thin", 1)]); - let opts = TreemapOptions { - min_side_px: 2.0, - padding_px: 0.0, - label_px: 0.0, - max_depth: 32, - hide_system: false, - }; - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); - - assert!(rects.iter().all(|r| r.id != 2), "sliver must be culled"); - assert!((area(&rect_of(&rects, 1)) - 10_000.0 * (10_000.0 / 10_001.0)).abs() < 1.0); - } - - /// Each level takes its strip out of its own children's frame, so a nested - /// directory starts one strip lower than its parent did. That compounding - /// is what makes depth cost pixels — and why it stops on its own. - #[test] - fn every_level_takes_its_label_strip_from_its_children() { - let mut b = EntryBatch::default(); - b.push("root", entry(0, 0, DIR, 0)); - b.push("d1", entry(1, 0, DIR, 0)); - b.push("d2", entry(2, 1, DIR, 0)); - b.push("f", entry(3, 2, FILE, 100)); - let mut builder = TreeBuilder::new(); - builder.add_batch(&b); - let tree = builder.finish(); - - let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &labelled(10.0)); - - // The root's own rect is the whole viewport: the strip is taken below - // it, not out of it. - assert!((rect_of(&rects, 0).y).abs() < 0.01); - assert!((rect_of(&rects, 0).h - 100.0).abs() < 0.01); - for (id, top, height) in [(1u32, 10.0, 90.0), (2, 20.0, 80.0), (3, 30.0, 70.0)] { - let r = rect_of(&rects, id); - assert!((r.y - top).abs() < 0.01, "id {id} starts a strip lower"); - assert!( - (r.h - height).abs() < 0.01, - "id {id} lost a strip off its height" - ); - assert!((r.w - 100.0).abs() < 0.01, "a strip is vertical"); - } - } - - /// Depth changes stay live because a capped layout is a prefix of a deeper - /// one. The strip is the one addition that could break it — it has to - /// shrink frames the cap then hides anyway, never the rect a shallower - /// layout keeps. - #[test] - fn a_label_strip_does_not_move_the_rects_a_cap_leaves_behind() { - let mut b = EntryBatch::default(); - b.push("root", entry(0, 0, DIR, 0)); - b.push("d1", entry(1, 0, DIR, 0)); - b.push("big", entry(2, 0, FILE, 400)); - b.push("d2", entry(3, 1, DIR, 0)); - b.push("f", entry(4, 1, FILE, 200)); - b.push("g", entry(5, 3, FILE, 100)); - let mut builder = TreeBuilder::new(); - builder.add_batch(&b); - let tree = builder.finish(); - let vp = Viewport { w: 400.0, h: 300.0 }; - - let capped = layout(&tree, 0, vp, &capped_at(2, 10.0)); - let deep: Vec = layout(&tree, 0, vp, &capped_at(8, 10.0)) - .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 file is not"); - } - - /// Depth follows the pixels available: the same subtree expands when it has - /// room and stops when it does not, with `max_depth` nowhere near either. - #[test] - fn a_directory_stops_expanding_when_its_interior_is_too_small() { - let mut b = EntryBatch::default(); - b.push("root", entry(0, 0, DIR, 0)); - b.push("wide", entry(1, 0, FILE, 800)); - b.push("mid", entry(2, 0, DIR, 0)); - b.push("deep", entry(3, 2, FILE, 200)); - let mut builder = TreeBuilder::new(); - builder.add_batch(&b); - let tree = builder.finish(); - - let opts = TreemapOptions { - min_side_px: 25.0, - ..labelled(10.0) - }; - - // A 40px-tall viewport leaves `mid` 30px, and its own strip takes 10: - // the 20px left cannot hold a child, so it stays a plain plate. - let cramped = layout(&tree, 0, Viewport { w: 200.0, h: 40.0 }, &opts); - assert!(cramped.iter().any(|r| r.id == 2), "the dir itself is drawn"); - assert!( - cramped.iter().all(|r| r.id != 3), - "its interior cannot hold a legible child" - ); - - // The same tree given more height expands, and nobody raised a cap. - let roomy = layout(&tree, 0, Viewport { w: 200.0, h: 100.0 }, &opts); - assert!(roomy.iter().any(|r| r.id == 3), "now there is room"); - assert!(opts.max_depth > 1, "the cap never came into it"); - } - - fn labelled(label_px: f32) -> TreemapOptions { - TreemapOptions { - label_px, - ..no_padding() - } - } - - fn capped_at(max_depth: u8, label_px: f32) -> TreemapOptions { - TreemapOptions { - max_depth, - label_px, - ..no_padding() - } - } - #[test] fn zero_size_children_emit_nothing_and_nothing_is_nan() { let tree = flat_tree(&[("empty", 0), ("real", 10)]); @@ -709,16 +560,18 @@ mod tests { let tree = builder.finish(); let opts = TreemapOptions { - min_side_px: 0.0, - padding_px: 0.0, - label_px: 0.0, max_depth: 1, - hide_system: false, + ..no_padding() }; let rects = layout(&tree, 0, Viewport { w: 100.0, h: 100.0 }, &opts); assert!(rects.iter().any(|r| r.id == 1), "depth-1 dir emitted"); assert!(rects.iter().all(|r| r.id != 2), "depth-2 file not emitted"); + assert_eq!(area(&rect_of(&rects, 1)), 10_000.0); + + let drilled = layout(&tree, 1, Viewport { w: 100.0, h: 100.0 }, &opts); + assert_eq!(rect_of(&drilled, 2).depth, 1); + assert_eq!(area(&rect_of(&drilled, 2)), 10_000.0); } #[test] @@ -872,4 +725,160 @@ mod tests { assert!((area(&rect_of(&hidden, 1)) - 10_000.0).abs() < 1.0); assert!((area(&rect_of(&hidden, 2)) - 10_000.0).abs() < 1.0); } + + /// root { x { xa 300, y { ya 100 } }, z { za 400 }, w 200 } + fn open_tree() -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("x", entry(1, 0, DIR, 0)); + b.push("xa", entry(2, 1, FILE, 300)); + b.push("y", entry(3, 1, DIR, 0)); + b.push("ya", entry(4, 3, FILE, 100)); + b.push("z", entry(5, 0, DIR, 0)); + b.push("za", entry(6, 5, FILE, 400)); + b.push("w", entry(7, 0, FILE, 200)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + fn capped(max_depth: u8, reveal: Option) -> TreemapOptions { + TreemapOptions { + max_depth, + reveal, + ..no_padding() + } + } + + const VIEW: Viewport = Viewport { w: 100.0, h: 100.0 }; + + fn geometry(rects: &[TreemapRect]) -> Vec<(NodeId, [u32; 4], u8)> { + rects + .iter() + .map(|r| (r.id, [r.x, r.y, r.w, r.h].map(f32::to_bits), r.depth)) + .collect() + } + + #[test] + fn revealing_a_folder_adds_its_children_and_moves_nothing() { + let tree = open_tree(); + let closed = layout(&tree, 0, VIEW, &capped(1, None)); + let opened = layout(&tree, 0, VIEW, &capped(1, Some(1))); + + let before = geometry(&closed); + let after = geometry(&opened); + for g in &before { + assert!(after.contains(g), "rect {} moved or vanished", g.0); + } + let mut added: Vec<_> = after.iter().filter(|g| !before.contains(g)).collect(); + added.sort_by_key(|g| g.0); + assert_eq!(added.len(), 2); + assert_eq!((added[0].0, added[0].2), (2, 2)); + assert_eq!((added[1].0, added[1].2), (3, 2)); + assert!(!rect_of(&closed, 1).expanded); + assert!(rect_of(&opened, 1).expanded); + } + + #[test] + fn revealing_a_nested_folder_keeps_its_parent_open() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, Some(3))); + + assert!(rects.iter().any(|r| r.id == 2), "x stays open"); + assert!(rects.iter().any(|r| r.id == 4), "y opens"); + assert!(rects.iter().all(|r| r.id != 6), "z stays closed"); + } + + #[test] + fn revealing_a_file_opens_the_folders_above_it() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, Some(4))); + + assert!(rects.iter().any(|r| r.id == 4), "ya drawn"); + assert!(rect_of(&rects, 1).expanded); + assert!(rect_of(&rects, 3).expanded); + assert!(!rect_of(&rects, 5).expanded); + } + + #[test] + fn a_reveal_that_is_not_hidden_under_the_view_changes_nothing() { + let tree = open_tree(); + let closed = layout(&tree, 1, VIEW, &capped(1, None)); + + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(5))), + closed, + "other branch" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(999))), + closed, + "dead id" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(2))), + closed, + "visible file" + ); + assert_eq!( + layout(&tree, 1, VIEW, &capped(1, Some(1))), + closed, + "the root" + ); + let wide = layout(&tree, 0, VIEW, &capped(2, None)); + assert_eq!( + layout(&tree, 0, VIEW, &capped(2, Some(1))), + wide, + "already open" + ); + } + + #[test] + fn only_folders_with_children_drawn_are_expanded() { + let tree = open_tree(); + let rects = layout(&tree, 0, VIEW, &capped(1, None)); + + assert!(rect_of(&rects, 0).expanded, "root"); + assert!(!rect_of(&rects, 1).expanded, "x at the cap"); + assert!(!rect_of(&rects, 7).expanded, "file"); + } + + #[test] + fn nothing_is_drawn_past_the_hard_depth_limit() { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + for id in 1..40 { + b.push("d", entry(id, id - 1, DIR, 0)); + } + b.push("f", entry(40, 39, FILE, 1)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + let tree = builder.finish(); + + let deepest_closed = layout(&tree, 0, VIEW, &capped(32, None)) + .iter() + .map(|r| r.depth) + .max(); + let deepest_open = layout(&tree, 0, VIEW, &capped(1, Some(39))) + .iter() + .map(|r| r.depth) + .max(); + + assert_eq!(deepest_closed, Some(MAX_LAYOUT_DEPTH)); + assert_eq!(deepest_open, Some(MAX_LAYOUT_DEPTH)); + } + + #[test] + fn an_open_folder_shows_only_what_the_filter_keeps() { + let tree = open_tree(); + // root, x, xa, y(out), ya(out), z, za, w + let bytes = vec![900u64, 300, 300, 0, 0, 400, 400, 200]; + let rects = layout_with_filter(&tree, 0, VIEW, &capped(1, Some(1)), &bytes); + + assert!( + rects.iter().all(|r| r.id != 3), + "filtered-out folder omitted" + ); + assert_eq!(area(&rect_of(&rects, 2)), area(&rect_of(&rects, 1))); + } } diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index c70103c..be1518b 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant}; use mathom_core::export::ExportOptions; use mathom_core::search::{FilterOverlay, SearchQuery}; use mathom_core::tree::{NodeId, Tree}; -use mathom_core::{EntryFlags, TreeBuilder, TreemapOptions, Viewport, treemap}; +use mathom_core::{EntryFlags, MAX_LAYOUT_DEPTH, TreeBuilder, TreemapOptions, Viewport, treemap}; use mathom_scanner::{GenericScanner, ScanEvent, ScanHandle, ScanOptions, Scanner}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; @@ -168,9 +168,22 @@ pub struct TreemapRectDto { depth: u8, is_dir: bool, category: u8, - /// What the block is called; the canvas draws it inside the rect. - name: String, - size: u64, + expanded: bool, + /// Only on folded directories under a depth cap, the ones the UI labels. + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TreemapArgs { + width: f32, + height: f32, + hide_system: bool, + filter: Option, + /// `None` draws every level. + max_depth: Option, + reveal: Option, } #[derive(Clone, Serialize)] @@ -341,26 +354,12 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } -/// Legibility floor for a treemap rect, in CSS pixels: below this on either -/// side the block is not drawn, so the map never fills with specks. Mirrored -/// as `TREEMAP_MIN_SIDE_PX` in `ui/src/lib/prefs.ts`. -const TREEMAP_MIN_SIDE_PX: f32 = 6.0; - -/// The strip a directory reserves at the top of its interior for its label — -/// and therefore the height the UI has to draw that label in. Mirrored as -/// `TREEMAP_LABEL_PX` in `ui/src/lib/prefs.ts`: the two have to agree or the -/// text lands on the children instead of above them. -const TREEMAP_LABEL_PX: f32 = 15.0; - #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, generation: u64, root_id: NodeId, - width: f32, - height: f32, - hide_system: bool, - filter: Option, + args: TreemapArgs, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -372,24 +371,21 @@ pub fn get_treemap( return Ok(Vec::new()); } let opts = TreemapOptions { - min_side_px: TREEMAP_MIN_SIDE_PX, + min_area_px: 3.0, padding_px: 1.0, - label_px: TREEMAP_LABEL_PX, - max_depth: 24, - hide_system, + max_depth: args.max_depth.map_or(MAX_LAYOUT_DEPTH, |d| d.max(1)), + hide_system: args.hide_system, + reveal: args.reveal, }; + let capped = args.max_depth.is_some(); let viewport = Viewport { - w: width, - h: height, + w: args.width, + h: args.height, }; - let rects = match overlay_for(&session, tree, filter.as_deref(), hide_system) { + let rects = match overlay_for(&session, tree, args.filter.as_deref(), args.hide_system) { Some(o) => treemap::layout_with_filter(tree, root_id, viewport, &opts, &o.bytes), None => treemap::layout(tree, root_id, viewport, &opts), }; - // The label's text rides along with the geometry: a rect the user can see - // is a rect they can read, and asking per node would be one round trip per - // rectangle. The culling above is what keeps the payload honest — what is - // sent is roughly what fits on screen, not the whole subtree. Ok(rects .into_iter() .map(|r| TreemapRectDto { @@ -401,8 +397,8 @@ pub fn get_treemap( depth: r.depth, is_dir: r.is_dir, category: r.category, - name: tree.name(r.id).to_string(), - size: tree.node(r.id).size, + expanded: r.expanded, + name: (capped && r.is_dir && !r.expanded).then(|| tree.name(r.id).to_owned()), }) .collect()) } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1703d05..e3cc8cd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,9 +14,14 @@ import { type Row, type SearchHit, type Snapshot, - type TreemapRect, } from "./lib/api"; import { copyText } from "./lib/clipboard"; +import { + loadTreemapPrefs, + saveTreemapPrefs, + treemapDepth, + type TreemapPrefs, +} from "./lib/prefs"; import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors"; const TREE_PANE_MIN = 320; @@ -52,6 +57,7 @@ export default function App() { const [selected, setSelected] = useState(null); const [selectedPath, setSelectedPath] = useState(null); const [viewRootId, setViewRootId] = useState(0); + const [treemapPrefs, setTreemapPrefs] = useState(loadTreemapPrefs); const [hoveredId, setHoveredId] = useState(null); const [revealId, setRevealId] = useState(null); const [treeWidth, setTreeWidth] = useState(560); @@ -122,15 +128,14 @@ export default function App() { ); const handleTreemapSelect = useCallback( - (rect: TreemapRect) => { - select(rect.id); - if (rect.isDir) setViewRootId(rect.id); + (id: number) => { + select(id); if (generation === 0) return; api - .getAncestors(generation, rect.id) + .getAncestors(generation, id) .then((chain) => { expandMany(chain.slice(0, -1).map((c) => c.id)); - setRevealId(rect.id); + setRevealId(id); }) .catch((e) => reportUnlessStale("revealing selection", e)); }, @@ -177,6 +182,11 @@ export default function App() { setViewRootId(id); }, []); + const handleTreemapPrefs = useCallback((prefs: TreemapPrefs) => { + setTreemapPrefs(prefs); + saveTreemapPrefs(prefs); + }, []); + const handleRevealed = useCallback(() => setRevealId(null), []); const handleContextMenu = useCallback( @@ -363,6 +373,7 @@ export default function App() { viewRootId={viewRootId} startError={scan.startError} hideSystem={scan.hideSystem} + treemapPrefs={treemapPrefs} filter={scan.filter} typePanelOpen={typePanelOpen} themePref={theme.pref} @@ -370,6 +381,7 @@ export default function App() { onScan={handleScan} onCancel={scan.cancel} onToggleHideSystem={scan.toggleHideSystem} + onTreemapPrefs={handleTreemapPrefs} onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} @@ -428,6 +440,8 @@ export default function App() { themeRev={theme.themeRev} hideSystem={scan.hideSystem} filter={scan.filter} + maxDepth={treemapDepth(treemapPrefs)} + openInPlace={treemapPrefs.style === "spacesniffer"} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/DepthPicker.tsx b/ui/src/components/DepthPicker.tsx new file mode 100644 index 0000000..7e40bd7 --- /dev/null +++ b/ui/src/components/DepthPicker.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; + +interface DepthPickerProps { + /** null means every level. */ + value: number | null; + presets: readonly (number | null)[]; + /** What the custom box holds while a preset is chosen. */ + custom: number; + max: number; + title?: string; + onChange: (depth: number | null) => void; + onCustom: (depth: number) => void; +} + +export function DepthPicker({ + value, + presets, + custom, + max, + title, + onChange, + onCustom, +}: DepthPickerProps) { + const isCustom = !presets.includes(value); + const firstCustom = Math.max(...presets.map((d) => d ?? 0)) + 1; + const [text, setText] = useState(String(custom)); + + const pick = (n: number) => { + const depth = Math.min(n, max); + onCustom(depth); + onChange(depth); + }; + + return ( +
+ {presets.map((d) => ( + + ))} + pick(custom)} + onChange={(e) => { + const digits = e.target.value + .replace(/\D/g, "") + .slice(0, String(max).length); + setText(digits); + if (Number(digits) >= 1) pick(Number(digits)); + }} + onBlur={() => setText(String(custom))} + placeholder={`${firstCustom}+`} + title={`Any depth up to ${max}`} + className={`h-6 w-0 min-w-0 flex-1 rounded text-center text-[12px] outline-none placeholder:text-ink-5 ${ + isCustom + ? "bg-raised text-ink" + : "bg-transparent text-ink-4 hover:text-ink-2" + }`} + /> +
+ ); +} diff --git a/ui/src/components/ExportMenu.tsx b/ui/src/components/ExportMenu.tsx index 29d3f5a..9114337 100644 --- a/ui/src/components/ExportMenu.tsx +++ b/ui/src/components/ExportMenu.tsx @@ -2,9 +2,11 @@ import { useEffect, useRef, useState } from "react"; import { save } from "@tauri-apps/plugin-dialog"; import { api, type ExportFormat } from "../lib/api"; import { copyText } from "../lib/clipboard"; +import { DepthPicker } from "./DepthPicker"; import { ExportIcon } from "./icons"; -const DEPTHS = ["all", "1", "2"] as const; +const DEPTH_PRESETS = [null, 1, 2] as const; +const MAX_EXPORT_DEPTH = 999; interface ExportMenuProps { generation: number; @@ -21,9 +23,8 @@ export function ExportMenu({ }: ExportMenuProps) { const [open, setOpen] = useState(false); const [format, setFormat] = useState("csv"); - // "all", "1", "2", or whatever digits sit in the custom box. - const [depth, setDepth] = useState("all"); - const [custom, setCustom] = useState("3"); + const [depth, setDepth] = useState(null); + const [customDepth, setCustomDepth] = useState(3); const [dirsOnly, setDirsOnly] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState<{ ok: boolean; msg: string } | null>( @@ -51,7 +52,7 @@ export function ExportMenu({ setBusy(true); setStatus(null); const args = { - maxDepth: depth === "all" ? null : Math.max(1, Number(depth) || 1), + maxDepth: depth, dirsOnly, hideSystem, }; @@ -128,44 +129,14 @@ export function ExportMenu({
Depth
-
- {DEPTHS.map((d) => ( - - ))} - setDepth(custom)} - onChange={(e) => { - const v = e.target.value.replace(/\D/g, "").slice(0, 3); - setCustom(v); - setDepth(v); - }} - onBlur={() => { - if (/^[1-9]\d{0,2}$/.test(custom)) return; - setCustom("3"); - if (!DEPTHS.includes(depth as (typeof DEPTHS)[number])) { - setDepth("3"); - } - }} - placeholder="3+" - title="Any depth — type a number" - className={`h-6 w-0 min-w-0 flex-1 rounded text-center text-[12px] outline-none placeholder:text-ink-5 ${ - !DEPTHS.includes(depth as (typeof DEPTHS)[number]) - ? "bg-raised text-ink" - : "bg-transparent text-ink-4 hover:text-ink-2" - }`} - /> -
+ +
Treemap style
+
+ {STYLE_OPTIONS.map((opt) => ( + + ))} +
+
Treemap depth
+ onTreemapPrefs({ ...treemapPrefs, depth: d })} + onCustom={setCustomDepth} + />
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..28554bf 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 { TreemapPrefs } from "../lib/prefs"; import type { AccentName, ThemePref } from "../lib/theme"; import { ExportMenu } from "./ExportMenu"; import { ScanMenu } from "./ScanMenu"; @@ -13,6 +14,7 @@ interface ToolbarProps { viewRootId: number; startError: string | null; hideSystem: boolean; + treemapPrefs: TreemapPrefs; filter: string | null; typePanelOpen: boolean; themePref: ThemePref; @@ -20,6 +22,7 @@ interface ToolbarProps { onScan: (path: string) => void; onCancel: () => void; onToggleHideSystem: () => void; + onTreemapPrefs: (prefs: TreemapPrefs) => void; onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; @@ -33,6 +36,7 @@ export function Toolbar({ viewRootId, startError, hideSystem, + treemapPrefs, filter, typePanelOpen, themePref, @@ -40,6 +44,7 @@ export function Toolbar({ onScan, onCancel, onToggleHideSystem, + onTreemapPrefs, onToggleTypePanel, onSearchSelect, onApplyFilter, @@ -100,6 +105,8 @@ export function Toolbar({ themePref={themePref} accent={accent} onToggleHideSystem={onToggleHideSystem} + treemapPrefs={treemapPrefs} + onTreemapPrefs={onTreemapPrefs} onThemePref={onThemePref} onAccent={onAccent} /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..a995a1e 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -30,6 +30,7 @@ import { PALETTE, canvasColors } from "../lib/palette"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; +const LABEL_FONT = 'ui-sans-serif, system-ui, "Segoe UI", sans-serif'; // 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 @@ -88,6 +89,22 @@ function snap(r: TreemapRect, dpr: number, gap: number): Snapped { return { x: x0, y: y0, w: x1 - x0 - gap, h: y1 - y0 - gap }; } +// Pre-order: the nearest shallower rect before a child is its parent. +function parentOf( + rects: TreemapRect[], + child: TreemapRect, +): TreemapRect | undefined { + for (let i = rects.indexOf(child) - 1; i >= 0; i--) { + if (rects[i].depth < child.depth) return rects[i]; + } + return undefined; +} + +const isFolded = (r: TreemapRect) => r.isDir && !r.expanded; + +const folderOf = (rects: TreemapRect[], r: TreemapRect) => + r.isDir ? r : parentOf(rects, r); + interface TooltipData { name: string; size: string; @@ -106,9 +123,13 @@ export interface TreemapProps { hideSystem: boolean; /** Active view filter (search grammar) or null. */ filter: string | null; + /** Levels drawn below the view root; null draws them all. */ + maxDepth: number | null; + /** Clicking a folder opens it in place instead of zooming into it. */ + openInPlace: boolean; selected: number | null; hoveredId: number | null; - onSelect: (rect: TreemapRect) => void; + onSelect: (id: number) => void; onHover: (id: number | null) => void; onNavigate: (id: number) => void; onContext: (id: number, x: number, y: number, zoom: ZoomTargets) => void; @@ -128,6 +149,8 @@ export function Treemap({ themeRev, hideSystem, filter, + maxDepth, + openInPlace, selected, hoveredId, onSelect, @@ -155,6 +178,11 @@ export function Treemap({ const tooltipSeqRef = useRef(0); const tooltipTimerRef = useRef(0); const lastMouseRef = useRef({ x: 0, y: 0 }); + // The clicked node the layout keeps open past the depth cap, until the view moves. + const openRef = useRef(null); + // Just clicked open; zoom instead if nothing inside is big enough to draw. + const pendingOpenRef = useRef(null); + const layoutCappedRef = useRef(false); const generationRef = useRef(generation); generationRef.current = generation; @@ -166,6 +194,12 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); filterRef.current = filter; + const maxDepthRef = useRef(maxDepth); + maxDepthRef.current = maxDepth; + const openInPlaceRef = useRef(openInPlace); + openInPlaceRef.current = openInPlace; + const onNavigateRef = useRef(onNavigate); + onNavigateRef.current = onNavigate; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -195,15 +229,17 @@ export function Treemap({ }; outline(selectedRef.current, theme.selection, 2); const hovered = hoveredIdRef.current; - if (hovered !== null && hovered !== selectedRef.current) { - if (hovered === rootIdRef.current || crumbIdsRef.current.has(hovered)) { - ctx.strokeStyle = theme.hoverRing; - ctx.lineWidth = 3; - ctx.strokeRect(1.5, 1.5, overlay.width - 3, overlay.height - 3); - } else { - outline(hovered, theme.hoverRing, 2); - } + if (hovered === null) return; + if (hovered === rootIdRef.current || crumbIdsRef.current.has(hovered)) { + ctx.strokeStyle = theme.hoverRing; + ctx.lineWidth = 3; + ctx.strokeRect(1.5, 1.5, overlay.width - 3, overlay.height - 3); + return; } + const r = byIdRef.current.get(hovered); + const folder = r && parentOf(rectsRef.current, r); + if (folder) outline(folder.id, theme.hoverRing, 1); + if (hovered !== selectedRef.current) outline(hovered, theme.hoverRing, 2); }, []); const blit = useCallback(() => { @@ -248,9 +284,11 @@ export function Treemap({ getGrainTile(theme.plate, theme.plateGrain), "repeat", )!; + const capped = layoutCappedRef.current; + const closed = (r: TreemapRect) => capped && isFolded(r); ctx.beginPath(); for (const r of rects) { - if (!r.isDir) continue; + if (!r.isDir || closed(r)) continue; const s = snap(r, dpr, 0); if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } @@ -272,6 +310,15 @@ export function Treemap({ ctx.fill(); } + ctx.fillStyle = theme.folder; + ctx.beginPath(); + for (const r of rects) { + if (!closed(r)) continue; + const s = snap(r, dpr, 1); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); + const sprite = getHighlightSprite(); for (const r of rects) { if (r.isDir) continue; @@ -279,6 +326,21 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } + ctx.font = `${12 * dpr}px ${LABEL_FONT}`; + ctx.textBaseline = "top"; + for (const r of rects) { + if (!closed(r) || !r.name) continue; + const s = snap(r, dpr, 1); + if (s.w < 48 * dpr || s.h < 24 * dpr) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(s.x + 6 * dpr, s.y, s.w - 12 * dpr, s.h); + ctx.clip(); + ctx.fillStyle = theme.selection; + ctx.fillText(r.name, s.x + 6 * dpr, s.y + 6 * dpr); + ctx.restore(); + } + if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -320,21 +382,29 @@ export function Treemap({ const forRoot = rootIdRef.current; lastFetchRef.current = performance.now(); try { - const rects = await api.getTreemap( - generation, - forRoot, - w, - h, - hideSystemRef.current, - filterRef.current, - ); + const maxDepth = maxDepthRef.current; + const reveal = maxDepth === null ? null : openRef.current; + const rects = await api.getTreemap(generation, forRoot, { + width: w, + height: h, + hideSystem: hideSystemRef.current, + filter: filterRef.current, + maxDepth, + reveal, + }); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; + layoutCappedRef.current = maxDepth !== null; byIdRef.current = new Map(rects.map((r) => [r.id, r])); hitFrozenRef.current = false; setHasRects(rects.length > 0); bake(); if (crumbsRootRef.current !== forRoot) refreshCrumbs(); + const pending = pendingOpenRef.current; + if (pending === null || pending !== reveal) return; + pendingOpenRef.current = null; + const opened = byIdRef.current.get(pending); + if (opened && isFolded(opened)) onNavigateRef.current(opened.id); } catch (e) { reportUnlessStale("loading treemap", e); if (seq === fetchSeqRef.current) hitFrozenRef.current = false; @@ -345,6 +415,8 @@ export function Treemap({ (id: number) => { if (id === rootIdRef.current) return; const zoomFrom = byIdRef.current.get(id); + openRef.current = null; + pendingOpenRef.current = null; rootIdRef.current = id; hitFrozenRef.current = true; setTooltip(null); @@ -402,6 +474,8 @@ export function Treemap({ hitFrozenRef.current = false; crumbsRootRef.current = null; crumbIdsRef.current = new Set(); + openRef.current = null; + pendingOpenRef.current = null; offscreenRef.current = null; const base = baseRef.current; if (base) base.getContext("2d")!.clearRect(0, 0, base.width, base.height); @@ -422,8 +496,11 @@ export function Treemap({ }, [themeRev, bake]); useEffect(() => { + // An open folder belongs to the layout it was clicked in. + openRef.current = null; + pendingOpenRef.current = null; void fetchLayout(); - }, [hideSystem, filter, fetchLayout]); + }, [hideSystem, filter, maxDepth, fetchLayout]); const prevStateRef = useRef(undefined); useEffect(() => { @@ -578,11 +655,22 @@ export function Treemap({ const handleClick = useCallback( (e: React.MouseEvent) => { + // The second click of a double-click would land on what the first revealed. + if (e.detail > 1) return; const bounds = containerRef.current!.getBoundingClientRect(); const hit = hitTest(e.clientX - bounds.left, e.clientY - bounds.top); - if (hit) onSelect(hit); + if (!hit) return; + onSelect(hit.id); + if (!openInPlaceRef.current && hit.isDir) { + onNavigate(hit.id); + return; + } + if (maxDepthRef.current === null || openRef.current === hit.id) return; + openRef.current = hit.id; + pendingOpenRef.current = hit.id; + void fetchLayout(); }, - [hitTest, onSelect], + [hitTest, onSelect, onNavigate, fetchLayout], ); const handleContextMenu = useCallback( @@ -601,15 +689,17 @@ export function Treemap({ [hitTest, regionAt, crumbs, onContext], ); - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - if (zoomRafRef.current !== 0) return; - const bounds = containerRef.current!.getBoundingClientRect(); - const region = regionAt(e.clientX - bounds.left, e.clientY - bounds.top); - if (region) onNavigate(region.id); - }, - [regionAt, onNavigate], - ); + const selectedFolder = useCallback(() => { + const id = selectedRef.current; + const r = id === null ? undefined : byIdRef.current.get(id); + return r && folderOf(rectsRef.current, r); + }, []); + + const handleDoubleClick = useCallback(() => { + if (zoomRafRef.current !== 0) return; + const target = selectedFolder(); + if (target) onNavigate(target.id); + }, [selectedFolder, onNavigate]); const zoomOut = useCallback(() => { if (crumbs.length < 2 || hitFrozenRef.current) return; diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..f00477e 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-folder: #6b6146; /* treemap folder closed by the depth limit */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -46,6 +47,7 @@ --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; --color-plate-grain: #bfc0c8; + --color-folder: #d9cda3; --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index dd3b149..7363e6a 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -46,6 +46,21 @@ export interface TreemapRect { depth: number; isDir: boolean; category: number; + /** A directory whose children were laid out. */ + expanded: boolean; + /** Only on folded directories under a depth cap. */ + name?: string; +} + +export interface TreemapArgs { + width: number; + height: number; + hideSystem: boolean; + filter: string | null; + /** null draws every level. */ + maxDepth: number | null; + /** Kept visible past maxDepth. */ + reveal: number | null; } export interface Crumb { @@ -150,22 +165,8 @@ export const api = { invoke("get_node", { generation, id }), getPath: (generation: number, id: number) => invoke("get_path", { generation, id }), - getTreemap: ( - generation: number, - rootId: number, - width: number, - height: number, - hideSystem: boolean, - filter: string | null, - ) => - invoke("get_treemap", { - generation, - rootId, - width, - height, - hideSystem, - filter, - }), + getTreemap: (generation: number, rootId: number, args: TreemapArgs) => + invoke("get_treemap", { generation, rootId, args }), getTypeStats: ( generation: number, rootId: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..08d4f72 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"), + folder: v("--color-folder"), 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..029ac75 --- /dev/null +++ b/ui/src/lib/prefs.ts @@ -0,0 +1,35 @@ +const STYLE_KEY = "mathom:treemap-style"; +const DEPTH_KEY = "mathom:treemap-depth"; + +/** Mirrors mathom-core's MAX_LAYOUT_DEPTH. */ +export const MAX_TREEMAP_DEPTH = 24; + +/** Default zooms into a clicked folder; SpaceSniffer opens it in place. */ +export type TreemapStyle = "default" | "spacesniffer"; + +export interface TreemapPrefs { + style: TreemapStyle; + /** null draws every level. */ + depth: number | null; +} + +/** SpaceSniffer needs something folded to open; a stored All is kept for Default. */ +export function treemapDepth(prefs: TreemapPrefs): number | null { + return prefs.style === "spacesniffer" ? (prefs.depth ?? 1) : prefs.depth; +} + +export function loadTreemapPrefs(): TreemapPrefs { + const n = Number(localStorage.getItem(DEPTH_KEY)); + return { + style: + localStorage.getItem(STYLE_KEY) === "spacesniffer" + ? "spacesniffer" + : "default", + depth: Number.isInteger(n) && n >= 1 && n <= MAX_TREEMAP_DEPTH ? n : null, + }; +} + +export function saveTreemapPrefs(prefs: TreemapPrefs) { + localStorage.setItem(STYLE_KEY, prefs.style); + localStorage.setItem(DEPTH_KEY, String(prefs.depth ?? "all")); +}