rust: port treemap and duplicate
This commit is contained in:
@@ -0,0 +1,315 @@
|
|||||||
|
//! Finds files with identical content in stages:
|
||||||
|
//!
|
||||||
|
//! 1. group by exact size (inventory only, no I/O)
|
||||||
|
//! 2. hash three 16 KiB samples (start, middle, end) of each candidate
|
||||||
|
//! 3. full SHA-256 of files whose samples still collide
|
||||||
|
//!
|
||||||
|
//! Only stage 3 establishes identity. Files are never declared duplicates by
|
||||||
|
//! name or size alone. Reading files may update their access times.
|
||||||
|
|
||||||
|
use crate::cancel::Cancel;
|
||||||
|
use crate::inventory::{Kind, NodeId, Tree, FLAG_HARDLINK_DUP};
|
||||||
|
use crate::platform;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
/// A set of files verified to have identical content.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Group {
|
||||||
|
pub size: i64,
|
||||||
|
pub hash: [u8; 32],
|
||||||
|
pub files: Vec<NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
/// The bytes reclaimable by keeping one copy.
|
||||||
|
pub fn wasted(&self) -> i64 {
|
||||||
|
self.size * (self.files.len() as i64 - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports activity.
|
||||||
|
#[derive(Clone, Default, Debug)]
|
||||||
|
pub struct Progress {
|
||||||
|
pub stage: String,
|
||||||
|
pub candidates: i64, // files sharing a size with another file
|
||||||
|
pub bytes_to_hash: i64,
|
||||||
|
pub bytes_hashed: i64,
|
||||||
|
pub skipped: i64, // unreadable or changed during hashing
|
||||||
|
pub verified_groups: i64,
|
||||||
|
pub candidate_groups: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunes the search.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct Options {
|
||||||
|
pub min_size: i64, // ignore files smaller than this (default 1 byte)
|
||||||
|
pub workers: usize, // concurrent readers (default 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Cand {
|
||||||
|
id: NodeId,
|
||||||
|
path: PathBuf,
|
||||||
|
size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_SIZE: i64 = 16 << 10;
|
||||||
|
|
||||||
|
type HashFn = fn(&Finder, &Cand) -> Option<[u8; 32]>;
|
||||||
|
|
||||||
|
/// Runs a search and exposes live progress.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Finder {
|
||||||
|
hashed: AtomicI64,
|
||||||
|
to_hash: AtomicI64,
|
||||||
|
skipped: AtomicI64,
|
||||||
|
p: Mutex<Progress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Finder {
|
||||||
|
/// A copy of the current progress.
|
||||||
|
pub fn progress(&self) -> Progress {
|
||||||
|
let mut p = self.p.lock().unwrap().clone();
|
||||||
|
p.bytes_hashed = self.hashed.load(Ordering::Relaxed);
|
||||||
|
p.bytes_to_hash = self.to_hash.load(Ordering::Relaxed);
|
||||||
|
p.skipped = self.skipped.load(Ordering::Relaxed);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage(&self, s: &str, f: impl FnOnce(&mut Progress)) {
|
||||||
|
let mut p = self.p.lock().unwrap();
|
||||||
|
p.stage = s.to_string();
|
||||||
|
f(&mut p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches the tree, taking its own read lock only while collecting
|
||||||
|
/// candidates. None if cancelled.
|
||||||
|
pub fn find(&self, cancel: &Cancel, tree: &RwLock<Tree>, mut o: Options) -> Option<Vec<Group>> {
|
||||||
|
o.min_size = o.min_size.max(1);
|
||||||
|
if o.workers < 1 {
|
||||||
|
o.workers = 4;
|
||||||
|
}
|
||||||
|
self.stage("grouping by size", |_| {});
|
||||||
|
let mut groups: Vec<Vec<Cand>> = Vec::new();
|
||||||
|
let mut ncand = 0i64;
|
||||||
|
{
|
||||||
|
let t = tree.read().unwrap();
|
||||||
|
let mut by_size: HashMap<i64, Vec<Cand>> = HashMap::new();
|
||||||
|
t.walk(t.root(), |id, n| {
|
||||||
|
if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size {
|
||||||
|
by_size.entry(n.size).or_default().push(Cand { id, path: PathBuf::new(), size: n.size });
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
for mut g in by_size.into_values() {
|
||||||
|
if g.len() > 1 {
|
||||||
|
for c in &mut g {
|
||||||
|
c.path = t.path(c.id);
|
||||||
|
}
|
||||||
|
ncand += g.len() as i64;
|
||||||
|
groups.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.stage("sampling", |p| {
|
||||||
|
p.candidates = ncand;
|
||||||
|
p.candidate_groups = groups.len() as i64;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage 2: sample hashes.
|
||||||
|
let mut sampled: Vec<Vec<Cand>> = Vec::new();
|
||||||
|
for g in groups {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if g[0].size <= 3 * SAMPLE_SIZE {
|
||||||
|
sampled.push(g); // a sample would be the whole file
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sampled.extend(self.split(cancel, &g, o.workers, sample_hash).into_values().filter(|v| v.len() > 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 3: full hashes.
|
||||||
|
self.to_hash.store(sampled.iter().map(|g| g[0].size * g.len() as i64).sum(), Ordering::Relaxed);
|
||||||
|
self.stage("hashing", |_| {});
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for g in &sampled {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for (hash, v) in self.split(cancel, g, o.workers, full_hash) {
|
||||||
|
if v.len() > 1 {
|
||||||
|
let mut files: Vec<NodeId> = v.iter().map(|c| c.id).collect();
|
||||||
|
files.sort_unstable();
|
||||||
|
out.push(Group { size: v[0].size, hash, files });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| b.wasted().cmp(&a.wasted()).then(a.files[0].cmp(&b.files[0])));
|
||||||
|
self.stage("done", |p| p.verified_groups = out.len() as i64);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes every candidate with a bounded pool of reader threads and
|
||||||
|
/// buckets the successes by hash. Stops starting new reads once cancelled.
|
||||||
|
fn split(&self, cancel: &Cancel, g: &[Cand], workers: usize, f: HashFn) -> HashMap<[u8; 32], Vec<Cand>> {
|
||||||
|
let next = AtomicUsize::new(0);
|
||||||
|
let results = Mutex::new(Vec::with_capacity(g.len()));
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for _ in 0..workers.min(g.len()) {
|
||||||
|
s.spawn(|| loop {
|
||||||
|
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if i >= g.len() || cancel.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match f(self, &g[i]) {
|
||||||
|
Some(sum) => results.lock().unwrap().push((i, sum)),
|
||||||
|
None => {
|
||||||
|
self.skipped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut res = results.into_inner().unwrap();
|
||||||
|
res.sort_unstable_by_key(|r| r.0);
|
||||||
|
let mut by: HashMap<[u8; 32], Vec<Cand>> = HashMap::new();
|
||||||
|
for (i, sum) in res {
|
||||||
|
by.entry(sum).or_default().push(g[i].clone());
|
||||||
|
}
|
||||||
|
by
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a candidate (never following symlinks or blocking on FIFOs) and
|
||||||
|
/// checks it is still a regular file of the inventoried size.
|
||||||
|
fn open(c: &Cand) -> Option<File> {
|
||||||
|
let fh = platform::open_content(&c.path).ok()?;
|
||||||
|
let md = fh.metadata().ok()?;
|
||||||
|
(md.is_file() && md.len() as i64 == c.size).then_some(fh)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_hash(_: &Finder, c: &Cand) -> Option<[u8; 32]> {
|
||||||
|
let fh = open(c)?;
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; SAMPLE_SIZE as usize];
|
||||||
|
for off in [0, c.size / 2 - SAMPLE_SIZE / 2, c.size - SAMPLE_SIZE] {
|
||||||
|
match fh.read_exact_at(&mut buf, off as u64) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {} // shrank; stage 3 rejects it
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
h.update(&buf);
|
||||||
|
}
|
||||||
|
Some(h.finalize().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_hash(f: &Finder, c: &Cand) -> Option<[u8; 32]> {
|
||||||
|
let mut fh = open(c)?;
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; 32 << 10];
|
||||||
|
let mut n = 0i64;
|
||||||
|
loop {
|
||||||
|
match fh.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(k) => {
|
||||||
|
h.update(&buf[..k]);
|
||||||
|
n += k as i64;
|
||||||
|
f.hashed.fetch_add(k as i64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(n == c.size).then(|| h.finalize().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::scan::{self, tests::TempDir};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn scan(root: &Path, workers: usize) -> Arc<RwLock<Tree>> {
|
||||||
|
let s = scan::start(root.as_os_str().as_bytes(), scan::Options { workers, ..Default::default() }, Cancel::new()).unwrap();
|
||||||
|
s.wait();
|
||||||
|
s.tree.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find() {
|
||||||
|
let root = TempDir::new("dup");
|
||||||
|
let big: Vec<u8> = (0..200_000usize).map(|i| (i * 7) as u8).collect();
|
||||||
|
let w = |name: &str, b: &[u8]| std::fs::write(root.0.join(name), b).unwrap();
|
||||||
|
w("a.bin", &big);
|
||||||
|
w("b.bin", &big);
|
||||||
|
// Same size, same samples, different middle-of-nowhere byte: must not match.
|
||||||
|
let mut c = big.clone();
|
||||||
|
c[20_000] ^= 1;
|
||||||
|
w("c.bin", &c);
|
||||||
|
w("small1", b"hello");
|
||||||
|
w("small2", b"hello");
|
||||||
|
w("small3", b"world"); // same size, different content
|
||||||
|
|
||||||
|
let tree = scan(&root.0, 2);
|
||||||
|
let f = Finder::default();
|
||||||
|
let groups = f.find(&Cancel::new(), &tree, Options::default()).unwrap();
|
||||||
|
assert_eq!(groups.len(), 2);
|
||||||
|
let t = tree.read().unwrap();
|
||||||
|
let names = |g: &Group| {
|
||||||
|
let mut v: Vec<String> = g.files.iter().map(|&id| t.node(id).name_str().into_owned()).collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
};
|
||||||
|
assert_eq!(names(&groups[0]), ["a.bin", "b.bin"], "big group");
|
||||||
|
assert_eq!(groups[0].size, 200_000);
|
||||||
|
assert_eq!(names(&groups[1]), ["small1", "small2"], "small group");
|
||||||
|
let p = f.progress();
|
||||||
|
assert!(p.candidates == 6 && p.verified_groups == 2, "progress {p:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate replaced by a FIFO (or a symlink to one) after the scan
|
||||||
|
/// must be skipped, not block the search forever in open(2).
|
||||||
|
#[test]
|
||||||
|
fn find_skips_swapped_fifo() {
|
||||||
|
let root = TempDir::new("dupfifo");
|
||||||
|
let other = TempDir::new("dupfifo2");
|
||||||
|
for n in ["a", "b", "c"] {
|
||||||
|
std::fs::write(root.0.join(n), b"same").unwrap();
|
||||||
|
}
|
||||||
|
let tree = scan(&root.0, 1);
|
||||||
|
let mkfifo = |p: &Path| {
|
||||||
|
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).unwrap();
|
||||||
|
unsafe { libc::mkfifo(c.as_ptr(), 0o644) == 0 }
|
||||||
|
};
|
||||||
|
std::fs::remove_file(root.0.join("b")).unwrap();
|
||||||
|
if !mkfifo(&root.0.join("b")) {
|
||||||
|
eprintln!("mkfifo unsupported; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fifo2 = other.0.join("p");
|
||||||
|
assert!(mkfifo(&fifo2));
|
||||||
|
std::fs::remove_file(root.0.join("c")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&fifo2, root.0.join("c")).unwrap();
|
||||||
|
|
||||||
|
let f = Arc::new(Finder::default());
|
||||||
|
let (tx, rx) = crossbeam_channel::bounded(1);
|
||||||
|
let (f2, t2) = (f.clone(), tree.clone());
|
||||||
|
std::thread::spawn(move || tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some()));
|
||||||
|
assert!(rx.recv_timeout(Duration::from_secs(5)).expect("duplicate search blocked on a FIFO"));
|
||||||
|
assert_eq!(f.progress().skipped, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
pub mod brand;
|
pub mod brand;
|
||||||
pub mod cancel;
|
pub mod cancel;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod duplicate;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod textutil;
|
pub mod textutil;
|
||||||
|
pub mod treemap;
|
||||||
|
|
||||||
/// Boxed error used across mode boundaries.
|
/// Boxed error used across mode boundaries.
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Pure geometry: squarified layout on a cell grid, level-of-detail
|
||||||
|
//! selection and spatial neighbour search. No knowledge of filesystems or
|
||||||
|
//! terminals.
|
||||||
|
|
||||||
|
mod nav;
|
||||||
|
|
||||||
|
pub use nav::{neighbour, Direction};
|
||||||
|
|
||||||
|
/// An integer cell rectangle.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Rect {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub w: i32,
|
||||||
|
pub h: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rect {
|
||||||
|
/// Whether the rectangle has no cells.
|
||||||
|
pub fn empty(&self) -> bool {
|
||||||
|
self.w <= 0 || self.h <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// w*h.
|
||||||
|
pub fn area(&self) -> i32 {
|
||||||
|
self.w.max(0) * self.h.max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the cell (x, y) is inside.
|
||||||
|
pub fn contains(&self, x: i32, y: i32) -> bool {
|
||||||
|
x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shrinks by n cells on each side.
|
||||||
|
pub fn inset(&self, n: i32) -> Rect {
|
||||||
|
Rect { x: self.x + n, y: self.y + n, w: (self.w - 2 * n).max(0), h: (self.h - 2 * n).max(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something to lay out. Size must be > 0 to receive space.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: i64,
|
||||||
|
pub size: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A laid-out item.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Block {
|
||||||
|
pub id: i64,
|
||||||
|
pub rect: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lays items out inside bounds using the squarified treemap algorithm
|
||||||
|
/// (Bruls, Huizing, van Wijk). Items with non-positive or non-finite size are
|
||||||
|
/// ignored. The output partitions bounds exactly: rectangle edges are snapped
|
||||||
|
/// to the grid by rounding shared float edges, so there are no gaps and no
|
||||||
|
/// overlaps. Items that round to zero width or height are omitted. The result
|
||||||
|
/// is deterministic for a given input set regardless of input order.
|
||||||
|
pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
|
||||||
|
if bounds.empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut its: Vec<Item> = items.iter().copied().filter(|it| it.size > 0.0 && it.size.is_finite()).collect();
|
||||||
|
let total: f64 = its.iter().map(|it| it.size).sum();
|
||||||
|
if its.is_empty() || total <= 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id)));
|
||||||
|
let scale = bounds.w as f64 * bounds.h as f64 / total;
|
||||||
|
let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect();
|
||||||
|
let frs = layout(&areas, bounds.x as f64, bounds.y as f64, bounds.w as f64, bounds.h as f64);
|
||||||
|
let mut out = Vec::with_capacity(its.len());
|
||||||
|
for (it, f) in its.iter().zip(frs) {
|
||||||
|
let (x0, y0) = (f[0].round() as i32, f[1].round() as i32);
|
||||||
|
let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w);
|
||||||
|
let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
|
||||||
|
if x1 > x0 && y1 > y0 {
|
||||||
|
out.push(Block { id: it.id, rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worst aspect ratio of a row with the given sum, largest and smallest
|
||||||
|
/// areas laid along a side of length side.
|
||||||
|
fn worst(sum: f64, largest: f64, smallest: f64, side: f64) -> f64 {
|
||||||
|
let (s2, w2) = (sum * sum, side * side);
|
||||||
|
(w2 * largest / s2).max(s2 / (w2 * smallest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Float rectangles [x, y, w, h] for areas sorted descending.
|
||||||
|
fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<[f64; 4]> {
|
||||||
|
let n = areas.len();
|
||||||
|
let mut out = vec![[0.0; 4]; n];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < n {
|
||||||
|
let short = w.min(h);
|
||||||
|
let mut j = i + 1;
|
||||||
|
let mut sum = areas[i];
|
||||||
|
let mut cur = worst(sum, areas[i], areas[i], short);
|
||||||
|
while j < n {
|
||||||
|
let s2 = sum + areas[j];
|
||||||
|
let nw = worst(s2, areas[i], areas[j], short);
|
||||||
|
if nw > cur {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cur = nw;
|
||||||
|
sum = s2;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let last = j == n;
|
||||||
|
if w >= h {
|
||||||
|
let mut thick = sum / h;
|
||||||
|
if last || thick > w {
|
||||||
|
thick = w;
|
||||||
|
}
|
||||||
|
let mut yy = y;
|
||||||
|
for k in i..j {
|
||||||
|
let hh = if k == j - 1 { y + h - yy } else { areas[k] / sum * h };
|
||||||
|
out[k] = [x, yy, thick, hh];
|
||||||
|
yy += hh;
|
||||||
|
}
|
||||||
|
x += thick;
|
||||||
|
w -= thick;
|
||||||
|
} else {
|
||||||
|
let mut thick = sum / w;
|
||||||
|
if last || thick > h {
|
||||||
|
thick = h;
|
||||||
|
}
|
||||||
|
let mut xx = x;
|
||||||
|
for k in i..j {
|
||||||
|
let ww = if k == j - 1 { x + w - xx } else { areas[k] / sum * w };
|
||||||
|
out[k] = [xx, y, ww, thick];
|
||||||
|
xx += ww;
|
||||||
|
}
|
||||||
|
y += thick;
|
||||||
|
h -= thick;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many leading items of a size-sorted (descending) list deserve their
|
||||||
|
/// own block when area cells represent total: items are kept while each
|
||||||
|
/// would get at least min_area cells and fewer than max_items have been
|
||||||
|
/// kept. The remainder should be merged into one group block.
|
||||||
|
pub fn visible(sizes: &[f64], total: f64, area: i32, min_area: i32, max_items: usize) -> usize {
|
||||||
|
if total <= 0.0 || area <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let cells = |s: f64| s / total * area as f64;
|
||||||
|
let mut k = 0;
|
||||||
|
while k < sizes.len() && k < max_items {
|
||||||
|
if cells(sizes[k]) < min_area as f64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
// Grouping a single leftover item gains nothing; show it directly if it
|
||||||
|
// gets at least one cell.
|
||||||
|
if k + 1 == sizes.len() && k < max_items && cells(sizes[k]) >= 1.0 {
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The block containing the cell (x, y).
|
||||||
|
pub fn at(blocks: &[Block], x: i32, y: i32) -> Option<i64> {
|
||||||
|
blocks.iter().find(|b| b.rect.contains(x, y)).map(|b| b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn r(x: i32, y: i32, w: i32, h: i32) -> Rect {
|
||||||
|
Rect { x, y, w, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn it(id: i64, size: f64) -> Item {
|
||||||
|
Item { id, size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small deterministic PRNG for property checks (splitmix64).
|
||||||
|
pub struct Rng(u64);
|
||||||
|
impl Rng {
|
||||||
|
pub fn new(seed: u64) -> Rng {
|
||||||
|
Rng(seed)
|
||||||
|
}
|
||||||
|
pub fn intn(&mut self, n: u64) -> u64 {
|
||||||
|
self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
|
||||||
|
let mut z = self.0;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||||
|
(z ^ (z >> 31)) % n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_partition(blocks: &[Block], b: Rect, exact: bool) {
|
||||||
|
let mut grid = vec![false; (b.w * b.h) as usize];
|
||||||
|
for bl in blocks {
|
||||||
|
let br = bl.rect;
|
||||||
|
assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}");
|
||||||
|
assert!(br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, "block {bl:?} outside bounds {b:?}");
|
||||||
|
for y in br.y..br.y + br.h {
|
||||||
|
for x in br.x..br.x + br.w {
|
||||||
|
let i = ((y - b.y) * b.w + (x - b.x)) as usize;
|
||||||
|
assert!(!grid[i], "overlap at {x},{y}");
|
||||||
|
grid[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exact {
|
||||||
|
if let Some(i) = grid.iter().position(|v| !v) {
|
||||||
|
panic!("gap at {},{}", b.x + i as i32 % b.w, b.y + i as i32 / b.w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn squarify_basic() {
|
||||||
|
let b = r(2, 3, 60, 20);
|
||||||
|
let items = [it(1, 6.0), it(2, 6.0), it(3, 4.0), it(4, 3.0), it(5, 2.0), it(6, 2.0), it(7, 1.0)];
|
||||||
|
let blocks = squarify(&items, b);
|
||||||
|
assert_eq!(blocks.len(), items.len());
|
||||||
|
check_partition(&blocks, b, true);
|
||||||
|
// Areas roughly proportional.
|
||||||
|
for bl in &blocks {
|
||||||
|
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
|
||||||
|
let got = bl.rect.area() as f64;
|
||||||
|
assert!(got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, "block {} area {got} want ~{want}", bl.id);
|
||||||
|
}
|
||||||
|
// Deterministic regardless of input order.
|
||||||
|
let mut rev = items.to_vec();
|
||||||
|
rev.reverse();
|
||||||
|
assert_eq!(blocks, squarify(&rev, b), "layout depends on input order");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn squarify_edge_cases() {
|
||||||
|
assert!(squarify(&[], r(0, 0, 10, 10)).is_empty());
|
||||||
|
assert!(squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), "zero sizes should produce nothing");
|
||||||
|
assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty());
|
||||||
|
for b in [r(0, 0, 0, 0), r(0, 0, 1, 1), r(0, 0, 1, 50), r(0, 0, 50, 1), r(0, 0, -3, 4)] {
|
||||||
|
let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b);
|
||||||
|
if !b.empty() {
|
||||||
|
check_partition(&bl, b, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One enormous item and many tiny ones.
|
||||||
|
let mut items = vec![it(0, 1e15)];
|
||||||
|
items.extend((1..500).map(|i| it(i, 1.0)));
|
||||||
|
check_partition(&squarify(&items, r(0, 0, 80, 24)), r(0, 0, 80, 24), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_recomputes() {
|
||||||
|
let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)];
|
||||||
|
for b in [r(0, 0, 80, 24), r(0, 0, 120, 40), r(0, 0, 300, 80), r(0, 0, 70, 20), r(0, 0, 180, 50)] {
|
||||||
|
let bl = squarify(&items, b);
|
||||||
|
check_partition(&bl, b, true);
|
||||||
|
assert_eq!(bl.len(), 3, "{b:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Go fuzz target's property, run over fixed seeds.
|
||||||
|
#[test]
|
||||||
|
fn squarify_random_partitions() {
|
||||||
|
let mut rng = Rng::new(1);
|
||||||
|
for _ in 0..300 {
|
||||||
|
let (w, h, n) = (rng.intn(400) as i32, rng.intn(150) as i32, rng.intn(3000) as usize);
|
||||||
|
let items: Vec<Item> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let bits = rng.intn(40) + 1;
|
||||||
|
it(i as i64, rng.intn(1 << bits) as f64)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let b = r(rng.intn(10) as i32, rng.intn(10) as i32, w, h);
|
||||||
|
let bl = squarify(&items, b);
|
||||||
|
if !b.empty() && !bl.is_empty() {
|
||||||
|
check_partition(&bl, b, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_counts() {
|
||||||
|
let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0];
|
||||||
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3);
|
||||||
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items");
|
||||||
|
assert_eq!(visible(&[10.0, 1.0], 11.0, 1100, 200, 10), 2, "single leftover");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn at_finds_block() {
|
||||||
|
let bl = [Block { id: 1, rect: r(0, 0, 5, 5) }, Block { id: 2, rect: r(5, 0, 5, 5) }];
|
||||||
|
assert_eq!(at(&bl, 6, 2), Some(2));
|
||||||
|
assert_eq!(at(&bl, 10, 2), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
use super::Block;
|
||||||
|
|
||||||
|
/// An arrow-key direction.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Direction {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the block a user expects to reach by pressing an arrow key from the
|
||||||
|
/// block with id sel:
|
||||||
|
///
|
||||||
|
/// 1. Only blocks entirely beyond the selected block's edge in the
|
||||||
|
/// direction of travel are candidates.
|
||||||
|
/// 2. Blocks whose span overlaps the selected block's orthogonal span are
|
||||||
|
/// strongly preferred over all others.
|
||||||
|
/// 3. Then the smallest gap along the direction of travel wins.
|
||||||
|
/// 4. Then the largest overlap with the selected block's span.
|
||||||
|
/// 5. Then the block whose orthogonal span is closest to the selected
|
||||||
|
/// block's centre line (0 if it contains it).
|
||||||
|
/// 6. Ties break by position (top, then left) and finally id.
|
||||||
|
///
|
||||||
|
/// None if there is no block in that direction.
|
||||||
|
pub fn neighbour(blocks: &[Block], sel: i64, d: Direction) -> Option<i64> {
|
||||||
|
let s = blocks.iter().find(|b| b.id == sel)?.rect;
|
||||||
|
// Lexicographic score, smaller is better; overlap is negated so larger wins.
|
||||||
|
let mut best: Option<(i32, i32, i32, i32, i32, i32, i64)> = None;
|
||||||
|
for b in blocks {
|
||||||
|
if b.id == sel {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = b.rect;
|
||||||
|
let (ok, gap, lo, hi, clo, chi) = match d {
|
||||||
|
Direction::Right => (c.x >= s.x + s.w, c.x - (s.x + s.w), s.y, s.y + s.h, c.y, c.y + c.h),
|
||||||
|
Direction::Left => (c.x + c.w <= s.x, s.x - (c.x + c.w), s.y, s.y + s.h, c.y, c.y + c.h),
|
||||||
|
Direction::Down => (c.y >= s.y + s.h, c.y - (s.y + s.h), s.x, s.x + s.w, c.x, c.x + c.w),
|
||||||
|
Direction::Up => (c.y + c.h <= s.y, s.y - (c.y + c.h), s.x, s.x + s.w, c.x, c.x + c.w),
|
||||||
|
};
|
||||||
|
if !ok {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ovl = hi.min(chi) - lo.max(clo);
|
||||||
|
let (tier, ovl) = if ovl > 0 { (0, ovl) } else { (1, 0) };
|
||||||
|
// Distance (doubled, to stay integral) from the selected centre line
|
||||||
|
// to the candidate's span; 0 if the span contains it.
|
||||||
|
let centre = lo + hi;
|
||||||
|
let off = if centre < 2 * clo {
|
||||||
|
2 * clo - centre
|
||||||
|
} else if centre > 2 * chi {
|
||||||
|
centre - 2 * chi
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let sc = (tier, gap, -ovl, off, c.y, c.x, b.id);
|
||||||
|
if best.is_none_or(|b| sc < b) {
|
||||||
|
best = Some(sc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|b| b.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::tests::{it, r, Rng};
|
||||||
|
use super::super::*;
|
||||||
|
use super::Direction::*;
|
||||||
|
|
||||||
|
fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block {
|
||||||
|
Block { id, rect: r(x, y, w, h) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// ┌───────┬───────┐
|
||||||
|
// │ A │ B │
|
||||||
|
// ├───┬───┴───────┤
|
||||||
|
// │ C │ D │
|
||||||
|
// └───┴───────────┘
|
||||||
|
#[test]
|
||||||
|
fn neighbour_basic() {
|
||||||
|
let a = blk(1, 0, 0, 8, 4);
|
||||||
|
let b = blk(2, 8, 0, 8, 4);
|
||||||
|
let c = blk(3, 0, 4, 4, 4);
|
||||||
|
let d = blk(4, 4, 4, 12, 4);
|
||||||
|
let bs = [d, c, b, a]; // order must not matter
|
||||||
|
let cases = [
|
||||||
|
(1, Right, Some(2)), (2, Left, Some(1)), (1, Down, Some(3)), (2, Down, Some(4)),
|
||||||
|
(3, Right, Some(4)), (4, Left, Some(3)), (3, Up, Some(1)), (4, Up, Some(2)),
|
||||||
|
(1, Left, None), (1, Up, None), (4, Right, None), (4, Down, None),
|
||||||
|
];
|
||||||
|
for (from, dir, want) in cases {
|
||||||
|
assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neighbour_prefers_adjacent_over_array_order() {
|
||||||
|
// S is on the left; R is directly right; F is far right and lower but
|
||||||
|
// appears first in the slice.
|
||||||
|
let s = blk(1, 0, 0, 10, 5);
|
||||||
|
let rr = blk(2, 10, 0, 10, 5);
|
||||||
|
let f = blk(3, 10, 20, 30, 5);
|
||||||
|
assert_eq!(neighbour(&[f, s, rr], 1, Right), Some(2));
|
||||||
|
// Tall block on the left with three stacked blocks to the right: pick
|
||||||
|
// the one at the centre line.
|
||||||
|
let t = blk(10, 0, 0, 10, 9);
|
||||||
|
let r1 = blk(11, 10, 0, 10, 3);
|
||||||
|
let r2 = blk(12, 10, 3, 10, 3);
|
||||||
|
let r3 = blk(13, 10, 6, 10, 3);
|
||||||
|
assert_eq!(neighbour(&[r3, r1, r2, t], 10, Right), Some(12), "centre preference");
|
||||||
|
assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neighbour_random_layouts_always_adjacent() {
|
||||||
|
let mut rng = Rng::new(7);
|
||||||
|
for iter in 0..200 {
|
||||||
|
let n = 2 + rng.intn(30) as i64;
|
||||||
|
let items: Vec<Item> = (0..n).map(|i| it(i, (1 + rng.intn(1000)) as f64)).collect();
|
||||||
|
let bl = squarify(&items, r(0, 0, 120, 40));
|
||||||
|
for s in &bl {
|
||||||
|
for d in [Left, Right, Up, Down] {
|
||||||
|
let Some(id) = neighbour(&bl, s.id, d) else { continue };
|
||||||
|
let c = bl.iter().find(|b| b.id == id).unwrap().rect;
|
||||||
|
let s = s.rect;
|
||||||
|
// In a partition, if any block touches the edge in direction d
|
||||||
|
// with overlapping span, the chosen one must touch too.
|
||||||
|
let touch = match d {
|
||||||
|
Right => c.x == s.x + s.w,
|
||||||
|
Left => c.x + c.w == s.x,
|
||||||
|
Down => c.y == s.y + s.h,
|
||||||
|
Up => c.y + c.h == s.y,
|
||||||
|
};
|
||||||
|
assert!(touch, "iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user