From 025831b64839b3431602c98f78177c171bcc1ce8 Mon Sep 17 00:00:00 2001 From: Sreedev Kodichath Date: Wed, 5 Aug 2026 11:13:24 +0200 Subject: [PATCH] [ADD] tui: the loop that joins the parts The loop reads the keys, changes the state, and sends the plans to the compositor. It waits for a key and for a report of the outputs at the same time, so the interface follows a monitor that connects or disconnects while the user works. A separate thread reads the keys, because that operation blocks. The thread tests a flag at a regular time, so the loop can stop it. The loop returns the terminal to its first mode before it gives an error to the caller. A terminal that stays in the raw mode is difficult for a user to repair. After it sends a plan, the loop reads the outputs again. The compositor is the authority on the result, and a change can give a different result from the request. The keypath test drives a running niri instance from a key event. It converts the key, changes the state, builds the plan, sends it, and compares the position that the compositor reports with the position that the interface shows. --- crates/dynamonix-tui/Cargo.toml | 4 +- crates/dynamonix-tui/src/lib.rs | 12 +- crates/dynamonix-tui/src/run.rs | 153 ++++++++++++++++++++++++++ crates/dynamonix-tui/tests/keypath.rs | 52 +++++++++ 4 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 crates/dynamonix-tui/src/run.rs create mode 100644 crates/dynamonix-tui/tests/keypath.rs diff --git a/crates/dynamonix-tui/Cargo.toml b/crates/dynamonix-tui/Cargo.toml index 6f4850a..995e3e1 100644 --- a/crates/dynamonix-tui/Cargo.toml +++ b/crates/dynamonix-tui/Cargo.toml @@ -14,8 +14,10 @@ dynamonix-model.workspace = true dynamonix-plan.workspace = true dynamonix-ipc.workspace = true niri-ipc.workspace = true -crossterm.workspace = true ratatui.workspace = true +crossterm.workspace = true +tokio.workspace = true +thiserror.workspace = true [lints] workspace = true diff --git a/crates/dynamonix-tui/src/lib.rs b/crates/dynamonix-tui/src/lib.rs index 75b9214..845dee5 100644 --- a/crates/dynamonix-tui/src/lib.rs +++ b/crates/dynamonix-tui/src/lib.rs @@ -1,19 +1,21 @@ //! The terminal interface that arranges the niri outputs. //! -//! The interface keeps the state separate from the terminal: +//! The interface has four parts: //! -//! - 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 [`app`] module holds the state and changes it. All the operations are +//! pure functions. The tests examine the behavior with no terminal. //! - The [`keys`] module changes a key into an operation. //! - The [`canvas`] module and the [`view`] module draw the interface. +//! - The [`run`] module connects the state to the compositor. //! -//! The tests can therefore examine all the behavior of the interface with no -//! terminal and no compositor. +//! This division keeps the input, the state and the output separate. pub mod app; pub mod canvas; pub mod keys; +pub mod run; pub mod theme; pub mod view; pub use app::{App, Command, Effect, Focus, Level, Status}; +pub use run::{Options, run}; diff --git a/crates/dynamonix-tui/src/run.rs b/crates/dynamonix-tui/src/run.rs new file mode 100644 index 0000000..fefb881 --- /dev/null +++ b/crates/dynamonix-tui/src/run.rs @@ -0,0 +1,153 @@ +//! The loop that connects the interface to the compositor. + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crossterm::event::{self, Event}; +use dynamonix_ipc::{Client, Update, WatchConfig, Watcher}; +use tokio::sync::mpsc; + +use crate::app::{App, Command, Effect, Level, Status}; +use crate::keys::command_for; +use crate::view; + +/// A condition that stops the interface. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The program cannot speak to the compositor. + #[error(transparent)] + Compositor(#[from] dynamonix_ipc::Error), + /// The program cannot use the terminal. + #[error("cannot use the terminal")] + Terminal(#[from] std::io::Error), +} + +/// The values that the interface needs to start. +#[derive(Debug, Clone)] +pub struct Options { + /// The path of the socket of the compositor. + pub socket: PathBuf, + /// The times that the task uses to watch the outputs. + pub watch: WatchConfig, +} + +/// Show the interface until the user stops it. +/// +/// # Errors +/// +/// The function gives an error if it cannot reach the compositor at the start, +/// or if it cannot use the terminal. +pub async fn run(options: Options) -> Result<(), Error> { + let mut client = Client::connect_to(&options.socket).await?; + let snapshot = client.snapshot().await?; + let mut app = App::new(snapshot); + + let mut watcher = Watcher::start(options.socket.clone(), options.watch); + let running = Arc::new(AtomicBool::new(true)); + let mut keys = start_keys(running.clone()); + + let mut terminal = ratatui::init(); + let result = main_loop( + &mut terminal, + &mut app, + &mut client, + &mut watcher, + &mut keys, + ) + .await; + running.store(false, Ordering::Relaxed); + ratatui::restore(); + result +} + +fn start_keys(running: Arc) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(64); + std::thread::spawn(move || { + while running.load(Ordering::Relaxed) { + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(found) => { + if sender.blocking_send(found).is_err() { + return; + } + } + Err(_) => return, + }, + Ok(false) => {} + Err(_) => return, + } + } + }); + receiver +} + +async fn main_loop( + terminal: &mut ratatui::DefaultTerminal, + app: &mut App, + client: &mut Client, + watcher: &mut Watcher, + keys: &mut mpsc::Receiver, +) -> Result<(), Error> { + loop { + terminal.draw(|frame| view::draw(frame, app))?; + + let command = tokio::select! { + found = keys.recv() => match found { + Some(Event::Key(key)) => command_for(key, app.focus()), + Some(_) => None, + None => Some(Command::Quit), + }, + update = watcher.next_update() => match update { + Some(Update::Outputs(snapshot)) => Some(Command::Refresh(snapshot)), + Some(Update::Lost(message)) => Some(Command::Report(Status::new( + Level::Danger, + format!("The program lost the compositor: {message}"), + ))), + None => None, + }, + }; + + let Some(command) = command else { + continue; + }; + match app.handle(command) { + Some(Effect::Quit) => return Ok(()), + Some(Effect::Apply(plan)) => { + let status = send(client, &plan).await; + app.handle(Command::Report(status)); + if let Ok(snapshot) = client.snapshot().await { + app.handle(Command::Refresh(Box::new(snapshot))); + } + } + None => {} + } + } +} + +async fn send(client: &mut Client, plan: &dynamonix_plan::Plan) -> Status { + match client.apply(plan).await { + Ok(report) if report.is_success() => { + let staged = report.staged_count(); + if staged > 0 { + Status::new( + Level::Good, + format!( + "The compositor made the changes. It keeps {staged} changes for an output that is not connected." + ), + ) + } else { + Status::new(Level::Good, "The compositor made all the changes.") + } + } + Ok(report) => Status::new( + Level::Danger, + format!( + "The compositor did not accept a change: {}", + report.refusal().unwrap_or("no reason") + ), + ), + Err(error) => Status::new(Level::Danger, format!("The program failed: {error}")), + } +} diff --git a/crates/dynamonix-tui/tests/keypath.rs b/crates/dynamonix-tui/tests/keypath.rs new file mode 100644 index 0000000..96bc85a --- /dev/null +++ b/crates/dynamonix-tui/tests/keypath.rs @@ -0,0 +1,52 @@ +//! The examination of the full path from a key to the compositor. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use dynamonix_ipc::Client; +use dynamonix_tui::app::{App, Command, Effect}; +use dynamonix_tui::keys::command_for; + +#[tokio::test] +async fn an_arrow_key_moves_the_output_of_a_true_compositor() { + let Ok(path) = std::env::var("DYNAMONIX_TEST_SOCKET") else { + return; + }; + let mut client = Client::connect_to(&path) + .await + .expect("the client connects"); + let start = client.snapshot().await.expect("the compositor answers"); + let mut app = App::new(start); + let name = app.selected().expect("an output is selected").to_owned(); + + let at = |app: &App| { + app.rects() + .into_iter() + .find(|(other, _)| *other == name) + .map(|(_, rect)| rect.min) + .expect("the output has an area") + }; + let before = at(&app); + + let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let command = command_for(key, app.focus()).expect("the key gives an operation"); + assert!(matches!(command, Command::Move { .. })); + app.handle(command); + let after = at(&app); + assert_ne!(before, after, "the key press must move the output"); + + let Some(Effect::Apply(plan)) = app.handle(Command::Apply) else { + panic!("the interface must send a plan"); + }; + let report = client.apply(&plan).await.expect("the compositor answers"); + assert!(report.is_success(), "the compositor refused: {report:?}"); + + let now = client.snapshot().await.expect("the compositor answers"); + let logical = now + .get(&name) + .expect("the output exists") + .logical + .expect("on"); + assert_eq!((logical.x, logical.y), (after.x, after.y)); + + let back = dynamonix_plan::diff(&now, &app.snapshot().to_layout()); + client.apply(&back).await.expect("the compositor answers"); +} -- 2.51.2