use crate::size::human;
use crate::tree::NodeId;
use crate::tui::state::{Mark, View};
const MIN_UNKNOWN: f64 = 0.08;
const MAX_DEPTH: usize = 3;
const NEST: (f64, f64) = (110.0, 54.0);
const CAPTION: f64 = 13.0;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Area {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Area {
#[must_use]
pub fn of(w: f64, h: f64) -> Self {
Self {
x: 0.0,
y: 0.0,
w,
h,
}
}
#[must_use]
pub fn size(&self) -> f64 {
self.w.max(0.0) * self.h.max(0.0)
}
#[must_use]
fn inset(&self, by: f64) -> Self {
Self {
x: self.x + by,
y: self.y + by,
w: (self.w - 2.0 * by).max(0.0),
h: (self.h - 2.0 * by).max(0.0),
}
}
#[must_use]
fn below(&self, off: f64) -> Self {
Self {
x: self.x,
y: self.y + off,
w: self.w,
h: (self.h - off).max(0.0),
}
}
fn split(&self, share: f64) -> (Self, Self) {
if self.w >= self.h {
let second = (self.w * share).round();
(
Self {
w: self.w - second,
..*self
},
Self {
x: self.x + self.w - second,
w: second,
..*self
},
)
} else {
let second = (self.h * share).round();
(
Self {
h: self.h - second,
..*self
},
Self {
y: self.y + self.h - second,
h: second,
..*self
},
)
}
}
}
impl std::hash::Hash for Area {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
for side in [self.x, self.y, self.w, self.h] {
side.to_bits().hash(state);
}
}
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum Kind {
Priced,
Unpriced,
}
#[derive(Clone, Debug, Hash, PartialEq)]
pub struct Tile {
pub id: NodeId,
pub area: Area,
pub depth: usize,
pub kind: Kind,
pub marked: bool,
pub cursor: bool,
pub nested: bool,
pub name: String,
pub worth: String,
}
#[derive(Clone, Debug, Hash, PartialEq)]
pub struct Map {
pub root: NodeId,
pub tiles: Vec<Tile>,
pub unknown: Option<Area>,
pub caption: String,
}
#[must_use]
pub fn focus(view: &View) -> Option<NodeId> {
let id = view.row()?.id;
if view.tree().children(id).is_empty() {
return view.tree().node(id).parent.or(Some(id));
}
Some(id)
}
#[must_use]
pub fn mappable(view: &View, root: NodeId, area: Area) -> bool {
view.roll(root).claims > 0 && area.w >= 1.0 && area.h >= 1.0
}
#[must_use]
pub fn plan(view: &View, root: NodeId, area: Area) -> Option<Map> {
if !mappable(view, root, area) {
return None;
}
let roll = view.roll(root);
let share = if roll.unpriced == 0 {
0.0
} else {
#[expect(
clippy::cast_precision_loss,
reason = "claim counts are in the tens of thousands; the ratio is a fraction of a \
pane, not an accounting figure"
)]
let exact = roll.unpriced as f64 / roll.claims as f64;
exact.clamp(MIN_UNKNOWN, 1.0)
};
let (known, unknown) = area.split(share);
let mut tiles = Vec::new();
if share < 1.0 {
lay(view, root, known, 1, &mut tiles, Kind::Priced);
}
if share > 0.0 {
lay(view, root, unknown, 1, &mut tiles, Kind::Unpriced);
}
if tiles.is_empty() {
return None;
}
Some(Map {
root,
tiles,
unknown: (share > 0.0).then_some(unknown),
caption: caption(view, root),
})
}
#[must_use]
pub fn caption(view: &View, root: NodeId) -> String {
let roll = view.roll(root);
let node = view.tree().node(root);
let name = if node.parent.is_none() {
node.path.display().to_string()
} else {
node.name.to_string_lossy().into_owned()
};
if roll.unpriced == 0 {
return format!("{name} — {}", human(roll.bytes));
}
format!(
"{name} — > {} · {} unpriced",
human(roll.bytes),
roll.unpriced
)
}
fn weighted(view: &View, parent: NodeId, kind: Kind) -> Vec<(NodeId, u64)> {
let mut children: Vec<(NodeId, u64)> = view
.tree()
.children(parent)
.iter()
.filter_map(|&id| {
let roll = view.roll(id);
let weight = match kind {
Kind::Priced => roll.bytes,
Kind::Unpriced => roll.unpriced as u64,
};
(weight > 0).then_some((id, weight))
})
.collect();
children.sort_unstable_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
children
}
fn collapse(view: &View, id: NodeId, kind: Kind) -> (NodeId, Vec<NodeId>, String) {
let mut chain = vec![id];
let mut name = view.tree().node(id).name.to_string_lossy().into_owned();
let mut at = id;
while let [(only, _)] = weighted(view, at, kind).as_slice() {
at = *only;
chain.push(at);
name.push('/');
name.push_str(&view.tree().node(at).name.to_string_lossy());
}
(at, chain, name)
}
fn lay(view: &View, parent: NodeId, area: Area, depth: usize, out: &mut Vec<Tile>, kind: Kind) {
let children = weighted(view, parent, kind);
if children.is_empty() {
return;
}
let weights: Vec<u64> = children.iter().map(|(_, weight)| *weight).collect();
for ((id, weight), placed) in children.iter().zip(squarify(area, &weights)) {
if placed.size() < 1.0 {
continue;
}
let (deepest, chain, name) = collapse(view, *id, kind);
let nested = kind == Kind::Priced
&& depth < MAX_DEPTH
&& placed.w >= NEST.0
&& placed.h >= NEST.1
&& !weighted(view, deepest, kind).is_empty();
out.push(Tile {
id: *id,
area: placed,
depth,
kind,
marked: view.mark_of(*id) == Mark::All,
cursor: view.row().is_some_and(|row| chain.contains(&row.id)),
nested,
name,
worth: match kind {
Kind::Priced => human(*weight),
Kind::Unpriced => format!("{weight} unpriced"),
},
});
if nested {
lay(
view,
deepest,
placed.inset(2.0).below(CAPTION),
depth + 1,
out,
kind,
);
}
}
}
#[must_use]
pub fn squarify(area: Area, weights: &[u64]) -> Vec<Area> {
let mut out = vec![Area::default(); weights.len()];
let total: u128 = weights.iter().map(|&weight| u128::from(weight)).sum();
if total == 0 || area.size() <= 0.0 {
return out;
}
#[expect(
clippy::cast_precision_loss,
reason = "byte totals reach terabytes; f64 carries 53 bits of mantissa, so the error \
is far below one pixel of a pane"
)]
let scale = area.size() / total as f64;
#[expect(
clippy::cast_precision_loss,
reason = "as above — these are pixel areas, not ledgers"
)]
let sized: Vec<f64> = weights
.iter()
.map(|&weight| weight as f64 * scale)
.collect();
let order: Vec<usize> = (0..sized.len()).filter(|&at| sized[at] > 0.0).collect();
let mut free = area;
let mut next = 0;
while next < order.len() {
let short = free.w.min(free.h);
if short <= 0.0 {
break;
}
let mut end = next + 1;
let mut row = sized[order[next]];
let mut best = worst(row, row, row, short);
while end < order.len() {
let candidate = sized[order[end]];
let grown = row + candidate;
let ratio = worst(grown, sized[order[next]], candidate, short);
if ratio > best {
break;
}
best = ratio;
row = grown;
end += 1;
}
free = place(&sized, &order[next..end], row, free, &mut out);
next = end;
}
out
}
fn worst(row: f64, largest: f64, smallest: f64, short: f64) -> f64 {
if row <= 0.0 || smallest <= 0.0 {
return f64::INFINITY;
}
let side = short * short;
let sum = row * row;
f64::max(side * largest / sum, sum / (side * smallest))
}
fn place(sized: &[f64], row: &[usize], total: f64, free: Area, out: &mut [Area]) -> Area {
let short = free.w.min(free.h);
let thick = total / short;
let mut along = 0.0;
if free.w <= free.h {
for &at in row {
let width = sized[at] / thick;
out[at] = Area {
x: free.x + along,
y: free.y,
w: width,
h: thick,
};
along += width;
}
Area {
y: free.y + thick,
h: free.h - thick,
..free
}
} else {
for &at in row {
let height = sized[at] / thick;
out[at] = Area {
x: free.x,
y: free.y + along,
w: thick,
h: height,
};
along += height;
}
Area {
x: free.x + thick,
w: free.w - thick,
..free
}
}
}
#[cfg(test)]
mod tests {
use super::{Area, Kind, MIN_UNKNOWN, focus, plan, squarify};
use crate::fixture::{hit, priced};
use crate::size::Size;
use crate::tree::Tree;
use crate::tui::keymap::{Action, Motion};
use crate::tui::state::View;
fn pane() -> Area {
Area::of(400.0, 300.0)
}
#[test]
fn area_is_proportional_to_weight_and_nothing_overlaps() {
let weights = [4096_u64, 2048, 1024, 900, 512, 64, 8, 1];
let area = pane();
let placed = squarify(area, &weights);
#[expect(clippy::cast_precision_loss, reason = "a test fixture's totals")]
let total = weights.iter().sum::<u64>() as f64;
for (weight, rect) in weights.iter().zip(&placed) {
#[expect(clippy::cast_precision_loss, reason = "a test fixture's totals")]
let want = area.size() * (*weight as f64) / total;
assert!(
(rect.size() - want).abs() < 1e-6,
"{weight} got {:?}, worth {want}",
rect.size()
);
assert!(
rect.x >= -1e-9
&& rect.y >= -1e-9
&& rect.x + rect.w <= area.w + 1e-9
&& rect.y + rect.h <= area.h + 1e-9,
"{rect:?} escaped {area:?}"
);
}
for (at, first) in placed.iter().enumerate() {
for second in &placed[at + 1..] {
let across = (first.x + first.w).min(second.x + second.w) - first.x.max(second.x);
let down = (first.y + first.h).min(second.y + second.h) - first.y.max(second.y);
assert!(
across <= 1e-9 || down <= 1e-9,
"{first:?} overlaps {second:?}"
);
}
}
}
#[test]
fn no_rectangle_comes_out_a_sliver() {
let weights = [4096_u64, 2048, 1024, 900, 512, 64, 32, 16];
for rect in squarify(pane(), &weights) {
let ratio = (rect.w / rect.h).max(rect.h / rect.w);
assert!(ratio < 8.0, "{rect:?} has an aspect ratio of {ratio}");
}
}
#[test]
fn one_weight_takes_the_whole_rectangle_and_no_weight_takes_none_of_it() {
let whole = squarify(pane(), &[7]);
assert!((whole[0].size() - pane().size()).abs() < 1e-6, "{whole:?}");
assert_eq!(squarify(pane(), &[]), Vec::new());
assert_eq!(squarify(pane(), &[0, 0]), vec![Area::default(); 2]);
let mixed = squarify(pane(), &[8, 0, 8]);
assert_eq!(mixed[1], Area::default());
assert!((mixed[0].size() - mixed[2].size()).abs() < 1e-6);
}
fn view() -> View {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/a/node_modules", 8 * 1024 * 1024));
tree.insert(priced("/scan/b/target", 2 * 1024 * 1024));
View::new(tree)
}
#[test]
fn a_priced_tree_is_a_plain_treemap_with_no_unknown_region() {
let view = view();
let map = plan(&view, view.tree().root(), pane()).unwrap();
assert!(map.unknown.is_none(), "{:?}", map.unknown);
assert!(map.tiles.iter().all(|tile| tile.kind == Kind::Priced));
let a = map
.tiles
.iter()
.find(|tile| tile.name == "a/node_modules")
.unwrap_or_else(|| panic!("{:?}", map.tiles));
let b = map
.tiles
.iter()
.find(|tile| tile.name == "b/target")
.unwrap();
assert!((a.area.size() / b.area.size() - 4.0).abs() < 1e-6);
assert_eq!(a.worth, "8.0 MiB");
assert!(map.caption.contains("/scan"), "{}", map.caption);
assert!(!map.caption.contains("unpriced"), "{}", map.caption);
}
#[test]
fn an_unpriced_claim_is_never_a_sliver_among_priced_ones() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/small/target", 1024));
tree.insert(hit("/scan/huge/node_modules", Size::Unmeasured, 0));
let view = View::new(tree);
let map = plan(&view, view.tree().root(), pane()).unwrap();
let huge = map
.tiles
.iter()
.find(|tile| tile.name == "huge/node_modules")
.unwrap_or_else(|| panic!("the unpriced subtree vanished: {:?}", map.tiles));
assert_eq!(huge.kind, Kind::Unpriced);
assert_eq!(huge.worth, "1 unpriced", "stated in bytes it does not have");
assert!(map.unknown.is_some());
assert!(
huge.area.size() > pane().size() * 0.4,
"{:?} of {:?}",
huge.area,
pane()
);
}
#[test]
fn one_unpriced_claim_in_a_hundred_is_still_visible() {
let mut tree = Tree::new("/scan");
for n in 0..99 {
tree.insert(priced(&format!("/scan/p{n}/target"), 1024));
}
tree.insert(hit("/scan/late/node_modules", Size::Unmeasured, 0));
let view = View::new(tree);
let map = plan(&view, view.tree().root(), pane()).unwrap();
let unknown = map.unknown.unwrap();
assert!(
unknown.size() >= pane().size() * MIN_UNKNOWN - 1.0,
"{unknown:?}"
);
}
#[test]
fn a_wholly_unpriced_tree_is_wholly_texture_and_says_so() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 0));
tree.insert(hit("/scan/b/target", Size::Unmeasured, 0));
let view = View::new(tree);
let map = plan(&view, view.tree().root(), pane()).unwrap();
assert!(map.tiles.iter().all(|tile| tile.kind == Kind::Unpriced));
assert!((map.unknown.unwrap().size() - pane().size()).abs() < 1e-6);
assert!(map.caption.contains("2 unpriced"), "{}", map.caption);
assert!(map.caption.contains("> "), "{}", map.caption);
}
#[test]
fn a_partly_priced_directory_appears_in_both_regions() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/a/node_modules", 4 * 1024 * 1024));
tree.insert(hit("/scan/a/target", Size::Unmeasured, 0));
let view = View::new(tree);
let map = plan(&view, view.tree().root(), pane()).unwrap();
let named: Vec<Kind> = map
.tiles
.iter()
.filter(|tile| tile.name.starts_with("a/"))
.map(|tile| tile.kind)
.collect();
assert!(named.contains(&Kind::Priced), "{named:?}");
assert!(named.contains(&Kind::Unpriced), "{named:?}");
}
#[test]
fn the_map_follows_the_cursor_and_a_leaf_maps_the_level_it_lives_in() {
let mut view = view();
assert_eq!(focus(&view), Some(view.tree().root()));
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Expand);
let a = view.tree().find(std::path::Path::new("/scan/a")).unwrap();
assert_eq!(focus(&view), Some(a));
view.apply(Action::Cursor(Motion::Down));
assert_eq!(focus(&view), Some(a));
let claim = view
.tree()
.find(std::path::Path::new("/scan/a/node_modules"))
.unwrap();
let map = plan(&view, a, pane()).unwrap();
assert!(
map.tiles.iter().any(|tile| tile.id == claim && tile.cursor),
"nothing on the map says where the cursor is: {:?}",
map.tiles
);
}
#[test]
fn a_marked_subtree_is_marked_on_the_map_too() {
let mut view = view();
view.apply(Action::Cursor(Motion::Down));
view.apply(Action::Mark);
let map = plan(&view, view.tree().root(), pane()).unwrap();
let marked: Vec<&str> = map
.tiles
.iter()
.filter(|tile| tile.marked)
.map(|tile| tile.name.as_str())
.collect();
assert_eq!(marked, ["a/node_modules"], "{:?}", map.tiles);
}
#[test]
fn a_filter_maps_what_it_shows_and_not_what_is_there() {
let mut view = view();
view.apply(Action::OpenFilter);
for character in "target".chars() {
view.apply(Action::Type(character));
}
view.apply(Action::Submit);
let map = plan(&view, view.tree().root(), pane()).unwrap();
let names: Vec<&str> = map.tiles.iter().map(|tile| tile.name.as_str()).collect();
assert_eq!(names, ["b/target"], "{:?}", map.tiles);
}
#[test]
fn there_is_no_map_of_a_directory_with_nothing_under_it() {
let empty = View::new(Tree::new("/scan"));
assert_eq!(plan(&empty, empty.tree().root(), pane()), None);
let view = view();
assert_eq!(plan(&view, view.tree().root(), Area::of(0.0, 40.0)), None);
}
}