diff --git a/Cargo.lock b/Cargo.lock index b8831be..6038e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,74 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dynamonix-geom" version = "0.1.0" @@ -54,6 +122,7 @@ dependencies = [ name = "dynamonix-ipc" version = "0.1.0" dependencies = [ + "dynamonix-geom", "dynamonix-model", "dynamonix-plan", "niri-ipc", @@ -84,6 +153,18 @@ dependencies = [ "proptest", ] +[[package]] +name = "dynamonix-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "dynamonix-geom", + "dynamonix-ipc", + "dynamonix-model", + "dynamonix-plan", + "niri-ipc", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -133,6 +214,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + [[package]] name = "getrandom" version = "0.3.4" @@ -199,6 +286,27 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -212,6 +320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys", ] @@ -241,6 +350,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -360,12 +492,30 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -391,6 +541,18 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -434,6 +596,43 @@ dependencies = [ "zmij", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "socket2" version = "0.6.5" @@ -537,6 +736,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -561,6 +766,28 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index b81697c..af5b328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/dynamonix-model", "crates/dynamonix-plan", "crates/dynamonix-ipc", + "crates/dynamonix-tui", ] [workspace.package] diff --git a/crates/dynamonix-tui/Cargo.toml b/crates/dynamonix-tui/Cargo.toml new file mode 100644 index 0000000..1814807 --- /dev/null +++ b/crates/dynamonix-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dynamonix-tui" +description = "The terminal interface that arranges the niri outputs." +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 +dynamonix-plan.workspace = true +dynamonix-ipc.workspace = true +niri-ipc.workspace = true +crossterm.workspace = true + +[lints] +workspace = true diff --git a/crates/dynamonix-tui/src/app.rs b/crates/dynamonix-tui/src/app.rs new file mode 100644 index 0000000..94da15c --- /dev/null +++ b/crates/dynamonix-tui/src/app.rs @@ -0,0 +1,971 @@ +//! The state of the interface and the operations that change it. +//! +//! Every operation in this module is a pure change of the state. No operation +//! speaks to the compositor. The `run` module does that work. This division +//! lets the tests examine all the behavior of the interface. + +use dynamonix_geom::{Point, Rect, Side, SnapConfig, Vector, rect_at, snap_rect}; +use dynamonix_model::{ + Layout, ModeExt, OutputConfig, OutputExt, Scale, Snapshot, TransformExt, mode, scale, +}; +use dynamonix_plan::{Plan, Problem, Severity, check, diff, is_safe}; +use niri_ipc::{ModeToSet, Transform}; + +/// The part of the interface that has the attention of the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Focus { + /// The map of the monitors. + #[default] + Map, + /// The list of the outputs. + Outputs, +} + +/// The importance of a message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// The message gives information. + Info, + /// The message reports a good result. + Good, + /// The message needs the attention of the user. + Caution, + /// The message reports a failure. + Danger, +} + +/// A message for the user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + /// The importance of the message. + pub level: Level, + /// The text of the message. + pub text: String, +} + +impl Status { + /// Make a message. + #[must_use] + pub fn new(level: Level, text: impl Into) -> Self { + Self { + level, + text: text.into(), + } + } +} + +impl Default for Status { + fn default() -> Self { + Self::new(Level::Info, "Move an output with the arrow keys.") + } +} + +/// An operation that the user asks for. +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + /// Stop the program. + Quit, + /// Give the attention to the next part of the interface. + FocusNext, + /// Select the next output. + SelectNext, + /// Select the previous output. + SelectPrevious, + /// Move the selected output on the side by the distance. + Move { + /// The side that the output moves to. + side: Side, + /// The distance in logical pixels. + distance: i32, + /// Set this field to `true` to let the output align with the outputs + /// near it. + snap: bool, + }, + /// Select the next mode of the selected output. + NextMode, + /// Select the previous mode of the selected output. + PreviousMode, + /// Make the scale factor larger. + ScaleUp, + /// Make the scale factor smaller. + ScaleDown, + /// Turn the selected output 90 degrees counter-clockwise. + TurnLeft, + /// Turn the selected output 90 degrees clockwise. + TurnRight, + /// Turn on the selected output, or turn it off. + ToggleEnabled, + /// Turn on the variable refresh rate, or turn it off. + ToggleVrr, + /// Move the arrangement to the origin. + Normalize, + /// Put all the outputs in one row. + ArrangeRow, + /// Remove all the changes that the user made. + Reset, + /// Send the changes to the compositor. + Apply, + /// Read a new state of the outputs. + Refresh(Box), + /// Show the result of an operation that spoke to the compositor. + Report(Status), +} + +/// An operation that the interface cannot do without the compositor. +#[derive(Debug, Clone, PartialEq)] +pub enum Effect { + /// Send the plan to the compositor. + Apply(Box), + /// Stop the program. + Quit, +} + +/// The largest distance in logical pixels that a snap operation moves. +/// +/// This distance must be less than [`crate::keys::STEP`]. One normal key press +/// then always moves an output away from a contact. A larger value would let a +/// snap operation cancel the movement of the key press. +pub const SNAP_THRESHOLD: i32 = 32; + +const _: () = assert!( + crate::keys::STEP > SNAP_THRESHOLD, + "a snap operation would cancel every normal key press" +); + +/// The movement of one output that is in progress. +/// +/// The interface keeps the true position of the output while the user moves it. +/// A snap operation changes the position that the compositor receives, but it +/// does not change the true position. Without this record, a snap operation +/// would return the output to its first position after each key press, and the +/// output would never move. +#[derive(Debug, Clone, PartialEq)] +struct Drag { + name: String, + start: Point, + raw: Point, +} + +/// The state of the interface. +#[derive(Debug, Clone)] +pub struct App { + snapshot: Snapshot, + draft: Layout, + order: Vec, + selected: usize, + focus: Focus, + status: Status, + problems: Vec, + ghost: Option, + drag: Option, + snap: SnapConfig, +} + +impl App { + /// Make the state from the first reading of the outputs. + #[must_use] + pub fn new(snapshot: Snapshot) -> Self { + let draft = snapshot.to_layout(); + let order = snapshot.names(); + let mut app = Self { + snapshot, + draft, + order, + selected: 0, + focus: Focus::default(), + status: Status::default(), + problems: Vec::new(), + ghost: None, + drag: None, + snap: SnapConfig { + threshold: SNAP_THRESHOLD, + align_centers: true, + }, + }; + app.examine(); + app + } + + /// Give the state of the outputs that the compositor reported. + #[must_use] + pub const fn snapshot(&self) -> &Snapshot { + &self.snapshot + } + + /// Give the layout that the user made. + #[must_use] + pub const fn draft(&self) -> &Layout { + &self.draft + } + + /// Give the names of the outputs in the order of the list. + #[must_use] + pub fn order(&self) -> &[String] { + &self.order + } + + /// Give the position of the selected output in the list. + #[must_use] + pub const fn selected_index(&self) -> usize { + self.selected + } + + /// Give the name of the selected output. + #[must_use] + pub fn selected(&self) -> Option<&str> { + self.order.get(self.selected).map(String::as_str) + } + + /// Give the part of the interface that has the attention of the user. + #[must_use] + pub const fn focus(&self) -> Focus { + self.focus + } + + /// Give the message for the user. + #[must_use] + pub const fn status(&self) -> &Status { + &self.status + } + + /// Give the problems of the layout that the user made. + #[must_use] + pub fn problems(&self) -> &[Problem] { + &self.problems + } + + /// Give the first position of the output that the user moves. + #[must_use] + pub const fn ghost(&self) -> Option { + self.ghost + } + + /// Give the changes that the compositor must make. + #[must_use] + pub fn plan(&self) -> Plan { + diff(&self.snapshot, &self.draft) + } + + /// Tell if the user made a change that the compositor does not have. + #[must_use] + pub fn has_changes(&self) -> bool { + !self.plan().is_empty() + } + + /// Give the configuration of the selected output. + #[must_use] + pub fn selected_config(&self) -> Option<&OutputConfig> { + self.draft.get(self.selected()?) + } + + /// Give the area of each output in the layout that the user made. + #[must_use] + pub fn rects(&self) -> Vec<(String, Rect)> { + self.draft.rects(&self.snapshot) + } + + /// Change the state for the operation. + pub fn handle(&mut self, command: Command) -> Option { + match command { + Command::Quit => return Some(Effect::Quit), + Command::FocusNext => { + self.focus = match self.focus { + Focus::Map => Focus::Outputs, + Focus::Outputs => Focus::Map, + }; + } + Command::SelectNext => self.step_selection(1), + Command::SelectPrevious => self.step_selection(-1), + Command::Move { + side, + distance, + snap, + } => self.move_selected(side, distance, snap), + Command::NextMode => self.step_mode(1), + Command::PreviousMode => self.step_mode(-1), + Command::ScaleUp => self.step_scale(1), + Command::ScaleDown => self.step_scale(-1), + Command::TurnLeft => self.turn(true), + Command::TurnRight => self.turn(false), + Command::ToggleEnabled => self.toggle_enabled(), + Command::ToggleVrr => self.toggle_vrr(), + Command::Normalize => { + self.release(); + self.draft = self.draft.normalized(&self.snapshot); + self.status = Status::new(Level::Info, "The arrangement moved to the origin."); + self.examine(); + } + Command::ArrangeRow => self.arrange_row(), + Command::Reset => { + self.draft = self.snapshot.to_layout(); + self.release(); + self.status = Status::new(Level::Info, "The program removed all the changes."); + self.examine(); + } + Command::Apply => return self.request_apply(), + Command::Refresh(snapshot) => self.refresh(*snapshot), + Command::Report(status) => self.status = status, + } + None + } + + fn request_apply(&mut self) -> Option { + let plan = self.plan(); + if plan.is_empty() { + self.status = Status::new(Level::Info, "The compositor already has this layout."); + return None; + } + if !is_safe(&self.problems) { + self.status = Status::new( + Level::Danger, + "Correct the errors before you send the layout.", + ); + return None; + } + self.status = Status::new( + Level::Info, + format!("The program sends {} changes.", plan.len()), + ); + Some(Effect::Apply(Box::new(plan))) + } + + fn refresh(&mut self, snapshot: Snapshot) { + let changed = self.has_changes(); + let names = snapshot.names(); + if names != self.order { + self.order = names; + self.selected = self.selected.min(self.order.len().saturating_sub(1)); + self.draft = snapshot.to_layout(); + self.status = Status::new(Level::Caution, "The group of the monitors changed."); + } else if !changed { + self.draft = snapshot.to_layout(); + } + self.snapshot = snapshot; + self.release(); + self.examine(); + } + + fn release(&mut self) { + self.ghost = None; + self.drag = None; + } + + fn examine(&mut self) { + self.problems = check(&self.draft, &self.snapshot); + } + + fn step_selection(&mut self, delta: isize) { + if self.order.is_empty() { + return; + } + let count = self.order.len(); + let current = isize::try_from(self.selected).unwrap_or(0); + let count_as_isize = isize::try_from(count).unwrap_or(1); + let next = (current + delta).rem_euclid(count_as_isize); + self.selected = usize::try_from(next).unwrap_or(0); + self.release(); + } + + fn change_selected(&mut self, change: impl FnOnce(OutputConfig) -> OutputConfig) { + let Some(name) = self.selected().map(ToOwned::to_owned) else { + return; + }; + self.draft.update(&name, change); + self.examine(); + } + + fn move_selected(&mut self, side: Side, distance: i32, snap: bool) { + let Some(name) = self.selected().map(ToOwned::to_owned) else { + return; + }; + let placed = self.rects(); + let Some(current) = placed + .iter() + .find(|(other, _)| *other == name) + .map(|(_, rect)| *rect) + else { + self.status = Status::new( + Level::Caution, + "The program cannot move an output that is off.", + ); + return; + }; + + let start = match &self.drag { + Some(drag) if drag.name == name => drag.start, + _ => current.min, + }; + let previous = match &self.drag { + Some(drag) if snap && drag.name == name => drag.raw, + _ => current.min, + }; + + let step = match side { + Side::Left => Vector::new(-distance, 0), + Side::Right => Vector::new(distance, 0), + Side::Top => Vector::new(0, -distance), + Side::Bottom => Vector::new(0, distance), + }; + let raw = previous + step; + + let candidate = rect_at(raw, current.size()); + let others: Vec = placed + .iter() + .filter(|(other, _)| *other != name) + .map(|(_, rect)| *rect) + .collect(); + let result = if snap { + snap_rect(candidate, &others, self.snap).apply(candidate) + } else { + candidate + }; + + self.drag = Some(Drag { + name: name.clone(), + start, + raw, + }); + self.ghost = Some(rect_at(start, current.size())); + self.draft + .update(&name, |config| config.with_origin(result.min)); + self.examine(); + } + + fn step_mode(&mut self, delta: isize) { + let Some(name) = self.selected().map(ToOwned::to_owned) else { + return; + }; + let Some(output) = self.snapshot.get(&name) else { + return; + }; + let modes = output.sorted_modes(); + if modes.is_empty() { + return; + } + let Some(config) = self.draft.get(&name).copied() else { + return; + }; + let current = config + .resolved_mode(output) + .and_then(|found| modes.iter().position(|item| *item == found)) + .unwrap_or(0); + let count = isize::try_from(modes.len()).unwrap_or(1); + let next = (isize::try_from(current).unwrap_or(0) + delta).rem_euclid(count); + let chosen = modes[usize::try_from(next).unwrap_or(0)]; + self.draft.update(&name, |config| config.with_mode(chosen)); + self.status = Status::new( + Level::Info, + format!("The mode of {name} is {}.", chosen.label()), + ); + self.examine(); + } + + fn step_scale(&mut self, delta: isize) { + let Some(name) = self.selected().map(ToOwned::to_owned) else { + return; + }; + let Some(output) = self.snapshot.get(&name) else { + return; + }; + let Some(config) = self.draft.get(&name).copied() else { + return; + }; + let current = config.resolved_scale(output).get(); + let position = scale::COMMON + .iter() + .position(|value| (*value - current).abs() < 1e-6); + let next = match position { + Some(index) => { + let count = isize::try_from(scale::COMMON.len()).unwrap_or(1); + let moved = (isize::try_from(index).unwrap_or(0) + delta).clamp(0, count - 1); + scale::COMMON[usize::try_from(moved).unwrap_or(0)] + } + None if delta > 0 => scale::COMMON + .iter() + .copied() + .find(|value| *value > current) + .unwrap_or(scale::MAXIMUM), + None => scale::COMMON + .iter() + .rev() + .copied() + .find(|value| *value < current) + .unwrap_or(scale::MINIMUM), + }; + let chosen = Scale::clamped(next); + self.draft.update(&name, |config| config.with_scale(chosen)); + self.status = Status::new( + Level::Info, + format!("The scale factor of {name} is {chosen}."), + ); + self.examine(); + } + + fn turn(&mut self, left: bool) { + self.change_selected(|config| { + let transform = if left { + config.transform.turn_left() + } else { + config.transform.turn_right() + }; + config.with_transform(transform) + }); + } + + fn toggle_enabled(&mut self) { + let Some(config) = self.selected_config().copied() else { + return; + }; + if config.enabled && self.draft.enabled_count() <= 1 { + self.status = Status::new( + Level::Danger, + "The program keeps one output on. It cannot turn off the last output.", + ); + return; + } + self.change_selected(|config| config.with_enabled(!config.enabled)); + } + + fn toggle_vrr(&mut self) { + let Some(name) = self.selected().map(ToOwned::to_owned) else { + return; + }; + let supported = self + .snapshot + .get(&name) + .is_some_and(|output| output.vrr_supported); + if !supported { + self.status = Status::new( + Level::Caution, + format!("The output {name} does not have a variable refresh rate."), + ); + return; + } + self.change_selected(|mut config| { + config.vrr.vrr = !config.vrr.vrr; + config + }); + } + + fn arrange_row(&mut self) { + self.release(); + let placed = self.rects(); + let mut ordered = placed; + ordered.sort_by_key(|(_, rect)| rect.min.x); + let mut x = 0; + for (name, rect) in ordered { + self.draft + .update(&name, |config| config.with_origin(Point::new(x, 0))); + x += rect.max.x - rect.min.x; + } + self.status = Status::new(Level::Info, "The program put the outputs in one row."); + self.examine(); + } + + /// Give the highest severity of the problems of the layout. + #[must_use] + pub fn worst_problem(&self) -> Option { + self.problems.iter().map(Problem::severity).max() + } + + /// Give the description of the mode of the output for the map. + #[must_use] + pub fn detail_of(&self, name: &str) -> String { + let Some(output) = self.snapshot.get(name) else { + return String::new(); + }; + let Some(config) = self.draft.get(name) else { + return String::new(); + }; + if !config.enabled { + return "off".to_owned(); + } + let Some(found) = config.resolved_mode(output) else { + return "no mode".to_owned(); + }; + let scale = config.resolved_scale(output); + let mut text = format!("{}x{} ×{scale}", found.width, found.height); + if config.transform != Transform::Normal { + text.push(' '); + text.push_str(config.transform.label()); + } + text + } + + /// Give the modes of the selected output and the position of the current one. + #[must_use] + pub fn selected_modes(&self) -> (Vec, usize) { + let Some(name) = self.selected() else { + return (Vec::new(), 0); + }; + let Some(output) = self.snapshot.get(name) else { + return (Vec::new(), 0); + }; + let modes = output.sorted_modes(); + let current = self + .draft + .get(name) + .and_then(|config| config.resolved_mode(output)) + .and_then(|found| modes.iter().position(|item| *item == found)) + .unwrap_or(0); + (modes, current) + } + + /// Tell if the layout of the selected output uses an automatic mode. + #[must_use] + pub fn selected_uses_automatic_mode(&self) -> bool { + self.selected_config() + .is_some_and(|config| config.mode == ModeToSet::Automatic) + } + + /// Give the preferred mode of the selected output. + #[must_use] + pub fn selected_preferred_mode(&self) -> Option { + let output = self.snapshot.get(self.selected()?)?; + mode::preferred(&output.modes) + } +} + +#[cfg(test)] +mod tests { + use dynamonix_ipc::fake::sample_desk; + + use super::*; + + fn app() -> App { + App::new(Snapshot::new(sample_desk())) + } + + #[test] + fn a_new_state_has_no_change_and_no_problem() { + let app = app(); + assert!(!app.has_changes()); + assert!(app.problems().is_empty()); + assert_eq!(app.selected(), Some("DP-2")); + } + + #[test] + fn the_selection_returns_to_the_first_output() { + let mut app = app(); + for _ in 0..app.order().len() { + app.handle(Command::SelectNext); + } + assert_eq!(app.selected_index(), 0); + } + + #[test] + fn the_selection_moves_back_from_the_first_output() { + let mut app = app(); + app.handle(Command::SelectPrevious); + assert_eq!(app.selected_index(), app.order().len() - 1); + } + + fn position_of(app: &App, name: &str) -> Point { + app.rects() + .into_iter() + .find(|(other, _)| other == name) + .map(|(_, rect)| rect.min) + .expect("the output has an area") + } + + fn press(app: &mut App, side: Side) { + app.handle(Command::Move { + side, + distance: crate::keys::STEP, + snap: true, + }); + } + + #[test] + fn one_normal_movement_moves_the_output() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + press(&mut app, Side::Bottom); + let after = position_of(&app, &name); + assert_ne!(before, after, "one key press must move the output"); + } + + #[test] + fn every_direction_moves_the_output() { + for side in [Side::Left, Side::Right, Side::Top, Side::Bottom] { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + press(&mut app, side); + assert_ne!(before, position_of(&app, &name), "{side:?} must move"); + } + } + + #[test] + fn the_movements_of_many_key_presses_accumulate() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + for _ in 0..4 { + press(&mut app, Side::Bottom); + } + let after = position_of(&app, &name); + assert_eq!(after.y - before.y, 4 * crate::keys::STEP); + } + + #[test] + fn a_movement_back_returns_the_output_to_its_first_position() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + press(&mut app, Side::Bottom); + press(&mut app, Side::Top); + assert_eq!(position_of(&app, &name), before); + } + + #[test] + fn an_accurate_movement_moves_one_pixel() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + app.handle(Command::Move { + side: Side::Bottom, + distance: crate::keys::FINE_STEP, + snap: false, + }); + assert_eq!( + position_of(&app, &name).y - before.y, + crate::keys::FINE_STEP + ); + } + + #[test] + fn an_accurate_movement_changes_one_axis_only() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + for _ in 0..3 { + press(&mut app, Side::Bottom); + } + let before = position_of(&app, &name); + app.handle(Command::Move { + side: Side::Left, + distance: crate::keys::FINE_STEP, + snap: false, + }); + let after = position_of(&app, &name); + assert_eq!(after.y, before.y, "a movement to the left must keep y"); + assert_eq!(after.x, before.x - crate::keys::FINE_STEP); + } + + #[test] + fn an_accurate_movement_starts_from_the_visible_position() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + for _ in 0..4 { + press(&mut app, Side::Bottom); + } + let before = position_of(&app, &name); + for _ in 0..3 { + app.handle(Command::Move { + side: Side::Bottom, + distance: crate::keys::FINE_STEP, + snap: false, + }); + } + let after = position_of(&app, &name); + assert_eq!(after.y - before.y, 3 * crate::keys::FINE_STEP); + } + + #[test] + fn a_movement_near_a_contact_aligns_the_output() { + let mut app = app(); + let name = app.selected().expect("an output is selected").to_owned(); + let before = position_of(&app, &name); + app.handle(Command::Move { + side: Side::Bottom, + distance: SNAP_THRESHOLD - 4, + snap: true, + }); + assert_eq!( + position_of(&app, &name), + before, + "a small movement must return to the contact" + ); + } + + #[test] + fn a_movement_changes_the_layout() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Bottom, + distance: 400, + snap: true, + }); + assert!(app.has_changes()); + assert!(app.ghost().is_some()); + } + + #[test] + fn a_movement_near_an_other_output_makes_a_contact() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Bottom, + distance: 12, + snap: true, + }); + let rects = app.rects(); + let areas: Vec = rects.iter().map(|(_, rect)| *rect).collect(); + assert!(dynamonix_geom::is_connected(&areas)); + } + + #[test] + fn the_program_never_turns_off_the_last_output() { + let mut app = app(); + let names: Vec = app.order().to_vec(); + for name in &names { + let index = names.iter().position(|item| item == name).unwrap_or(0); + app.selected = index; + app.handle(Command::ToggleEnabled); + } + assert!(app.draft().enabled_count() >= 1); + } + + #[test] + fn a_turn_changes_the_size_of_the_output() { + let mut app = app(); + let before = app.rects()[0].1.size(); + app.handle(Command::TurnLeft); + let after = app.rects()[0].1.size(); + assert_eq!(before.width, after.height); + assert_eq!(before.height, after.width); + } + + #[test] + fn four_turns_give_the_first_layout() { + let mut app = app(); + let before = app.rects(); + for _ in 0..4 { + app.handle(Command::TurnLeft); + } + assert_eq!(app.rects(), before); + } + + #[test] + fn a_reset_removes_every_change() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Right, + distance: 300, + snap: true, + }); + app.handle(Command::ScaleUp); + assert!(app.has_changes()); + app.handle(Command::Reset); + assert!(!app.has_changes()); + } + + #[test] + fn the_row_arrangement_joins_all_the_outputs() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Right, + distance: 900, + snap: true, + }); + app.handle(Command::ArrangeRow); + let areas: Vec = app.rects().into_iter().map(|(_, rect)| rect).collect(); + assert!(dynamonix_geom::is_connected(&areas)); + assert!(dynamonix_geom::overlapping_pairs(&areas).is_empty()); + } + + #[test] + fn the_program_does_not_send_an_empty_plan() { + let mut app = app(); + assert_eq!(app.handle(Command::Apply), None); + } + + #[test] + fn the_program_sends_a_plan_after_a_change() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Bottom, + distance: 500, + snap: true, + }); + let effect = app.handle(Command::Apply); + assert!(matches!(effect, Some(Effect::Apply(_)))); + } + + #[test] + fn a_scale_change_makes_the_output_smaller() { + let mut app = app(); + let before = app.rects()[0].1.size(); + app.handle(Command::ScaleUp); + let after = app.rects()[0].1.size(); + assert!(after.width < before.width); + } + + #[test] + fn the_scale_factor_stays_inside_the_limits() { + let mut app = app(); + for _ in 0..40 { + app.handle(Command::ScaleUp); + } + for _ in 0..80 { + app.handle(Command::ScaleDown); + } + let config = app.selected_config().expect("an output is selected"); + let output = app + .snapshot() + .get(app.selected().expect("selected")) + .expect("exists"); + let value = config.resolved_scale(output).get(); + assert!((scale::MINIMUM..=scale::MAXIMUM).contains(&value)); + } + + #[test] + fn a_new_reading_of_the_same_outputs_keeps_the_changes_of_the_user() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Bottom, + distance: 500, + snap: true, + }); + let plan = app.plan(); + app.handle(Command::Refresh(Box::new(Snapshot::new(sample_desk())))); + assert_eq!(app.plan().len(), plan.len()); + } + + #[test] + fn a_new_group_of_the_monitors_replaces_the_layout() { + let mut app = app(); + app.handle(Command::Move { + side: Side::Bottom, + distance: 500, + snap: true, + }); + let smaller: Vec = sample_desk().into_iter().take(2).collect(); + app.handle(Command::Refresh(Box::new(Snapshot::new(smaller)))); + assert!(!app.has_changes()); + assert_eq!(app.order().len(), 2); + } + + #[test] + fn the_quit_command_gives_the_quit_effect() { + let mut app = app(); + assert_eq!(app.handle(Command::Quit), Some(Effect::Quit)); + } + + #[test] + fn the_attention_moves_between_the_parts() { + let mut app = app(); + assert_eq!(app.focus(), Focus::Map); + app.handle(Command::FocusNext); + assert_eq!(app.focus(), Focus::Outputs); + app.handle(Command::FocusNext); + assert_eq!(app.focus(), Focus::Map); + } + + #[test] + fn the_detail_text_shows_the_size_and_the_scale_factor() { + let app = app(); + let text = app.detail_of("DP-2"); + assert!(text.contains("3440x1440"), "{text}"); + } +} diff --git a/crates/dynamonix-tui/src/keys.rs b/crates/dynamonix-tui/src/keys.rs new file mode 100644 index 0000000..2574320 --- /dev/null +++ b/crates/dynamonix-tui/src/keys.rs @@ -0,0 +1,155 @@ +//! The keys that the interface accepts. +//! +//! The map of the keys is a pure function. A test can therefore examine it +//! without a terminal. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use dynamonix_geom::Side; + +use crate::app::{Command, Focus}; + +/// The distance in logical pixels of a normal movement. +/// +/// The distance is more than the threshold of a snap operation. One key press +/// therefore always moves the output away from a contact. +pub const STEP: i32 = 48; + +/// The distance in logical pixels of an accurate movement. +/// +/// An accurate movement does not use a snap operation. A user who wants an +/// accurate position does not want an automatic change of that position. +pub const FINE_STEP: i32 = 1; + +/// Find the operation for the key. +/// +/// The function gives `None` if the interface does no operation for the key. +#[must_use] +pub fn command_for(key: KeyEvent, focus: Focus) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + let fine = key.modifiers.contains(KeyModifiers::SHIFT); + let distance = if fine { FINE_STEP } else { STEP }; + let snap = !fine; + + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c') => Some(Command::Quit), + _ => None, + }; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => Some(Command::Quit), + KeyCode::Tab | KeyCode::BackTab => Some(Command::FocusNext), + KeyCode::Enter => Some(Command::Apply), + + KeyCode::Up | KeyCode::Char('k') => { + Some(directional(focus, Side::Top, distance, snap, false)) + } + KeyCode::Down | KeyCode::Char('j') => { + Some(directional(focus, Side::Bottom, distance, snap, true)) + } + KeyCode::Left | KeyCode::Char('h') => { + Some(directional(focus, Side::Left, distance, snap, false)) + } + KeyCode::Right | KeyCode::Char('l') => { + Some(directional(focus, Side::Right, distance, snap, true)) + } + + KeyCode::Char('m') => Some(Command::NextMode), + KeyCode::Char('M') => Some(Command::PreviousMode), + KeyCode::Char('+' | '=') => Some(Command::ScaleUp), + KeyCode::Char('-' | '_') => Some(Command::ScaleDown), + KeyCode::Char('r') => Some(Command::TurnLeft), + KeyCode::Char('R') => Some(Command::TurnRight), + KeyCode::Char('e') => Some(Command::ToggleEnabled), + KeyCode::Char('v') => Some(Command::ToggleVrr), + KeyCode::Char('n') => Some(Command::Normalize), + KeyCode::Char('a') => Some(Command::ArrangeRow), + KeyCode::Char('u') => Some(Command::Reset), + _ => None, + } +} + +fn directional(focus: Focus, side: Side, distance: i32, snap: bool, forward: bool) -> Command { + match focus { + Focus::Map => Command::Move { + side, + distance, + snap, + }, + Focus::Outputs if forward => Command::SelectNext, + Focus::Outputs => Command::SelectPrevious, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn the_map_moves_the_output_with_the_arrow_keys() { + assert_eq!( + command_for(key(KeyCode::Left), Focus::Map), + Some(Command::Move { + side: Side::Left, + distance: STEP, + snap: true + }) + ); + } + + #[test] + fn the_shift_key_makes_the_movement_accurate() { + let event = KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT); + assert_eq!( + command_for(event, Focus::Map), + Some(Command::Move { + side: Side::Left, + distance: FINE_STEP, + snap: false + }) + ); + } + + #[test] + fn the_list_changes_the_selection_with_the_arrow_keys() { + assert_eq!( + command_for(key(KeyCode::Down), Focus::Outputs), + Some(Command::SelectNext) + ); + assert_eq!( + command_for(key(KeyCode::Up), Focus::Outputs), + Some(Command::SelectPrevious) + ); + } + + #[test] + fn the_program_stops_with_the_q_key() { + assert_eq!( + command_for(key(KeyCode::Char('q')), Focus::Map), + Some(Command::Quit) + ); + assert_eq!( + command_for(key(KeyCode::Esc), Focus::Map), + Some(Command::Quit) + ); + } + + #[test] + fn the_program_ignores_the_release_of_a_key() { + let mut event = key(KeyCode::Char('q')); + event.kind = KeyEventKind::Release; + assert_eq!(command_for(event, Focus::Map), None); + } + + #[test] + fn an_unknown_key_gives_no_operation() { + assert_eq!(command_for(key(KeyCode::Char('z')), Focus::Map), None); + } +} diff --git a/crates/dynamonix-tui/src/lib.rs b/crates/dynamonix-tui/src/lib.rs new file mode 100644 index 0000000..44f8e88 --- /dev/null +++ b/crates/dynamonix-tui/src/lib.rs @@ -0,0 +1,15 @@ +//! The terminal interface that arranges the niri outputs. +//! +//! The interface keeps the state separate from the terminal: +//! +//! - The [`app`] module holds the state and changes it. Every operation is a +//! pure function. No operation speaks to the compositor or to the terminal. +//! - The [`keys`] module changes a key into an operation. +//! +//! The tests can therefore examine all the behavior of the interface with no +//! terminal and no compositor. + +pub mod app; +pub mod keys; + +pub use app::{App, Command, Effect, Focus, Level, Status};