From aa5ae2da7f1299d61a6b632c78e968361a84ce92 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Wed, 5 Aug 2026 11:09:14 +0200 Subject: [PATCH] [ADD] plan: the changes that make a layout The compositor accepts one change to one output at a time. This crate compares a snapshot with a layout and gives the smallest group of the changes. Each change belongs to one of four stages, and the stages keep this order: 1. Turn on the outputs. 2. Set the mode, the scale factor and the transform. 3. Set the position. 4. Turn off the outputs. The last stage comes last for safety. An order that turns off an output first can leave the computer with no output, and a user cannot then correct the layout. The action type of the "niri-ipc" crate has no comparison. The crate therefore keeps a change type of its own. The program compares that type, shows it to a user, and then converts it into an action. The crate also examines a layout. Two conditions are errors: a layout that turns off all the outputs, and a layout that gives a mode which the monitor does not have. An overlap between two outputs and a space between two groups are warnings, because the compositor accepts them. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/dynamonix-plan/Cargo.toml | 20 ++ crates/dynamonix-plan/src/change.rs | 165 ++++++++++++++++ crates/dynamonix-plan/src/check.rs | 279 ++++++++++++++++++++++++++++ crates/dynamonix-plan/src/diff.rs | 266 ++++++++++++++++++++++++++ crates/dynamonix-plan/src/lib.rs | 20 ++ 7 files changed, 761 insertions(+) create mode 100644 crates/dynamonix-plan/Cargo.toml create mode 100644 crates/dynamonix-plan/src/change.rs create mode 100644 crates/dynamonix-plan/src/check.rs create mode 100644 crates/dynamonix-plan/src/diff.rs create mode 100644 crates/dynamonix-plan/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1b56f49..7f28f16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dynamonix-plan" +version = "0.1.0" +dependencies = [ + "dynamonix-geom", + "dynamonix-model", + "niri-ipc", + "proptest", +] + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 37ca624..2c1f722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "3" members = [ "crates/dynamonix-geom", "crates/dynamonix-model", + "crates/dynamonix-plan", ] [workspace.package] diff --git a/crates/dynamonix-plan/Cargo.toml b/crates/dynamonix-plan/Cargo.toml new file mode 100644 index 0000000..7df38de --- /dev/null +++ b/crates/dynamonix-plan/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dynamonix-plan" +description = "The engine that compares the wanted layout with the current layout." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +dynamonix-geom.workspace = true +dynamonix-model.workspace = true +niri-ipc.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/dynamonix-plan/src/change.rs b/crates/dynamonix-plan/src/change.rs new file mode 100644 index 0000000..1bbf0fa --- /dev/null +++ b/crates/dynamonix-plan/src/change.rs @@ -0,0 +1,165 @@ +//! One change to one output. +//! +//! The compositor accepts one change at a time. The `niri-ipc` crate does not +//! compare its action type. This module gives a type that the program can +//! compare, show to a user, and then change into an action of the compositor. + +use std::fmt; + +use niri_ipc::{ModeToSet, OutputAction, PositionToSet, ScaleToSet, Transform, VrrToSet}; + +use dynamonix_model::TransformExt; + +/// The order of the changes in a plan. +/// +/// The program sends the changes of one stage before the changes of the next +/// stage. This order prevents a condition with no output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Stage { + /// The program turns on the outputs first. + Enable, + /// Then the program sets the mode, the scale and the transform. + Appearance, + /// Then the program sets the position. + Placement, + /// The program turns off the outputs last. + Disable, +} + +/// One change to one output. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Change { + /// Turn on the output. + Enable, + /// Turn off the output. + Disable, + /// Set the mode of the output. + SetMode(ModeToSet), + /// Set the scale factor of the output. + SetScale(ScaleToSet), + /// Set the rotation and the reflection of the output. + SetTransform(Transform), + /// Set the position of the output. + SetPosition(PositionToSet), + /// Set the variable refresh rate of the output. + SetVrr(VrrToSet), +} + +impl Change { + /// Give the stage of the change. + #[must_use] + pub const fn stage(&self) -> Stage { + match self { + Self::Enable => Stage::Enable, + Self::Disable => Stage::Disable, + Self::SetPosition(_) => Stage::Placement, + Self::SetMode(_) | Self::SetScale(_) | Self::SetTransform(_) | Self::SetVrr(_) => { + Stage::Appearance + } + } + } + + /// Change this item into an action of the compositor. + #[must_use] + pub const fn to_action(&self) -> OutputAction { + match *self { + Self::Enable => OutputAction::On, + Self::Disable => OutputAction::Off, + Self::SetMode(mode) => OutputAction::Mode { mode }, + Self::SetScale(scale) => OutputAction::Scale { scale }, + Self::SetTransform(transform) => OutputAction::Transform { transform }, + Self::SetPosition(position) => OutputAction::Position { position }, + Self::SetVrr(vrr) => OutputAction::Vrr { vrr }, + } + } +} + +impl fmt::Display for Change { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Enable => write!(formatter, "turn on"), + Self::Disable => write!(formatter, "turn off"), + Self::SetMode(ModeToSet::Automatic) => write!(formatter, "set the mode to automatic"), + Self::SetMode(ModeToSet::Specific(mode)) => match mode.refresh { + Some(refresh) => write!( + formatter, + "set the mode to {}x{} at {refresh:.3} Hz", + mode.width, mode.height + ), + None => write!(formatter, "set the mode to {}x{}", mode.width, mode.height), + }, + Self::SetScale(ScaleToSet::Automatic) => { + write!(formatter, "set the scale factor to automatic") + } + Self::SetScale(ScaleToSet::Specific(scale)) => { + write!(formatter, "set the scale factor to {scale}") + } + Self::SetTransform(transform) => { + write!(formatter, "set the transform to {}", transform.label()) + } + Self::SetPosition(PositionToSet::Automatic) => { + write!(formatter, "set the position to automatic") + } + Self::SetPosition(PositionToSet::Specific(position)) => { + write!(formatter, "move to x={} y={}", position.x, position.y) + } + Self::SetVrr(vrr) if vrr.on_demand => { + write!(formatter, "set the refresh rate to variable on demand") + } + Self::SetVrr(vrr) if vrr.vrr => { + write!(formatter, "turn on the variable refresh rate") + } + Self::SetVrr(_) => write!(formatter, "turn off the variable refresh rate"), + } + } +} + +#[cfg(test)] +mod tests { + use niri_ipc::ConfiguredPosition; + + use super::*; + + #[test] + fn the_program_turns_on_an_output_before_it_turns_off_an_output() { + assert!(Change::Enable.stage() < Change::Disable.stage()); + } + + #[test] + fn the_program_sets_the_size_before_the_position() { + assert!( + Change::SetScale(ScaleToSet::Automatic).stage() + < Change::SetPosition(PositionToSet::Automatic).stage() + ); + } + + #[test] + fn the_program_sets_the_position_before_it_turns_off_an_output() { + assert!(Change::SetPosition(PositionToSet::Automatic).stage() < Change::Disable.stage()); + } + + #[test] + fn every_change_has_a_text_for_a_user() { + let changes = [ + Change::Enable, + Change::Disable, + Change::SetScale(ScaleToSet::Specific(1.5)), + Change::SetTransform(Transform::_90), + Change::SetPosition(PositionToSet::Specific(ConfiguredPosition { x: 10, y: 20 })), + Change::SetVrr(VrrToSet { + vrr: true, + on_demand: false, + }), + ]; + for change in changes { + assert!(!change.to_string().is_empty()); + } + } + + #[test] + fn the_text_of_a_position_shows_the_coordinates() { + let change = + Change::SetPosition(PositionToSet::Specific(ConfiguredPosition { x: 10, y: 20 })); + assert_eq!(change.to_string(), "move to x=10 y=20"); + } +} diff --git a/crates/dynamonix-plan/src/check.rs b/crates/dynamonix-plan/src/check.rs new file mode 100644 index 0000000..e89859d --- /dev/null +++ b/crates/dynamonix-plan/src/check.rs @@ -0,0 +1,279 @@ +//! The safety examination of a layout. +//! +//! A layout can put the computer in a condition that a user cannot correct. An +//! example is a layout that turns off all the outputs. This module finds these +//! conditions before the program sends the layout to the compositor. + +use std::fmt; + +use dynamonix_geom::{Rect, groups, overlapping_pairs}; +use dynamonix_model::{Layout, Snapshot, scale}; +use niri_ipc::ScaleToSet; + +/// The importance of a problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Severity { + /// The layout is usable. The result can be different from the expectation. + Warning, + /// The program must not send the layout. + Error, +} + +/// A condition in a layout that needs the attention of a user. +#[derive(Debug, Clone, PartialEq)] +pub enum Problem { + /// The layout turns off all the outputs. + NoEnabledOutput, + /// The output has no mode that matches the request. + UnknownMode { + /// The name of the output. + output: String, + }, + /// Two outputs cover the same area of the logical space. + Overlap { + /// The name of the first output. + first: String, + /// The name of the second output. + second: String, + }, + /// The outputs make more than one group with a space between the groups. + Separated { + /// The number of the groups. + count: usize, + }, + /// The scale factor is outside of the limits of the compositor. + ScaleOutOfRange { + /// The name of the output. + output: String, + /// The scale factor in the layout. + requested: f64, + }, + /// The layout names an output that the compositor does not report. + NotConnected { + /// The name of the output. + output: String, + }, +} + +impl Problem { + /// Give the importance of the problem. + #[must_use] + pub const fn severity(&self) -> Severity { + match self { + Self::NoEnabledOutput | Self::UnknownMode { .. } => Severity::Error, + Self::Overlap { .. } + | Self::Separated { .. } + | Self::ScaleOutOfRange { .. } + | Self::NotConnected { .. } => Severity::Warning, + } + } +} + +impl fmt::Display for Problem { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoEnabledOutput => write!( + formatter, + "the layout turns off all the outputs; keep one output on" + ), + Self::UnknownMode { output } => { + write!(formatter, "the output {output} does not have the mode") + } + Self::Overlap { first, second } => write!( + formatter, + "the outputs {first} and {second} cover the same area" + ), + Self::Separated { count } => write!( + formatter, + "the outputs make {count} groups with a space between them" + ), + Self::ScaleOutOfRange { output, requested } => write!( + formatter, + "the scale factor {requested} of the output {output} is outside \ + of the limits {} to {}; the compositor will change it", + scale::MINIMUM, + scale::MAXIMUM + ), + Self::NotConnected { output } => write!( + formatter, + "the output {output} is not connected; the compositor will keep the change" + ), + } + } +} + +/// Examine a layout and give all the problems. +/// +/// The program must not send the layout if one problem has the severity +/// [`Severity::Error`]. +#[must_use] +pub fn check(desired: &Layout, snapshot: &Snapshot) -> Vec { + let mut problems = Vec::new(); + if !desired.is_empty() && desired.enabled_count() == 0 { + problems.push(Problem::NoEnabledOutput); + } + for (name, config) in desired.iter() { + let Some(output) = snapshot.get(name) else { + problems.push(Problem::NotConnected { + output: name.clone(), + }); + continue; + }; + if config.enabled && config.resolved_mode(output).is_none() { + problems.push(Problem::UnknownMode { + output: name.clone(), + }); + } + if let ScaleToSet::Specific(requested) = config.scale + && !(scale::MINIMUM..=scale::MAXIMUM).contains(&requested) + { + problems.push(Problem::ScaleOutOfRange { + output: name.clone(), + requested, + }); + } + } + let placed = desired.rects(snapshot); + let names: Vec<&String> = placed.iter().map(|(name, _)| name).collect(); + let areas: Vec = placed.iter().map(|(_, rect)| *rect).collect(); + for (first, second) in overlapping_pairs(&areas) { + problems.push(Problem::Overlap { + first: names[first].clone(), + second: names[second].clone(), + }); + } + let count = groups(&areas).len(); + if count > 1 { + problems.push(Problem::Separated { count }); + } + problems +} + +/// Tell if the program can send the layout to the compositor. +#[must_use] +pub fn is_safe(problems: &[Problem]) -> bool { + problems + .iter() + .all(|problem| problem.severity() < Severity::Error) +} + +#[cfg(test)] +mod tests { + use dynamonix_geom::Point; + use niri_ipc::{LogicalOutput, Mode, Output, Transform}; + + use super::*; + + fn output(name: &str, x: i32) -> Output { + Output { + name: name.to_owned(), + make: "Make".to_owned(), + model: "Model".to_owned(), + serial: Some(name.to_owned()), + physical_size: None, + modes: vec![Mode { + width: 1920, + height: 1080, + refresh_rate: 60_000, + is_preferred: true, + }], + current_mode: Some(0), + is_custom_mode: false, + vrr_supported: false, + vrr_enabled: false, + logical: Some(LogicalOutput { + x, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + transform: Transform::Normal, + }), + } + } + + #[test] + fn a_layout_that_matches_the_snapshot_has_no_problem() { + let snapshot = Snapshot::new([output("DP-1", 0), output("DP-2", 1920)]); + let problems = check(&snapshot.to_layout(), &snapshot); + assert_eq!(problems, Vec::new()); + assert!(is_safe(&problems)); + } + + #[test] + fn a_layout_with_no_enabled_output_is_an_error() { + let snapshot = Snapshot::new([output("DP-1", 0)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-1", |config| config.with_enabled(false)); + let problems = check(&layout, &snapshot); + assert!(problems.contains(&Problem::NoEnabledOutput)); + assert!(!is_safe(&problems)); + } + + #[test] + fn an_overlap_is_a_warning() { + let snapshot = Snapshot::new([output("DP-1", 0), output("DP-2", 1920)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-2", |config| config.with_origin(Point::new(960, 0))); + let problems = check(&layout, &snapshot); + assert!( + problems + .iter() + .any(|p| matches!(p, Problem::Overlap { .. })) + ); + assert!(is_safe(&problems)); + } + + #[test] + fn a_space_between_the_outputs_is_a_warning() { + let snapshot = Snapshot::new([output("DP-1", 0), output("DP-2", 1920)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-2", |config| config.with_origin(Point::new(4000, 0))); + let problems = check(&layout, &snapshot); + assert!(problems.contains(&Problem::Separated { count: 2 })); + assert!(is_safe(&problems)); + } + + #[test] + fn an_output_that_is_not_connected_is_a_warning() { + let snapshot = Snapshot::new([output("DP-1", 0)]); + let mut layout = snapshot.to_layout(); + layout.insert("HDMI-A-1", dynamonix_model::OutputConfig::default()); + let problems = check(&layout, &snapshot); + assert!(problems.contains(&Problem::NotConnected { + output: "HDMI-A-1".to_owned() + })); + assert!(is_safe(&problems)); + } + + #[test] + fn an_empty_layout_has_no_problem() { + let snapshot = Snapshot::new([output("DP-1", 0)]); + assert_eq!(check(&Layout::default(), &snapshot), Vec::new()); + } + + #[test] + fn every_problem_has_a_text_for_a_user() { + let problems = [ + Problem::NoEnabledOutput, + Problem::UnknownMode { + output: "DP-1".to_owned(), + }, + Problem::Overlap { + first: "DP-1".to_owned(), + second: "DP-2".to_owned(), + }, + Problem::Separated { count: 2 }, + Problem::ScaleOutOfRange { + output: "DP-1".to_owned(), + requested: 20.0, + }, + Problem::NotConnected { + output: "DP-9".to_owned(), + }, + ]; + for problem in problems { + assert!(!problem.to_string().is_empty()); + } + } +} diff --git a/crates/dynamonix-plan/src/diff.rs b/crates/dynamonix-plan/src/diff.rs new file mode 100644 index 0000000..6d904ae --- /dev/null +++ b/crates/dynamonix-plan/src/diff.rs @@ -0,0 +1,266 @@ +//! The comparison between the wanted layout and the current layout. +//! +//! The program does not send the full layout to the compositor. It sends only +//! the changes. This module finds those changes and puts them in a safe order. + +use niri_ipc::{ModeToSet, Output, PositionToSet}; + +use dynamonix_model::{Layout, OutputConfig, OutputExt, Snapshot}; + +use crate::change::{Change, Stage}; + +/// One change to one named output. +#[derive(Debug, Clone, PartialEq)] +pub struct Step { + /// The name of the output that the compositor must change. + pub output: String, + /// The change that the compositor must make. + pub change: Change, +} + +impl Step { + /// Give the stage of the step. + #[must_use] + pub const fn stage(&self) -> Stage { + self.change.stage() + } +} + +/// The full list of the changes that make the wanted layout. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Plan { + steps: Vec, +} + +impl Plan { + /// Make a plan from a list of the steps. + /// + /// The function puts the steps in the order of their stages. Two steps of + /// the same stage keep the order of the list. + #[must_use] + pub fn new(steps: impl IntoIterator) -> Self { + let mut collected: Vec = steps.into_iter().collect(); + collected.sort_by_key(Step::stage); + Self { steps: collected } + } + + /// Give the steps in the order that the program must send them. + #[must_use] + pub fn steps(&self) -> &[Step] { + &self.steps + } + + /// Give the number of the steps. + #[must_use] + pub fn len(&self) -> usize { + self.steps.len() + } + + /// Tell if the layout needs no change. + #[must_use] + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// Give the names of the outputs that the plan changes. + #[must_use] + pub fn outputs(&self) -> Vec { + let mut names: Vec = self.steps.iter().map(|step| step.output.clone()).collect(); + names.dedup(); + names + } +} + +/// Find the changes that make the wanted layout from the current layout. +/// +/// The function reads the capabilities of each output from the snapshot. It +/// gives an empty plan if the layout matches the snapshot. +/// +/// The function also makes steps for an output that is not connected now. The +/// compositor keeps such a change and applies it when the output connects. +#[must_use] +pub fn diff(current: &Snapshot, desired: &Layout) -> Plan { + let mut steps = Vec::new(); + for (name, config) in desired.iter() { + for change in changes_for(current.get(name), config) { + steps.push(Step { + output: name.clone(), + change, + }); + } + } + Plan::new(steps) +} + +fn changes_for(output: Option<&Output>, config: &OutputConfig) -> Vec { + let Some(output) = output else { + return full_settings(config); + }; + if !config.enabled { + return if output.is_enabled() { + vec![Change::Disable] + } else { + Vec::new() + }; + } + if !output.is_enabled() { + let mut changes = vec![Change::Enable]; + changes.extend(full_settings(config).into_iter().skip(1)); + return changes; + } + let mut changes = Vec::new(); + if config.resolved_mode(output) != output.current_mode() { + changes.push(Change::SetMode(config.mode)); + } + if Some(config.resolved_scale(output)) != output.current_scale() { + changes.push(Change::SetScale(config.scale)); + } + if Some(config.transform) != output.current_transform() { + changes.push(Change::SetTransform(config.transform)); + } + if config.vrr.vrr != output.vrr_enabled { + changes.push(Change::SetVrr(config.vrr)); + } + if let PositionToSet::Specific(_) = config.position + && config.origin() != output.position() + { + changes.push(Change::SetPosition(config.position)); + } + changes +} + +fn full_settings(config: &OutputConfig) -> Vec { + if !config.enabled { + return vec![Change::Disable]; + } + let mut changes = vec![Change::Enable]; + if config.mode != ModeToSet::Automatic { + changes.push(Change::SetMode(config.mode)); + } + changes.push(Change::SetScale(config.scale)); + changes.push(Change::SetTransform(config.transform)); + changes.push(Change::SetVrr(config.vrr)); + changes.push(Change::SetPosition(config.position)); + changes +} + +#[cfg(test)] +mod tests { + use dynamonix_geom::Point; + use dynamonix_model::Scale; + use niri_ipc::{LogicalOutput, Mode, Transform}; + + use super::*; + + fn output(name: &str, x: i32, enabled: bool) -> Output { + Output { + name: name.to_owned(), + make: "Make".to_owned(), + model: "Model".to_owned(), + serial: Some(name.to_owned()), + physical_size: None, + modes: vec![ + Mode { + width: 2560, + height: 1440, + refresh_rate: 60_000, + is_preferred: true, + }, + Mode { + width: 1920, + height: 1080, + refresh_rate: 60_000, + is_preferred: false, + }, + ], + current_mode: Some(0), + is_custom_mode: false, + vrr_supported: true, + vrr_enabled: false, + logical: enabled.then_some(LogicalOutput { + x, + y: 0, + width: 2560, + height: 1440, + scale: 1.0, + transform: Transform::Normal, + }), + } + } + + #[test] + fn a_layout_that_matches_the_snapshot_needs_no_change() { + let snapshot = Snapshot::new([output("DP-1", 0, true), output("DP-2", 2560, false)]); + let plan = diff(&snapshot, &snapshot.to_layout()); + assert!(plan.is_empty(), "the plan has {} steps", plan.len()); + } + + #[test] + fn a_change_of_the_position_gives_one_step() { + let snapshot = Snapshot::new([output("DP-1", 0, true)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-1", |config| config.with_origin(Point::new(100, 200))); + let plan = diff(&snapshot, &layout); + assert_eq!(plan.len(), 1); + assert_eq!(plan.steps()[0].output, "DP-1"); + assert_eq!(plan.steps()[0].change.stage(), Stage::Placement); + } + + #[test] + fn a_change_of_the_scale_gives_one_step() { + let snapshot = Snapshot::new([output("DP-1", 0, true)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-1", |config| { + config.with_scale(Scale::new(2.0).expect("valid")) + }); + let plan = diff(&snapshot, &layout); + assert_eq!(plan.len(), 1); + assert_eq!(plan.steps()[0].change.stage(), Stage::Appearance); + } + + #[test] + fn the_program_turns_off_an_output_in_the_last_stage() { + let snapshot = Snapshot::new([output("DP-1", 0, true), output("DP-2", 2560, true)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-2", |config| config.with_enabled(false)); + layout.update("DP-1", |config| config.with_origin(Point::new(0, 100))); + let plan = diff(&snapshot, &layout); + let stages: Vec = plan.steps().iter().map(Step::stage).collect(); + assert_eq!(stages, vec![Stage::Placement, Stage::Disable]); + } + + #[test] + fn the_program_turns_on_an_output_in_the_first_stage() { + let snapshot = Snapshot::new([output("DP-1", 0, true), output("DP-2", 0, false)]); + let mut layout = snapshot.to_layout(); + layout.update("DP-2", |config| { + config + .with_enabled(true) + .with_origin(Point::new(2560, 0)) + .with_scale(Scale::ONE) + }); + let plan = diff(&snapshot, &layout); + assert_eq!(plan.steps()[0].change, Change::Enable); + assert_eq!(plan.steps()[0].stage(), Stage::Enable); + } + + #[test] + fn an_output_that_is_not_connected_still_gives_steps() { + let snapshot = Snapshot::new([output("DP-1", 0, true)]); + let mut layout = snapshot.to_layout(); + layout.insert( + "HDMI-A-1", + OutputConfig::default().with_origin(Point::new(2560, 0)), + ); + let plan = diff(&snapshot, &layout); + assert!(plan.outputs().contains(&"HDMI-A-1".to_owned())); + } + + #[test] + fn a_second_plan_after_the_first_plan_is_empty() { + let snapshot = Snapshot::new([output("DP-1", 0, true)]); + let layout = snapshot.to_layout(); + assert!(diff(&snapshot, &layout).is_empty()); + assert!(diff(&snapshot, &layout.normalized(&snapshot)).is_empty()); + } +} diff --git a/crates/dynamonix-plan/src/lib.rs b/crates/dynamonix-plan/src/lib.rs new file mode 100644 index 0000000..3418815 --- /dev/null +++ b/crates/dynamonix-plan/src/lib.rs @@ -0,0 +1,20 @@ +//! The engine that compares the wanted layout with the current layout. +//! +//! The program does not send a full layout to the compositor. The compositor +//! accepts one change to one output at a time. This crate finds the smallest +//! group of the changes that makes the wanted layout, and puts the changes in a +//! safe order. +//! +//! The crate also examines a layout for the conditions that can make a computer +//! difficult to use. See [`check`]. +//! +//! No item in this crate speaks to the compositor. All the operations are pure +//! functions. + +pub mod change; +pub mod check; +pub mod diff; + +pub use change::{Change, Stage}; +pub use check::{Problem, Severity, check, is_safe}; +pub use diff::{Plan, Step, diff}; -- 2.51.2