From 8cb8e6f2cdab426ca34fad1d95f86eb254f23ba0 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 17:14:24 +0100 Subject: [PATCH] rust: port treemap and duplicate --- rust/src/duplicate.rs | 315 ++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 2 + rust/src/treemap/mod.rs | 302 ++++++++++++++++++++++++++++++++++++++ rust/src/treemap/nav.rs | 141 ++++++++++++++++++ 4 files changed, 760 insertions(+) create mode 100644 rust/src/duplicate.rs create mode 100644 rust/src/treemap/mod.rs create mode 100644 rust/src/treemap/nav.rs diff --git a/rust/src/duplicate.rs b/rust/src/duplicate.rs new file mode 100644 index 0000000..745c644 --- /dev/null +++ b/rust/src/duplicate.rs @@ -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, +} + +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, +} + +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, mut o: Options) -> Option> { + 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::new(); + let mut ncand = 0i64; + { + let t = tree.read().unwrap(); + let mut by_size: HashMap> = 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::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 = 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> { + 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> = 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 { + 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> { + 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 = (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 = 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); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ae752ec..d146d5a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -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; diff --git a/rust/src/treemap/mod.rs b/rust/src/treemap/mod.rs new file mode 100644 index 0000000..c412aea --- /dev/null +++ b/rust/src/treemap/mod.rs @@ -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 { + if bounds.empty() { + return Vec::new(); + } + let mut its: Vec = 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 = 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 { + 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 = (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); + } +} diff --git a/rust/src/treemap/nav.rs b/rust/src/treemap/nav.rs new file mode 100644 index 0000000..86187d7 --- /dev/null +++ b/rust/src/treemap/nav.rs @@ -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 { + 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 = (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:?}"); + } + } + } + } +}