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