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 cancel;
|
||||
pub mod config;
|
||||
pub mod duplicate;
|
||||
pub mod export;
|
||||
pub mod inventory;
|
||||
pub mod platform;
|
||||
pub mod scan;
|
||||
pub mod textutil;
|
||||
pub mod treemap;
|
||||
|
||||
/// Boxed error used across mode boundaries.
|
||||
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