From 4caf92482f2f21b3e8636f41aa36297a8aa29bdc Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 21:09:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(mirada):=20mirada-body=20=E2=80=94=20conta?= =?UTF-8?q?bilidad=20del=20Cuerpo=20del=20compositor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BodyState agnóstico de smithay: lleva salidas + superficies, traduce BrainCommand a BodyOp (sólo lo que cambia) y emite BodyEvent desde los mutadores del backend. Ejemplo headless: Cuerpo sin gráficos guiado por stdin para ejercitar el bucle Cerebro↔Cuerpo. 13 tests. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/modules/mirada/mirada-body/Cargo.toml | 14 + .../mirada/mirada-body/examples/headless.rs | 121 ++++++ crates/modules/mirada/mirada-body/src/lib.rs | 379 ++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 crates/modules/mirada/mirada-body/Cargo.toml create mode 100644 crates/modules/mirada/mirada-body/examples/headless.rs create mode 100644 crates/modules/mirada/mirada-body/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c4f57db..8966f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7401,6 +7401,14 @@ dependencies = [ "nahual-theme", ] +[[package]] +name = "mirada-body" +version = "0.1.0" +dependencies = [ + "mirada-link", + "mirada-protocol", +] + [[package]] name = "mirada-brain" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3dba7f3..7a5b8f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,6 +164,7 @@ members = [ "crates/modules/mirada/mirada-protocol", "crates/modules/mirada/mirada-brain", "crates/modules/mirada/mirada-link", + "crates/modules/mirada/mirada-body", # ============================================================ # modules/nakui/ — ERP matemático (categórico) diff --git a/crates/modules/mirada/mirada-body/Cargo.toml b/crates/modules/mirada/mirada-body/Cargo.toml new file mode 100644 index 0000000..d2ba2bc --- /dev/null +++ b/crates/modules/mirada/mirada-body/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mirada-body" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "mirada — estado del Cuerpo del compositor: lleva la cuenta de salidas y superficies, traduce los BrainCommand a operaciones de backend y emite los BodyEvent. Agnóstico de smithay." + +[dependencies] +mirada-protocol = { path = "../mirada-protocol" } + +[dev-dependencies] +mirada-link = { path = "../mirada-link" } diff --git a/crates/modules/mirada/mirada-body/examples/headless.rs b/crates/modules/mirada/mirada-body/examples/headless.rs new file mode 100644 index 0000000..0fb479d --- /dev/null +++ b/crates/modules/mirada/mirada-body/examples/headless.rs @@ -0,0 +1,121 @@ +//! `headless` — un Cuerpo de carmen sin gráficos, guiado por stdin. +//! +//! Es el banco de pruebas del Cerebro: implementa el lado del Cuerpo del +//! protocolo (escucha en un socket, lleva un [`BodyState`], manda +//! [`BodyEvent`]s y ejecuta —imprimiéndolas— las [`BodyOp`]s) sin tocar +//! `smithay` ni el hardware. Así se ejercita el bucle entero +//! Cerebro↔Cuerpo desde una terminal. +//! +//! ```text +//! # terminal 1 — el Cuerpo escucha +//! cargo run -p mirada-body --example headless -- /tmp/mirada.sock +//! # terminal 2 — el Cerebro se conecta +//! MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada +//! ``` +//! +//! Órdenes de stdin: `output `, `open `, `close `, +//! `title `, `key `, `pointer `, `tick`, `quit`. + +use std::io::BufRead; +use std::time::Duration; + +use mirada_body::BodyState; +use mirada_link::BodyLink; + +fn main() { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/mirada.sock".to_string()); + let _ = std::fs::remove_file(&path); + + println!("Cuerpo headless · escuchando en {path} — esperando al Cerebro…"); + let mut link: BodyLink = match BodyLink::listen(&path) { + Ok(l) => l, + Err(e) => { + eprintln!("no se pudo escuchar en {path}: {e}"); + std::process::exit(1); + } + }; + println!("Cerebro conectado. Órdenes: output / open / close / title / key / pointer / tick / quit"); + + let mut body = BodyState::new(); + let mut next_id: u64 = 1; + let stdin = std::io::stdin(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + let mut parts = line.split_whitespace(); + let cmd = parts.next().unwrap_or(""); + let rest: Vec<&str> = parts.collect(); + + // Cada orden o bien manda un evento al Cerebro, o no manda nada. + let event = match cmd { + "output" if rest.len() == 2 => { + match (rest[0].parse(), rest[1].parse()) { + (Ok(w), Ok(h)) => Some(body.add_output(0, w, h)), + _ => { + eprintln!("uso: output "); + None + } + } + } + "open" if !rest.is_empty() => { + let app = rest[0]; + let id = next_id; + next_id += 1; + println!(" → ventana {id} ({app})"); + Some(body.open_surface(id, format!("org.brahman.{app}"), format!("{app} {id}"))) + } + "close" if rest.len() == 1 => match rest[0].parse() { + Ok(id) => body.close_surface(id), + Err(_) => { + eprintln!("uso: close "); + None + } + }, + "title" if rest.len() >= 2 => match rest[0].parse() { + Ok(id) => body.retitle_surface(id, rest[1..].join(" ")), + Err(_) => { + eprintln!("uso: title "); + None + } + }, + "key" if rest.len() == 1 => Some(body.keybind(rest[0])), + "pointer" if rest.len() == 1 => match rest[0].parse() { + Ok(id) => Some(body.pointer_enter(id)), + Err(_) => { + eprintln!("uso: pointer "); + None + } + }, + "tick" => None, + "quit" | "exit" => break, + "" => None, + other => { + eprintln!("orden desconocida: {other}"); + None + } + }; + + if let Some(ev) = event { + if link.send(&ev).is_err() { + eprintln!("el Cerebro cerró la conexión."); + break; + } + } + + // Deja que el Cerebro responda y ejecuta lo que ordene. + std::thread::sleep(Duration::from_millis(40)); + for command in link.drain() { + for op in body.apply(command) { + println!(" · op: {op:?}"); + } + } + } + + println!("Cuerpo headless · adiós."); + let _ = std::fs::remove_file(&path); +} diff --git a/crates/modules/mirada/mirada-body/src/lib.rs b/crates/modules/mirada/mirada-body/src/lib.rs new file mode 100644 index 0000000..385806d --- /dev/null +++ b/crates/modules/mirada/mirada-body/src/lib.rs @@ -0,0 +1,379 @@ +//! `mirada-body` — el estado del Cuerpo del compositor. +//! +//! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene +//! dos mitades: el *backend*, que habla Wayland y posee el hardware, y +//! esta *contabilidad* — qué salidas y superficies existen y con qué +//! geometría. Aislarla deja el backend reducido a "ejecuta estas +//! [`BodyOp`]" y la hace testeable sin un servidor gráfico. +//! +//! El flujo es simétrico al del Cerebro: +//! +//! - El backend avisa de cambios de hardware/clientes con los mutadores +//! ([`BodyState::open_surface`], [`BodyState::add_output`], …), que +//! devuelven el [`BodyEvent`] a mandar al Cerebro. +//! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los +//! traduce a [`BodyOp`]s concretas que el backend ejecuta. + +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; + +use mirada_protocol::{BodyEvent, BrainCommand, OutputId, Rect, WindowId}; + +/// Una superficie Wayland desde la óptica del Cuerpo. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Surface { + pub app_id: String, + pub title: String, + /// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`]. + pub geometry: Option, + pub visible: bool, + pub focused: bool, +} + +impl Surface { + fn new(app_id: String, title: String) -> Self { + Self { app_id, title, geometry: None, visible: false, focused: false } + } +} + +/// Una orden concreta para el backend (smithay, headless, …). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BodyOp { + /// Recoloca una superficie y la muestra u oculta. + Configure { id: WindowId, rect: Rect, visible: bool }, + /// Da el foco del teclado a una superficie. + Focus(WindowId), + /// Quita el foco a todas las superficies. + Unfocus, + /// Pide el cierre ordenado de un cliente. + CloseClient(WindowId), + /// Mata a un cliente que no responde. + KillClient(WindowId), + /// Registra los atajos globales a interceptar. + SetGrabs(Vec), + /// Cambia el cursor del puntero. + SetCursor(String), + /// Apaga el compositor y libera el hardware. + Shutdown, +} + +/// La contabilidad del Cuerpo: salidas y superficies. +#[derive(Debug, Default)] +pub struct BodyState { + outputs: Vec<(OutputId, Rect)>, + /// `BTreeMap` para que el orden de las `BodyOp` sea determinista. + surfaces: BTreeMap, + focused: Option, +} + +impl BodyState { + /// Cuerpo recién arrancado: sin salidas ni superficies. + pub fn new() -> Self { + Self::default() + } + + // --- Traducción de comandos del Cerebro -------------------------- + + /// Traduce un comando del Cerebro a las operaciones de backend que lo + /// materializan. Sólo emite lo que de verdad cambia: un `Place` + /// idéntico al estado actual no produce ninguna `BodyOp`. + pub fn apply(&mut self, cmd: BrainCommand) -> Vec { + match cmd { + BrainCommand::Place(placements) => { + let mut ops = Vec::new(); + let listed: Vec = placements.iter().map(|p| p.id).collect(); + let mut new_focus = None; + + // Reconfigura las superficies que aparecen en la lista. + for p in &placements { + if p.focused { + new_focus = Some(p.id); + } + if let Some(s) = self.surfaces.get_mut(&p.id) { + if s.geometry != Some(p.rect) || s.visible != p.visible { + s.geometry = Some(p.rect); + s.visible = p.visible; + ops.push(BodyOp::Configure { + id: p.id, + rect: p.rect, + visible: p.visible, + }); + } + } + } + + // Oculta lo que el Cerebro ya no coloca. + for (id, s) in &mut self.surfaces { + if !listed.contains(id) && s.visible { + s.visible = false; + let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0)); + ops.push(BodyOp::Configure { id: *id, rect, visible: false }); + } + } + + // Reasigna el foco sólo si cambió. + if new_focus != self.focused { + self.focused = new_focus; + for (id, s) in &mut self.surfaces { + s.focused = Some(*id) == new_focus; + } + ops.push(match new_focus { + Some(id) => BodyOp::Focus(id), + None => BodyOp::Unfocus, + }); + } + ops + } + BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)], + BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)], + BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)], + BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)], + BrainCommand::Shutdown => vec![BodyOp::Shutdown], + } + } + + // --- Mutadores del backend → eventos para el Cerebro ------------- + + /// Registra una salida recién conectada. + pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent { + self.outputs.push((id, Rect::new(0, 0, width, height))); + BodyEvent::OutputAdded { id, width, height } + } + + /// Da de baja una salida desconectada. + pub fn remove_output(&mut self, id: OutputId) -> BodyEvent { + self.outputs.retain(|(o, _)| *o != id); + BodyEvent::OutputRemoved { id } + } + + /// Registra una superficie recién creada por un cliente. + pub fn open_surface( + &mut self, + id: WindowId, + app_id: impl Into, + title: impl Into, + ) -> BodyEvent { + let app_id = app_id.into(); + let title = title.into(); + self.surfaces + .insert(id, Surface::new(app_id.clone(), title.clone())); + BodyEvent::WindowOpened { id, app_id, title } + } + + /// Da de baja una superficie destruida. `None` si no se conocía. + pub fn close_surface(&mut self, id: WindowId) -> Option { + self.surfaces.remove(&id)?; + if self.focused == Some(id) { + self.focused = None; + } + Some(BodyEvent::WindowClosed { id }) + } + + /// Actualiza el título de una superficie. `None` si no se conocía. + pub fn retitle_surface(&mut self, id: WindowId, title: impl Into) -> Option { + let title = title.into(); + let s = self.surfaces.get_mut(&id)?; + s.title = title.clone(); + Some(BodyEvent::WindowRetitled { id, title }) + } + + /// Construye un evento de puntero entrando en una superficie. + pub fn pointer_enter(&self, id: WindowId) -> BodyEvent { + BodyEvent::PointerEntered { id } + } + + /// Construye un evento de atajo pulsado. + pub fn keybind(&self, combo: impl Into) -> BodyEvent { + BodyEvent::Keybind(combo.into()) + } + + // --- Accesores de sólo lectura ----------------------------------- + + /// Las salidas conectadas. + pub fn outputs(&self) -> &[(OutputId, Rect)] { + &self.outputs + } + + /// Una superficie conocida. + pub fn surface(&self, id: WindowId) -> Option<&Surface> { + self.surfaces.get(&id) + } + + /// Número de superficies registradas. + pub fn surface_count(&self) -> usize { + self.surfaces.len() + } + + /// Las superficies visibles, en orden de id. + pub fn visible(&self) -> impl Iterator { + self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s)) + } + + /// La superficie enfocada. + pub fn focused(&self) -> Option { + self.focused + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mirada_protocol::WindowPlacement; + + fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement { + WindowPlacement { + id, + rect: Rect::new(0, 0, 800, 600), + visible, + focused, + } + } + + /// Cuerpo con dos superficies abiertas. + fn body_with_two() -> BodyState { + let mut b = BodyState::new(); + b.add_output(0, 1920, 1080); + b.open_surface(1, "app1", "uno"); + b.open_surface(2, "app2", "dos"); + b + } + + #[test] + fn opening_a_surface_yields_a_window_opened_event() { + let mut b = BodyState::new(); + let ev = b.open_surface(7, "org.brahman.shuma", "shell"); + assert_eq!( + ev, + BodyEvent::WindowOpened { + id: 7, + app_id: "org.brahman.shuma".into(), + title: "shell".into() + } + ); + assert_eq!(b.surface_count(), 1); + } + + #[test] + fn placing_surfaces_configures_and_focuses_them() { + let mut b = body_with_two(); + let ops = b.apply(BrainCommand::Place(vec![ + placement(1, true, false), + placement(2, true, true), + ])); + // Dos Configure + un Focus. + let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count(); + assert_eq!(configures, 2); + assert!(ops.contains(&BodyOp::Focus(2))); + assert_eq!(b.focused(), Some(2)); + assert!(b.surface(2).unwrap().focused); + } + + #[test] + fn an_identical_place_produces_no_ops() { + let mut b = body_with_two(); + let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]); + assert!(!b.apply(cmd.clone()).is_empty()); + // Repetir el mismo Place no cambia nada. + assert!(b.apply(cmd).is_empty()); + } + + #[test] + fn dropping_a_surface_from_the_list_hides_it() { + let mut b = body_with_two(); + b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)])); + // El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio). + let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)])); + assert!(ops.contains(&BodyOp::Configure { + id: 2, + rect: Rect::new(0, 0, 800, 600), + visible: false, + })); + assert!(!b.surface(2).unwrap().visible); + } + + #[test] + fn placement_for_an_unknown_surface_is_ignored() { + let mut b = body_with_two(); + // La 99 no existe — no debe producir Configure. + let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)])); + assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. }))); + } + + #[test] + fn close_and_kill_map_to_client_ops() { + let mut b = body_with_two(); + assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]); + assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]); + } + + #[test] + fn grab_keys_cursor_and_shutdown_pass_through() { + let mut b = BodyState::new(); + assert_eq!( + b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])), + vec![BodyOp::SetGrabs(vec!["Super+q".into()])] + ); + assert_eq!( + b.apply(BrainCommand::SetCursor("crosshair".into())), + vec![BodyOp::SetCursor("crosshair".into())] + ); + assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]); + } + + #[test] + fn closing_a_surface_clears_its_focus() { + let mut b = body_with_two(); + b.apply(BrainCommand::Place(vec![placement(1, true, true)])); + assert_eq!(b.focused(), Some(1)); + let ev = b.close_surface(1); + assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 })); + assert_eq!(b.focused(), None); + assert_eq!(b.surface_count(), 1); + } + + #[test] + fn closing_an_unknown_surface_yields_nothing() { + let mut b = body_with_two(); + assert!(b.close_surface(404).is_none()); + } + + #[test] + fn retitling_updates_the_surface() { + let mut b = body_with_two(); + let ev = b.retitle_surface(1, "uno bis"); + assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() })); + assert_eq!(b.surface(1).unwrap().title, "uno bis"); + } + + #[test] + fn outputs_are_tracked() { + let mut b = BodyState::new(); + b.add_output(0, 2560, 1440); + b.add_output(1, 1920, 1080); + assert_eq!(b.outputs().len(), 2); + b.remove_output(0); + assert_eq!(b.outputs().len(), 1); + assert_eq!(b.outputs()[0].0, 1); + } + + #[test] + fn moving_focus_emits_a_single_focus_op() { + let mut b = body_with_two(); + b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)])); + // Cambia el foco a la 2; geometría igual → sólo un Focus. + let ops = b.apply(BrainCommand::Place(vec![ + placement(1, true, false), + placement(2, true, true), + ])); + assert_eq!(ops, vec![BodyOp::Focus(2)]); + } + + #[test] + fn visible_iterates_only_shown_surfaces() { + let mut b = body_with_two(); + b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)])); + let shown: Vec<_> = b.visible().map(|(id, _)| id).collect(); + assert_eq!(shown, vec![1]); + } +}