From fd46de40438e85d27354acc1c70f7d0316c589e0 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Wed, 5 Aug 2026 11:12:32 +0200 Subject: [PATCH] [ADD] tui: the state and the keys The state of the interface holds no terminal and no connection. Every operation is a pure function, and an operation that needs the compositor gives an effect to the caller. The tests therefore examine the full behavior of the interface with no terminal and no niri. A key maps to an operation, and an operation changes the state. The step of a movement must be larger than the limit of a snap operation. A key press moves an output 48 pixels, and a snap operation moves an output not more than 32 pixels. A limit above the step pulls the output back to the position that it left, and the output never moves. A constant assertion holds this relation. The state also keeps the position that the movements give, apart from the position that a snap operation gives. Without that record the movements of separate key presses do not accumulate, because each press starts again from a position that a snap operation moved. A movement of one pixel uses no snap operation and starts from the position on the screen, so an exact position stays exact. The state refuses to turn off the last output. --- Cargo.lock | 227 ++++++++ Cargo.toml | 1 + crates/dynamonix-tui/Cargo.toml | 20 + crates/dynamonix-tui/src/app.rs | 971 +++++++++++++++++++++++++++++++ crates/dynamonix-tui/src/keys.rs | 155 +++++ crates/dynamonix-tui/src/lib.rs | 15 + 6 files changed, 1389 insertions(+) create mode 100644 crates/dynamonix-tui/Cargo.toml create mode 100644 crates/dynamonix-tui/src/app.rs create mode 100644 crates/dynamonix-tui/src/keys.rs create mode 100644 crates/dynamonix-tui/src/lib.rs 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}; -- 2.51.2