feat(mirada): mirada-body — contabilidad del Cuerpo del compositor
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 <noreply@anthropic.com>
4caf92482f2f21b3e8636f41aa36297a8aa29bdc · by sergio · 2026-05-20T21:09:09Z (5 months ago) · parent 3e335df29 · browse the tree at this commit
Semantic delta · 35
crates/modules/mirada/mirada-body/examples/headless.rs
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 <w> <h>`, `open <app>`, `close <id>`,
+//! `title <id> <texto>`, `key <combo>`, `pointer <id>`, `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 <ancho> <alto>");
+ 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 <id>");
+ None
+ }
+ },
+ "title" if rest.len() >= 2 => match rest[0].parse() {
+ Ok(id) => body.retitle_surface(id, rest[1..].join(" ")),
+ Err(_) => {
+ eprintln!("uso: title <id> <texto>");
+ 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 <id>");
+ 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<Rect>,
+ 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<String>),
+ /// 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<WindowId, Surface>,
+ focused: Option<WindowId>,
+}
+
+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<BodyOp> {
+ match cmd {
+ BrainCommand::Place(placements) => {
+ let mut ops = Vec::new();
+ let listed: Vec<WindowId> = 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<String>,
+ title: impl Into<String>,
+ ) -> 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<BodyEvent> {
+ 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<String>) -> Option<BodyEvent> {
+ 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<String>) -> 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<Item = (WindowId, &Surface)> {
+ self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s))
+ }
+
+ /// La superficie enfocada.
+ pub fn focused(&self) -> Option<WindowId> {
+ 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]);
+ }
+}