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)); + } + } + } +}