From 0b01fd115b6a985c86391b92e3f178826901e5e7 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Wed, 5 Aug 2026 11:08:39 +0200 Subject: [PATCH] [ADD] geom: the geometry of the output space The compositor puts every output that it shows in one logical plane. This crate holds the arithmetic of that plane, and the crates above it do not repeat any part of it. The crate gives three groups of operations: - A snap operation moves one area a small distance to align it with the areas near it. A keyboard moves an output in steps, and an exact arrangement is difficult without this help. - The adjacency operations find a space or an overlap between the areas. The compositor accepts the two conditions, and the interface reports them. - The bounding operations give the area that holds all the areas. The map of the monitors needs that area to select a view. The position types and the area types come from the "euclid" crate. A unit type separates the logical values from the physical values. The two differ by the scale factor, and a confusion between them is silent. Every operation is a pure function, and the tests examine the operations with random values. A second snap operation on the same area gives no more movement. --- Cargo.lock | 408 ++++++++++++++++++++++ Cargo.toml | 52 +++ crates/dynamonix-geom/Cargo.toml | 19 + crates/dynamonix-geom/src/adjacency.rs | 157 +++++++++ crates/dynamonix-geom/src/axis.rs | 167 +++++++++ crates/dynamonix-geom/src/lib.rs | 24 ++ crates/dynamonix-geom/src/snap.rs | 233 ++++++++++++ crates/dynamonix-geom/src/space.rs | 120 +++++++ crates/dynamonix-geom/tests/properties.rs | 104 ++++++ 9 files changed, 1284 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/dynamonix-geom/Cargo.toml create mode 100644 crates/dynamonix-geom/src/adjacency.rs create mode 100644 crates/dynamonix-geom/src/axis.rs create mode 100644 crates/dynamonix-geom/src/lib.rs create mode 100644 crates/dynamonix-geom/src/snap.rs create mode 100644 crates/dynamonix-geom/src/space.rs create mode 100644 crates/dynamonix-geom/tests/properties.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c523815 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,408 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dynamonix-geom" +version = "0.1.0" +dependencies = [ + "euclid", + "petgraph", + "proptest", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eafcc46 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] +resolver = "3" +members = [ + "crates/dynamonix-geom", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "GPL-3.0-or-later" +repository = "https://git.devtechnica.com/sreedev/dynamonix" +authors = ["dynamonix contributors"] + +[workspace.dependencies] +dynamonix-geom = { path = "crates/dynamonix-geom", version = "0.1.0" } +dynamonix-model = { path = "crates/dynamonix-model", version = "0.1.0" } +dynamonix-plan = { path = "crates/dynamonix-plan", version = "0.1.0" } +dynamonix-ipc = { path = "crates/dynamonix-ipc", version = "0.1.0" } +dynamonix-tui = { path = "crates/dynamonix-tui", version = "0.1.0" } + +niri-ipc = "26.4.0" +euclid = "0.22.14" +thiserror = "2.0.19" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.151" +tokio = { version = "1.53.1", features = ["rt", "net", "io-util", "sync", "time", "macros"] } +tokio-stream = "0.1.17" +ratatui = "0.30.2" +crossterm = { version = "0.29.0", features = ["event-stream"] } +tachyonfx = "0.25.1" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +clap = { version = "4.6.5", features = ["derive"] } +color-eyre = "0.6.5" +directories = "6.0.0" +insta = { version = "1.44.1", features = ["yaml"] } +proptest = "1.9.0" + +[workspace.lints.rust] +missing_docs = "warn" +unsafe_code = "forbid" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +must_use_candidate = "allow" +module_name_repetitions = "allow" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/crates/dynamonix-geom/Cargo.toml b/crates/dynamonix-geom/Cargo.toml new file mode 100644 index 0000000..698e2de --- /dev/null +++ b/crates/dynamonix-geom/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dynamonix-geom" +description = "Geometry primitives for the logical output space of the niri compositor." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +euclid.workspace = true +petgraph = { version = "0.8.3", default-features = false } + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/dynamonix-geom/src/adjacency.rs b/crates/dynamonix-geom/src/adjacency.rs new file mode 100644 index 0000000..455e0c6 --- /dev/null +++ b/crates/dynamonix-geom/src/adjacency.rs @@ -0,0 +1,157 @@ +//! The relations between the rectangles in the logical plane. +//! +//! The compositor lets you put the outputs in any arrangement. An arrangement +//! with a gap or with an overlap can confuse a user. This module finds these +//! conditions. + +use std::collections::BTreeMap; + +use petgraph::unionfind::UnionFind; + +use crate::axis::Side; +use crate::space::Rect; + +/// Tell if the two rectangles share an area. +/// +/// Two rectangles that touch on one edge do not share an area. +#[must_use] +pub fn overlaps(a: Rect, b: Rect) -> bool { + a.intersects(&b) +} + +/// Give the side of the first rectangle that touches the second rectangle. +/// +/// Two rectangles touch if one side of the first rectangle has the same +/// coordinate as the opposite side of the second rectangle. The rectangles must +/// also share a length on the other axis. The function gives `None` if the +/// rectangles do not touch. +#[must_use] +pub fn touches(a: Rect, b: Rect) -> Option { + if overlaps(a, b) { + return None; + } + let shares_rows = a.min.y < b.max.y && b.min.y < a.max.y; + let shares_columns = a.min.x < b.max.x && b.min.x < a.max.x; + Side::all().into_iter().find(|side| { + let aligned = side.coordinate_of(a) == side.opposite().coordinate_of(b); + let overlaps_across = match side.axis() { + crate::axis::Axis::Horizontal => shares_rows, + crate::axis::Axis::Vertical => shares_columns, + }; + aligned && overlaps_across + }) +} + +/// Tell if the two rectangles touch or share an area. +#[must_use] +pub fn is_adjacent(a: Rect, b: Rect) -> bool { + overlaps(a, b) || touches(a, b).is_some() +} + +/// Give the groups of the rectangles that connect to each other. +/// +/// Two rectangles are in the same group if they touch, if they share an area, +/// or if an other rectangle connects them. Each group contains the positions of +/// the rectangles in the given list. The groups and the positions increase. +#[must_use] +pub fn groups(rects: &[Rect]) -> Vec> { + let mut sets = UnionFind::new(rects.len()); + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + if is_adjacent(*a, *b) { + sets.union(i, j); + } + } + } + let mut collected: BTreeMap> = BTreeMap::new(); + for index in 0..rects.len() { + collected.entry(sets.find(index)).or_default().push(index); + } + collected.into_values().collect() +} + +/// Tell if all the rectangles make one connected group. +/// +/// A list with no rectangle and a list with one rectangle are connected. +#[must_use] +pub fn is_connected(rects: &[Rect]) -> bool { + groups(rects).len() <= 1 +} + +/// Give the pairs of the rectangles that share an area. +/// +/// Each pair contains the positions of the two rectangles in the given list. +/// The first position is always less than the second position. +#[must_use] +pub fn overlapping_pairs(rects: &[Rect]) -> Vec<(usize, usize)> { + let mut found = Vec::new(); + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + if overlaps(*a, *b) { + found.push((i, j)); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::{Point, Size, rect_at}; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect { + rect_at(Point::new(x, y), Size::new(w, h)) + } + + #[test] + fn rectangles_that_touch_do_not_overlap() { + let a = rect(0, 0, 100, 100); + let b = rect(100, 0, 100, 100); + assert!(!overlaps(a, b)); + assert_eq!(touches(a, b), Some(Side::Right)); + assert_eq!(touches(b, a), Some(Side::Left)); + } + + #[test] + fn rectangles_that_meet_at_a_corner_do_not_touch() { + let a = rect(0, 0, 100, 100); + let b = rect(100, 100, 100, 100); + assert_eq!(touches(a, b), None); + assert!(!is_adjacent(a, b)); + } + + #[test] + fn a_gap_between_two_rectangles_breaks_the_group() { + let rects = [rect(0, 0, 100, 100), rect(150, 0, 100, 100)]; + assert!(!is_connected(&rects)); + assert_eq!(groups(&rects), vec![vec![0], vec![1]]); + } + + #[test] + fn a_middle_rectangle_joins_two_groups() { + let rects = [ + rect(0, 0, 100, 100), + rect(200, 0, 100, 100), + rect(100, 0, 100, 100), + ]; + assert!(is_connected(&rects)); + assert_eq!(groups(&rects), vec![vec![0, 1, 2]]); + } + + #[test] + fn an_empty_list_is_connected() { + assert!(is_connected(&[])); + assert!(groups(&[]).is_empty()); + } + + #[test] + fn overlapping_pairs_give_each_pair_one_time() { + let rects = [ + rect(0, 0, 100, 100), + rect(50, 50, 100, 100), + rect(500, 500, 10, 10), + ]; + assert_eq!(overlapping_pairs(&rects), vec![(0, 1)]); + } +} diff --git a/crates/dynamonix-geom/src/axis.rs b/crates/dynamonix-geom/src/axis.rs new file mode 100644 index 0000000..724c55b --- /dev/null +++ b/crates/dynamonix-geom/src/axis.rs @@ -0,0 +1,167 @@ +//! The names of the directions and the sides of the logical plane. +//! +//! An [`Axis`] is one of the two directions. A [`Side`] is one of the four +//! sides of a rectangle. Each side is perpendicular to one axis. + +use crate::space::{Rect, center_x, center_y}; + +/// One of the two directions of the logical plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Axis { + /// The direction from the left to the right. + Horizontal, + /// The direction from the top to the bottom. + Vertical, +} + +impl Axis { + /// Give the two axes. + #[must_use] + pub const fn all() -> [Self; 2] { + [Self::Horizontal, Self::Vertical] + } + + /// Give the other axis. + #[must_use] + pub const fn transverse(self) -> Self { + match self { + Self::Horizontal => Self::Vertical, + Self::Vertical => Self::Horizontal, + } + } + + /// Give the two sides that are perpendicular to the axis. + /// + /// The first side is the side with the smaller coordinate. + #[must_use] + pub const fn sides(self) -> [Side; 2] { + match self { + Self::Horizontal => [Side::Left, Side::Right], + Self::Vertical => [Side::Top, Side::Bottom], + } + } + + /// Give the center of the rectangle on the axis. + #[must_use] + pub fn center_of(self, rect: Rect) -> i32 { + match self { + Self::Horizontal => center_x(rect), + Self::Vertical => center_y(rect), + } + } +} + +/// One of the four sides of a rectangle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Side { + /// The side with the smallest horizontal coordinate. + Left, + /// The side with the largest horizontal coordinate. + Right, + /// The side with the smallest vertical coordinate. + Top, + /// The side with the largest vertical coordinate. + Bottom, +} + +impl Side { + /// Give the four sides. + #[must_use] + pub const fn all() -> [Self; 4] { + [Self::Left, Self::Right, Self::Top, Self::Bottom] + } + + /// Give the axis that is perpendicular to the side. + #[must_use] + pub const fn axis(self) -> Axis { + match self { + Self::Left | Self::Right => Axis::Horizontal, + Self::Top | Self::Bottom => Axis::Vertical, + } + } + + /// Give the side that is opposite to this side. + #[must_use] + pub const fn opposite(self) -> Self { + match self { + Self::Left => Self::Right, + Self::Right => Self::Left, + Self::Top => Self::Bottom, + Self::Bottom => Self::Top, + } + } + + /// Tell if the side has the smaller coordinate on its axis. + #[must_use] + pub const fn is_leading(self) -> bool { + matches!(self, Self::Left | Self::Top) + } + + /// Give the coordinate of the side of the rectangle. + #[must_use] + pub fn coordinate_of(self, rect: Rect) -> i32 { + match self { + Self::Left => rect.min.x, + Self::Right => rect.max.x, + Self::Top => rect.min.y, + Self::Bottom => rect.max.y, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::{Point, Size, rect_at}; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect { + rect_at(Point::new(x, y), Size::new(w, h)) + } + + #[test] + fn the_opposite_of_the_opposite_is_the_same_side() { + for side in Side::all() { + assert_eq!(side.opposite().opposite(), side); + } + } + + #[test] + fn a_side_and_its_opposite_share_an_axis() { + for side in Side::all() { + assert_eq!(side.axis(), side.opposite().axis()); + } + } + + #[test] + fn the_transverse_of_the_transverse_is_the_same_axis() { + for axis in Axis::all() { + assert_eq!(axis.transverse().transverse(), axis); + } + } + + #[test] + fn the_sides_of_an_axis_have_that_axis() { + for axis in Axis::all() { + for side in axis.sides() { + assert_eq!(side.axis(), axis); + } + } + } + + #[test] + fn exactly_one_side_of_an_axis_is_leading() { + for axis in Axis::all() { + let leading = axis.sides().iter().filter(|s| s.is_leading()).count(); + assert_eq!(leading, 1); + } + } + + #[test] + fn the_coordinates_of_a_rectangle_match_its_corners() { + let r = rect(10, 20, 100, 50); + assert_eq!(Side::Left.coordinate_of(r), 10); + assert_eq!(Side::Right.coordinate_of(r), 110); + assert_eq!(Side::Top.coordinate_of(r), 20); + assert_eq!(Side::Bottom.coordinate_of(r), 70); + } +} diff --git a/crates/dynamonix-geom/src/lib.rs b/crates/dynamonix-geom/src/lib.rs new file mode 100644 index 0000000..8813c2d --- /dev/null +++ b/crates/dynamonix-geom/src/lib.rs @@ -0,0 +1,24 @@ +//! Geometry primitives for the logical output space of the niri compositor. +//! +//! The niri compositor puts every enabled output in one logical plane. This +//! crate gives the types and the operations for that plane. +//! +//! The crate has three groups of items: +//! +//! - The [`space`] items give the position types and the area types. +//! - The [`axis`] items name the directions and the sides. +//! - The [`snap`] items and the [`adjacency`] items give the operations that +//! arrange the areas. +//! +//! All the operations are pure functions. No operation reads the compositor and +//! no operation writes to the compositor. + +pub mod adjacency; +pub mod axis; +pub mod snap; +pub mod space; + +pub use adjacency::{groups, is_adjacent, is_connected, overlapping_pairs, overlaps, touches}; +pub use axis::{Axis, Side}; +pub use snap::{Contact, Snap, SnapConfig, SnapContact, snap_rect}; +pub use space::{Logical, Point, Rect, Size, Vector, bounding_box, normalize, rect_at}; diff --git a/crates/dynamonix-geom/src/snap.rs b/crates/dynamonix-geom/src/snap.rs new file mode 100644 index 0000000..373b83f --- /dev/null +++ b/crates/dynamonix-geom/src/snap.rs @@ -0,0 +1,233 @@ +//! The operation that aligns one rectangle with a group of other rectangles. +//! +//! A user moves an output with the keyboard or with the mouse. The movement is +//! not accurate. This module moves the output a small distance more. The result +//! is an arrangement with no gap and no overlap. + +use crate::axis::{Axis, Side}; +use crate::space::{Rect, Vector}; + +/// The way that the moved rectangle makes contact with a target rectangle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Contact { + /// One side of the moved rectangle aligns with one side of the target. + Edges { + /// The side of the moved rectangle. + moved: Side, + /// The side of the target rectangle. + target: Side, + }, + /// The center of the moved rectangle aligns with the center of the target. + Centers, +} + +/// A contact between the moved rectangle and one target rectangle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapContact { + /// The position of the target rectangle in the list of the targets. + pub target: usize, + /// The type of the contact. + pub contact: Contact, + /// The distance to move the rectangle to make the contact. + pub delta: i32, +} + +/// The limits for a snap operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapConfig { + /// The largest distance in logical pixels that a snap operation moves. + pub threshold: i32, + /// Set this field to `true` to align the centers of the rectangles. + pub align_centers: bool, +} + +impl Default for SnapConfig { + fn default() -> Self { + Self { + threshold: 64, + align_centers: true, + } + } +} + +/// The result of a snap operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Snap { + /// The movement to apply to the moved rectangle. + pub offset: Vector, + /// The contact on the horizontal axis. + pub horizontal: Option, + /// The contact on the vertical axis. + pub vertical: Option, +} + +impl Default for Snap { + fn default() -> Self { + Self { + offset: Vector::zero(), + horizontal: None, + vertical: None, + } + } +} + +impl Snap { + /// Tell if the operation found a contact on one axis or on the two axes. + #[must_use] + pub const fn is_attached(&self) -> bool { + self.horizontal.is_some() || self.vertical.is_some() + } + + /// Give the position of the moved rectangle after the snap operation. + #[must_use] + pub fn apply(&self, moved: Rect) -> Rect { + moved.translate(self.offset) + } +} + +/// Move a rectangle a small distance to align it with the target rectangles. +/// +/// The operation examines the two axes separately. On each axis the operation +/// finds the contact with the smallest distance. The operation ignores a +/// contact if the distance is more than the threshold. +/// +/// The list of the targets must not contain the moved rectangle. +#[must_use] +pub fn snap_rect(moved: Rect, targets: &[Rect], config: SnapConfig) -> Snap { + let horizontal = best_contact(moved, targets, Axis::Horizontal, config); + let vertical = best_contact(moved, targets, Axis::Vertical, config); + let offset = Vector::new( + horizontal.map_or(0, |contact| contact.delta), + vertical.map_or(0, |contact| contact.delta), + ); + Snap { + offset, + horizontal, + vertical, + } +} + +fn best_contact( + moved: Rect, + targets: &[Rect], + axis: Axis, + config: SnapConfig, +) -> Option { + let mut best: Option = None; + for (index, target) in targets.iter().enumerate() { + for candidate in candidates(moved, *target, axis, index, config) { + if candidate.delta.abs() > config.threshold { + continue; + } + let is_better = best.is_none_or(|current| candidate.delta.abs() < current.delta.abs()); + if is_better { + best = Some(candidate); + } + } + } + best +} + +fn candidates( + moved: Rect, + target: Rect, + axis: Axis, + index: usize, + config: SnapConfig, +) -> Vec { + let mut found = Vec::with_capacity(5); + for moved_side in axis.sides() { + for target_side in axis.sides() { + found.push(SnapContact { + target: index, + contact: Contact::Edges { + moved: moved_side, + target: target_side, + }, + delta: target_side.coordinate_of(target) - moved_side.coordinate_of(moved), + }); + } + } + if config.align_centers { + found.push(SnapContact { + target: index, + contact: Contact::Centers, + delta: axis.center_of(target) - axis.center_of(moved), + }); + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::{Point, Size, rect_at}; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect { + rect_at(Point::new(x, y), Size::new(w, h)) + } + + fn strict() -> SnapConfig { + SnapConfig { + threshold: 64, + align_centers: false, + } + } + + #[test] + fn a_rectangle_near_a_target_moves_to_touch_it() { + let target = rect(0, 0, 1920, 1080); + let moved = rect(1910, 0, 1920, 1080); + let snap = snap_rect(moved, &[target], strict()); + assert_eq!(snap.apply(moved), rect(1920, 0, 1920, 1080)); + } + + #[test] + fn a_distant_rectangle_does_not_move() { + let target = rect(0, 0, 1920, 1080); + let moved = rect(3000, 2000, 1920, 1080); + let snap = snap_rect(moved, &[target], strict()); + assert_eq!(snap.offset, Vector::zero()); + assert!(!snap.is_attached()); + } + + #[test] + fn the_operation_never_moves_more_than_the_threshold() { + let target = rect(0, 0, 1920, 1080); + let config = SnapConfig { + threshold: 10, + align_centers: true, + }; + for x in -400..400 { + let moved = rect(x, 0, 800, 600); + let snap = snap_rect(moved, &[target], config); + assert!(snap.offset.x.abs() <= config.threshold); + assert!(snap.offset.y.abs() <= config.threshold); + } + } + + #[test] + fn the_operation_is_stable_when_it_repeats() { + let target = rect(0, 0, 1920, 1080); + let moved = rect(1908, 12, 1280, 1024); + let first = snap_rect(moved, &[target], SnapConfig::default()).apply(moved); + let second = snap_rect(first, &[target], SnapConfig::default()).apply(first); + assert_eq!(first, second); + } + + #[test] + fn an_empty_target_list_gives_no_contact() { + let moved = rect(10, 10, 100, 100); + let snap = snap_rect(moved, &[], SnapConfig::default()); + assert_eq!(snap.apply(moved), moved); + assert!(!snap.is_attached()); + } + + #[test] + fn the_two_axes_snap_independently() { + let target = rect(0, 0, 1920, 1080); + let moved = rect(1918, 1076, 800, 600); + let snap = snap_rect(moved, &[target], strict()); + assert_eq!(snap.apply(moved).min, Point::new(1920, 1080)); + } +} diff --git a/crates/dynamonix-geom/src/space.rs b/crates/dynamonix-geom/src/space.rs new file mode 100644 index 0000000..1c4f6ea --- /dev/null +++ b/crates/dynamonix-geom/src/space.rs @@ -0,0 +1,120 @@ +//! The position types and the area types of the logical plane. +//! +//! The types in this module use integer coordinates. The niri compositor also +//! uses integer coordinates for the position and the size of each output. + +use euclid::{Box2D, Point2D, Size2D, Vector2D}; + +/// The logical coordinate space of the compositor. +/// +/// The compositor divides the physical pixels of an output by the scale factor. +/// The result is the logical size. The compositor puts all the outputs in one +/// logical plane. This type is a marker. It keeps the logical values separate +/// from the physical values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Logical; + +/// A position in the logical space. +pub type Point = Point2D; + +/// A width and a height in the logical space. +pub type Size = Size2D; + +/// A movement from one position to an other position in the logical space. +pub type Vector = Vector2D; + +/// An area in the logical space. +/// +/// The area has a minimum corner and a maximum corner. The minimum corner is at +/// the top left. The maximum corner is at the bottom right. +pub type Rect = Box2D; + +/// Make a rectangle from a position and a size. +#[must_use] +pub fn rect_at(origin: Point, size: Size) -> Rect { + Rect::from_origin_and_size(origin, size) +} + +/// Give the horizontal center of the rectangle. +/// +/// The function uses integer division. The result can be one pixel less than +/// the true center. +#[must_use] +pub fn center_x(rect: Rect) -> i32 { + rect.min.x + (rect.max.x - rect.min.x) / 2 +} + +/// Give the vertical center of the rectangle. +/// +/// The function uses integer division. The result can be one pixel less than +/// the true center. +#[must_use] +pub fn center_y(rect: Rect) -> i32 { + rect.min.y + (rect.max.y - rect.min.y) / 2 +} + +/// Give the smallest rectangle that contains all the given rectangles. +/// +/// The function gives `None` if the list is empty. +#[must_use] +pub fn bounding_box(rects: &[Rect]) -> Option { + rects.iter().copied().reduce(|a, b| a.union(&b)) +} + +/// Move all the rectangles so that the minimum corner of the bounding box is at +/// the origin. +/// +/// The function keeps the position of each rectangle relative to the other +/// rectangles. +#[must_use] +pub fn normalize(rects: &[Rect]) -> Vec { + let Some(bounds) = bounding_box(rects) else { + return Vec::new(); + }; + let shift = Vector::new(-bounds.min.x, -bounds.min.y); + rects.iter().map(|rect| rect.translate(shift)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect { + rect_at(Point::new(x, y), Size::new(w, h)) + } + + #[test] + fn rect_at_gives_the_requested_corners() { + let r = rect(10, 20, 100, 50); + assert_eq!(r.min, Point::new(10, 20)); + assert_eq!(r.max, Point::new(110, 70)); + } + + #[test] + fn center_is_between_the_edges() { + let r = rect(0, 0, 100, 50); + assert_eq!(center_x(r), 50); + assert_eq!(center_y(r), 25); + } + + #[test] + fn bounding_box_of_an_empty_list_is_none() { + assert_eq!(bounding_box(&[]), None); + } + + #[test] + fn bounding_box_contains_every_rectangle() { + let rects = [rect(0, 0, 100, 100), rect(200, 50, 100, 100)]; + let bounds = bounding_box(&rects).expect("the list is not empty"); + assert_eq!(bounds, rect(0, 0, 300, 150)); + } + + #[test] + fn normalize_moves_the_bounding_box_to_the_origin() { + let rects = [rect(-50, -20, 100, 100), rect(50, 80, 100, 100)]; + let moved = normalize(&rects); + let bounds = bounding_box(&moved).expect("the list is not empty"); + assert_eq!(bounds.min, Point::zero()); + assert_eq!(moved[1].min - moved[0].min, rects[1].min - rects[0].min); + } +} diff --git a/crates/dynamonix-geom/tests/properties.rs b/crates/dynamonix-geom/tests/properties.rs new file mode 100644 index 0000000..bd8a193 --- /dev/null +++ b/crates/dynamonix-geom/tests/properties.rs @@ -0,0 +1,104 @@ +//! The properties that the geometry operations must always obey. + +use std::collections::BTreeSet; + +use dynamonix_geom::{ + Point, Rect, Side, Size, SnapConfig, bounding_box, groups, normalize, rect_at, snap_rect, + touches, +}; +use proptest::prelude::*; + +fn any_rect() -> impl Strategy { + (-4000i32..4000, -4000i32..4000, 1i32..3000, 1i32..3000) + .prop_map(|(x, y, w, h)| rect_at(Point::new(x, y), Size::new(w, h))) +} + +fn any_rects(max: usize) -> impl Strategy> { + prop::collection::vec(any_rect(), 0..max) +} + +proptest! { + #[test] + fn a_snap_never_moves_more_than_the_threshold( + moved in any_rect(), + targets in any_rects(6), + threshold in 0i32..200, + ) { + let config = SnapConfig { threshold, align_centers: true }; + let snap = snap_rect(moved, &targets, config); + prop_assert!(snap.offset.x.abs() <= threshold); + prop_assert!(snap.offset.y.abs() <= threshold); + } + + #[test] + fn a_second_snap_changes_nothing( + moved in any_rect(), + targets in any_rects(6), + threshold in 0i32..200, + ) { + let config = SnapConfig { threshold, align_centers: true }; + let once = snap_rect(moved, &targets, config).apply(moved); + let twice = snap_rect(once, &targets, config).apply(once); + prop_assert_eq!(once, twice); + } + + #[test] + fn a_snap_keeps_the_size( + moved in any_rect(), + targets in any_rects(6), + ) { + let snap = snap_rect(moved, &targets, SnapConfig::default()); + prop_assert_eq!(snap.apply(moved).size(), moved.size()); + } + + #[test] + fn the_groups_contain_every_rectangle_one_time(rects in any_rects(8)) { + let found: Vec = groups(&rects).into_iter().flatten().collect(); + let unique: BTreeSet = found.iter().copied().collect(); + prop_assert_eq!(found.len(), rects.len()); + prop_assert_eq!(unique.len(), rects.len()); + } + + #[test] + fn a_touch_is_symmetric(a in any_rect(), b in any_rect()) { + match (touches(a, b), touches(b, a)) { + (Some(side), Some(other)) => prop_assert_eq!(side, other.opposite()), + (None, None) => {} + (first, second) => prop_assert!(false, "{first:?} does not match {second:?}"), + } + } + + #[test] + fn a_rectangle_never_touches_itself(a in any_rect()) { + prop_assert_eq!(touches(a, a), None::); + } + + #[test] + fn normalize_puts_the_bounding_box_at_the_origin(rects in any_rects(8)) { + let moved = normalize(&rects); + prop_assert_eq!(moved.len(), rects.len()); + if let Some(bounds) = bounding_box(&moved) { + prop_assert_eq!(bounds.min, Point::zero()); + } + } + + #[test] + fn normalize_keeps_the_relative_positions(rects in any_rects(8)) { + let moved = normalize(&rects); + for index in 1..rects.len() { + prop_assert_eq!( + moved[index].min - moved[0].min, + rects[index].min - rects[0].min + ); + } + } + + #[test] + fn the_bounding_box_contains_every_rectangle(rects in any_rects(8)) { + if let Some(bounds) = bounding_box(&rects) { + for rect in &rects { + prop_assert!(bounds.contains_box(rect)); + } + } + } +} -- 2.51.2