gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

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 · (5 months ago) · parent 3e335df29 · browse the tree at this commit

Semantic delta · 35

crates/modules/mirada/mirada-body/examples/headless.rs

5 files · +523 −0

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]);
+    }
+}