feat(mirada): mirada-protocol — contrato Cerebro↔Cuerpo del compositor
BrainCommand/BodyEvent + WindowPlacement, marco postcard con prefijo u32
LE (write_frame/read_frame, guard MAX_FRAME) y el puente placements()
desde un Workspace de mirada-layout. 9 tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index 02fceaa..c5b854e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7397,6 +7397,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "mirada-protocol"
+version = "0.1.0"
+dependencies = [
+ "mirada-layout",
+ "postcard",
+ "serde",
+]
+
[[package]]
name = "moka"
version = "0.12.15"
diff --git a/Cargo.toml b/Cargo.toml
index 12d1ea8..334c019 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -161,6 +161,7 @@ members = [
# modules/mirada/ — Compositor Wayland
# ============================================================
"crates/modules/mirada/mirada-layout",
+ "crates/modules/mirada/mirada-protocol",
# ============================================================
# modules/nakui/ — ERP matemático (categórico)
diff --git a/crates/modules/mirada/mirada-protocol/Cargo.toml b/crates/modules/mirada/mirada-protocol/Cargo.toml
new file mode 100644
index 0000000..73200ea
--- /dev/null
+++ b/crates/modules/mirada/mirada-protocol/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "mirada-protocol"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — contrato Cerebro↔Cuerpo del compositor: comandos de geometría que el Cerebro (GPUI) envía y eventos de hardware/superficies que el Cuerpo (smithay) reporta. Marco postcard con prefijo de longitud."
+
+[dependencies]
+mirada-layout = { path = "../mirada-layout" }
+serde = { workspace = true }
+postcard = { workspace = true }
diff --git a/crates/modules/mirada/mirada-protocol/src/lib.rs b/crates/modules/mirada/mirada-protocol/src/lib.rs
new file mode 100644
index 0000000..7e0a9c2
--- /dev/null
+++ b/crates/modules/mirada/mirada-protocol/src/lib.rs
@@ -0,0 +1,271 @@
+//! `mirada-protocol` — el contrato Cerebro↔Cuerpo del compositor.
+//!
+//! mirada se parte en dos procesos:
+//!
+//! - **El Cuerpo** (`mirada-compositor`, sobre `smithay`): habla Wayland
+//! con los clientes, posee el hardware (DRM/GPU/libinput) y compone las
+//! superficies reales. Los píxeles nunca salen de él.
+//! - **El Cerebro** (una app GPUI sobre [`mirada-layout`]): decide *dónde*
+//! va cada ventana — pura aritmética de rectángulos— y orquesta el
+//! escritorio (layouts, atajos, focos).
+//!
+//! Este crate es el único lenguaje que comparten: un par de enums y un
+//! marco de cable. No depende de Wayland, ni de `smithay`, ni de GPUI —
+//! sólo de [`mirada-layout`] para reusar [`Rect`] y [`WindowId`].
+//!
+//! - El Cerebro emite [`BrainCommand`]; el Cuerpo los aplica.
+//! - El Cuerpo emite [`BodyEvent`]; el Cerebro reacciona y recalcula.
+//!
+//! El cable es [`postcard`] con prefijo de longitud `u32` little-endian
+//! (ver [`write_frame`] / [`read_frame`]).
+
+#![forbid(unsafe_code)]
+
+use std::io::{self, Read, Write};
+
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+
+pub use mirada_layout::geometry::Rect;
+pub use mirada_layout::workspace::WindowId;
+use mirada_layout::{LayoutMode, Workspace};
+
+/// Identificador de una salida física (un monitor).
+pub type OutputId = u32;
+
+/// Dónde y cómo debe colocarse una ventana en pantalla.
+///
+/// Es la unidad de geometría que el Cerebro calcula y el Cuerpo aplica a
+/// la superficie Wayland correspondiente.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WindowPlacement {
+ pub id: WindowId,
+ /// Rectángulo en píxeles de pantalla.
+ pub rect: Rect,
+ /// `false` la oculta sin destruirla (p. ej. en modo `Monocle`).
+ pub visible: bool,
+ /// `true` si esta ventana tiene el foco del teclado.
+ pub focused: bool,
+}
+
+/// Una orden del Cerebro al Cuerpo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BrainCommand {
+ /// Geometría completa del escritorio: el Cuerpo mueve/redimensiona
+ /// cada superficie y oculta las que falten en la lista.
+ Place(Vec<WindowPlacement>),
+ /// Pide el cierre ordenado de una ventana (`xdg_toplevel.close`).
+ Close(WindowId),
+ /// Mata al cliente de una ventana que no responde.
+ Kill(WindowId),
+ /// Registra los atajos globales que el Cuerpo debe interceptar y
+ /// devolver como [`BodyEvent::Keybind`] en vez de pasarlos al cliente.
+ GrabKeys(Vec<String>),
+ /// Cambia el cursor del puntero al nombre dado (tema XCursor).
+ SetCursor(String),
+ /// Apaga el Cuerpo y libera el hardware.
+ Shutdown,
+}
+
+/// Un hecho del Cuerpo que el Cerebro debe conocer.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BodyEvent {
+ /// Apareció un monitor (al arrancar o en caliente).
+ OutputAdded { id: OutputId, width: i32, height: i32 },
+ /// Desapareció un monitor.
+ OutputRemoved { id: OutputId },
+ /// Un cliente creó una ventana de nivel superior.
+ WindowOpened { id: WindowId, app_id: String, title: String },
+ /// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]).
+ WindowClosed { id: WindowId },
+ /// Una ventana cambió su título.
+ WindowRetitled { id: WindowId, title: String },
+ /// El usuario pulsó un atajo registrado con [`BrainCommand::GrabKeys`].
+ Keybind(String),
+ /// El puntero entró en una ventana — el Cerebro puede enfocar al pasar.
+ PointerEntered { id: WindowId },
+}
+
+/// Tamaño máximo de un marco, en bytes. Acota el búfer de [`read_frame`]
+/// para que un prefijo de longitud corrupto no reserve gigabytes.
+pub const MAX_FRAME: usize = 16 * 1024 * 1024;
+
+/// Escribe `value` como un marco: prefijo `u32` LE con la longitud + el
+/// cuerpo serializado con `postcard`.
+pub fn write_frame<W: Write, T: Serialize>(w: &mut W, value: &T) -> io::Result<()> {
+ let body = postcard::to_stdvec(value)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+ if body.len() > MAX_FRAME {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "marco mayor que MAX_FRAME",
+ ));
+ }
+ w.write_all(&(body.len() as u32).to_le_bytes())?;
+ w.write_all(&body)?;
+ w.flush()
+}
+
+/// Lee un marco escrito por [`write_frame`]. Devuelve `Ok(None)` en un
+/// EOF limpio (el otro extremo cerró sin datos a medias).
+pub fn read_frame<R: Read, T: DeserializeOwned>(r: &mut R) -> io::Result<Option<T>> {
+ let mut len = [0u8; 4];
+ match r.read_exact(&mut len) {
+ Ok(()) => {}
+ Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
+ Err(e) => return Err(e),
+ }
+ let len = u32::from_le_bytes(len) as usize;
+ if len > MAX_FRAME {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "prefijo de longitud mayor que MAX_FRAME",
+ ));
+ }
+ let mut body = vec![0u8; len];
+ r.read_exact(&mut body)?;
+ let value = postcard::from_bytes(&body)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+ Ok(Some(value))
+}
+
+/// Traduce un [`Workspace`] de mirada-layout a la geometría de cable.
+///
+/// Es el puente del Cerebro: toma el estado abstracto (ventanas, foco,
+/// modo) y la pantalla física, y produce el [`Vec<WindowPlacement>`] que
+/// va dentro de un [`BrainCommand::Place`].
+///
+/// En modo [`LayoutMode::Monocle`] sólo la ventana enfocada queda
+/// `visible`; en el resto de modos todas lo están.
+pub fn placements(ws: &Workspace, screen: Rect) -> Vec<WindowPlacement> {
+ let monocle = ws.params().mode == LayoutMode::Monocle;
+ let focused = ws.focused();
+ ws.layout(screen)
+ .into_iter()
+ .map(|(id, rect)| {
+ let is_focused = focused == Some(id);
+ WindowPlacement {
+ id,
+ rect,
+ visible: !monocle || is_focused,
+ focused: is_focused,
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mirada_layout::LayoutParams;
+ use std::io::Cursor;
+
+ fn ws(mode: LayoutMode) -> Workspace {
+ let mut w = Workspace::new(LayoutParams { mode, ..LayoutParams::default() });
+ for id in [10, 20, 30] {
+ w.add(id);
+ }
+ w
+ }
+
+ const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
+
+ #[test]
+ fn frame_round_trips_a_brain_command() {
+ let cmd = BrainCommand::Place(vec![WindowPlacement {
+ id: 7,
+ rect: Rect::new(0, 0, 800, 600),
+ visible: true,
+ focused: true,
+ }]);
+ let mut buf = Vec::new();
+ write_frame(&mut buf, &cmd).unwrap();
+ let mut cur = Cursor::new(buf);
+ let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
+ assert_eq!(back, cmd);
+ }
+
+ #[test]
+ fn frame_round_trips_a_body_event() {
+ let ev = BodyEvent::WindowOpened {
+ id: 42,
+ app_id: "org.brahman.shuma".into(),
+ title: "shell".into(),
+ };
+ let mut buf = Vec::new();
+ write_frame(&mut buf, &ev).unwrap();
+ let mut cur = Cursor::new(buf);
+ let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
+ assert_eq!(back, ev);
+ }
+
+ #[test]
+ fn several_frames_stream_in_order() {
+ let evs = vec![
+ BodyEvent::OutputAdded { id: 0, width: 2560, height: 1440 },
+ BodyEvent::WindowOpened { id: 1, app_id: "a".into(), title: "t".into() },
+ BodyEvent::Keybind("Super+Return".into()),
+ ];
+ let mut buf = Vec::new();
+ for ev in &evs {
+ write_frame(&mut buf, ev).unwrap();
+ }
+ let mut cur = Cursor::new(buf);
+ for ev in &evs {
+ let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
+ assert_eq!(&back, ev);
+ }
+ // Agotado el stream, un EOF limpio.
+ assert!(read_frame::<_, BodyEvent>(&mut cur).unwrap().is_none());
+ }
+
+ #[test]
+ fn empty_reader_is_a_clean_eof() {
+ let mut cur = Cursor::new(Vec::new());
+ assert!(read_frame::<_, BrainCommand>(&mut cur).unwrap().is_none());
+ }
+
+ #[test]
+ fn an_oversized_length_prefix_is_rejected() {
+ let mut buf = Vec::new();
+ buf.extend_from_slice(&(u32::MAX).to_le_bytes());
+ let mut cur = Cursor::new(buf);
+ assert!(read_frame::<_, BrainCommand>(&mut cur).is_err());
+ }
+
+ #[test]
+ fn placements_cover_every_window() {
+ let p = placements(&ws(LayoutMode::Columns), SCREEN);
+ assert_eq!(p.len(), 3);
+ assert!(p.iter().all(|w| w.visible));
+ // Sólo una enfocada — la última añadida.
+ assert_eq!(p.iter().filter(|w| w.focused).count(), 1);
+ assert!(p.iter().find(|w| w.id == 30).unwrap().focused);
+ }
+
+ #[test]
+ fn monocle_keeps_only_the_focused_window_visible() {
+ let p = placements(&ws(LayoutMode::Monocle), SCREEN);
+ assert_eq!(p.len(), 3);
+ assert_eq!(p.iter().filter(|w| w.visible).count(), 1);
+ let shown = p.iter().find(|w| w.visible).unwrap();
+ assert!(shown.focused);
+ assert_eq!(shown.id, 30);
+ }
+
+ #[test]
+ fn an_empty_workspace_places_nothing() {
+ let empty = Workspace::new(LayoutParams::default());
+ assert!(placements(&empty, SCREEN).is_empty());
+ }
+
+ #[test]
+ fn placements_fill_a_place_command_round_trip() {
+ let cmd = BrainCommand::Place(placements(&ws(LayoutMode::Grid), SCREEN));
+ let mut buf = Vec::new();
+ write_frame(&mut buf, &cmd).unwrap();
+ let mut cur = Cursor::new(buf);
+ let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
+ assert_eq!(back, cmd);
+ }
+}