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), + /// 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), + /// 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: &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: &mut R) -> io::Result> { + 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`] 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 { + 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); + } +}