Files
brahman/crates/modules/mirada/mirada-protocol/src/lib.rs
T
sergio fb3091d995 feat(mirada): acción spawn — lanzar programas desde el compositor
Un escritorio sin forma de abrir una terminal no es usable. Ahora el
keymap puede lanzar programas:

- `mirada-protocol`: nuevo `BrainCommand::Spawn(String)`.
- `mirada-brain`: `DesktopAction::Spawn(String)` con forma textual
  `spawn:<comando>` (`Display`/`FromStr`); `Desktop::apply` la traduce
  a `BrainCommand::Spawn`. El keymap por defecto trae
  `Super+Shift+Return` → `spawn:foot`. `DesktopAction` deja de ser
  `Copy` (lleva el comando) — `Keymap::lookup` clona en vez de copiar.
- `mirada-body`: `BodyOp::Spawn(String)`.
- `mirada-compositor`: `exec_op` ejecuta el spawn con un helper
  `spawn_command` (`sh -c`, hereda `WAYLAND_DISPLAY`), que también
  recoge el lanzamiento de `MIRADA_STARTUP` — antes duplicado.

`spawn:foot --title x` también funciona desde `mirada-ctl`. Tests
nuevos del round-trip textual y del flujo atajo→comando.

Nota: un keymap.ron ya existente no recibe el atajo nuevo; hay que
añadir la línea a mano o borrar el archivo para regenerarlo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 03:59:37 +00:00

328 lines
12 KiB
Rust

//! `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,
/// `true` si flota (fuera del teselado): el Cuerpo la pinta encima.
pub floating: bool,
/// `true` si está en pantalla completa: cubre toda la salida.
pub fullscreen: 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),
/// Lanza un programa como proceso hijo del Cuerpo — hereda su
/// entorno, `WAYLAND_DISPLAY` incluido, así el cliente se conecta
/// aquí. La cadena se pasa a `sh -c`.
Spawn(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 },
/// Un cliente pidió pantalla completa para su ventana (`true`), o la
/// soltó (`false`) — `xdg_toplevel.set_fullscreen`.
FullscreenRequest { id: WindowId, fullscreen: bool },
/// El usuario arrastró una ventana con el ratón a un rectángulo nuevo
/// (mover o redimensionar interactivos). El Cerebro la hace flotar
/// ahí; si estaba teselada, deja de estarlo.
WindowFloatTo { id: WindowId, rect: Rect },
}
/// 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 fullscreen = ws.fullscreen();
let monocle = ws.params().mode == LayoutMode::Monocle;
let focused = ws.focused();
ws.layout(screen)
.into_iter()
.map(|(id, rect)| {
let floating = ws.is_floating(id);
let is_fs = fullscreen == Some(id);
// Con una ventana en pantalla completa manda ella: ocupa toda
// la salida, es la única visible y se lleva el foco.
let (rect, visible, is_focused) = match fullscreen {
Some(_) => (if is_fs { screen } else { rect }, is_fs, is_fs),
None => {
let f = focused == Some(id);
// Una flotante siempre se ve; en `Monocle`, sólo la enfocada.
(rect, floating || !monocle || f, f)
}
};
WindowPlacement {
id,
rect,
visible,
focused: is_focused,
floating,
fullscreen: is_fs,
}
})
.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,
floating: false,
fullscreen: false,
}]);
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 a_floating_window_is_marked_and_stays_visible_in_monocle() {
let mut w = ws(LayoutMode::Monocle); // Monocle oculta las no enfocadas
w.set_floating(10, Some(Rect::new(0, 0, 200, 200)));
let p = placements(&w, SCREEN);
let f = p.iter().find(|x| x.id == 10).unwrap();
assert!(f.floating);
assert!(f.visible, "una flotante se ve aunque el modo sea Monocle");
// Y conserva su rectángulo flotante.
assert_eq!(f.rect, Rect::new(0, 0, 200, 200));
}
#[test]
fn a_fullscreen_window_covers_the_screen_and_hides_the_rest() {
let mut w = ws(LayoutMode::Columns);
w.set_fullscreen(Some(20));
let p = placements(&w, SCREEN);
let fs = p.iter().find(|x| x.id == 20).unwrap();
assert!(fs.fullscreen);
assert!(fs.focused, "la ventana en pantalla completa se lleva el foco");
assert_eq!(fs.rect, SCREEN);
// El resto queda oculto.
assert!(p.iter().filter(|x| x.id != 20).all(|x| !x.visible));
}
#[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);
}
}