⎇ ee27108f6c9abe63ac3abdca743da82b467fc2ca
crates/modules/mirada/mirada-protocol/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(mirada): acople del shell — ventana-dock al pie de la pantalla
1//! `mirada-protocol` — el contrato Cerebro↔Cuerpo del compositor.
2//!
3//! mirada se parte en dos procesos:
4//!
5//! - **El Cuerpo** (`mirada-compositor`, sobre `smithay`): habla Wayland
6//! con los clientes, posee el hardware (DRM/GPU/libinput) y compone las
7//! superficies reales. Los píxeles nunca salen de él.
8//! - **El Cerebro** (una app GPUI sobre [`mirada-layout`]): decide *dónde*
9//! va cada ventana — pura aritmética de rectángulos— y orquesta el
10//! escritorio (layouts, atajos, focos).
11//!
12//! Este crate es el único lenguaje que comparten: un par de enums y un
13//! marco de cable. No depende de Wayland, ni de `smithay`, ni de GPUI —
14//! sólo de [`mirada-layout`] para reusar [`Rect`] y [`WindowId`].
15//!
16//! - El Cerebro emite [`BrainCommand`]; el Cuerpo los aplica.
17//! - El Cuerpo emite [`BodyEvent`]; el Cerebro reacciona y recalcula.
18//!
19//! El cable es [`postcard`] con prefijo de longitud `u32` little-endian
20//! (ver [`write_frame`] / [`read_frame`]).
21
22#![forbid(unsafe_code)]
23
24use std::io::{self, Read, Write};
25
26use serde::de::DeserializeOwned;
27use serde::{Deserialize, Serialize};
28
29pub use mirada_layout::geometry::Rect;
30pub use mirada_layout::workspace::WindowId;
31use mirada_layout::{LayoutMode, Workspace};
32
33/// Identificador de una salida física (un monitor).
34pub type OutputId = u32;
35
36/// Dónde y cómo debe colocarse una ventana en pantalla.
37///
38/// Es la unidad de geometría que el Cerebro calcula y el Cuerpo aplica a
39/// la superficie Wayland correspondiente.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct WindowPlacement {
42 pub id: WindowId,
43 /// Rectángulo en píxeles de pantalla.
44 pub rect: Rect,
45 /// `false` la oculta sin destruirla (p. ej. en modo `Monocle`).
46 pub visible: bool,
47 /// `true` si esta ventana tiene el foco del teclado.
48 pub focused: bool,
49 /// `true` si flota (fuera del teselado): el Cuerpo la pinta encima.
50 pub floating: bool,
51 /// `true` si está en pantalla completa: cubre toda la salida.
52 pub fullscreen: bool,
53}
54
55/// Una orden del Cerebro al Cuerpo.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub enum BrainCommand {
58 /// Geometría completa del escritorio: el Cuerpo mueve/redimensiona
59 /// cada superficie y oculta las que falten en la lista.
60 Place(Vec<WindowPlacement>),
61 /// Pide el cierre ordenado de una ventana (`xdg_toplevel.close`).
62 Close(WindowId),
63 /// Mata al cliente de una ventana que no responde.
64 Kill(WindowId),
65 /// Registra los atajos globales que el Cuerpo debe interceptar y
66 /// devolver como [`BodyEvent::Keybind`] en vez de pasarlos al cliente.
67 GrabKeys(Vec<String>),
68 /// Cambia el cursor del puntero al nombre dado (tema XCursor).
69 SetCursor(String),
70 /// Lanza un programa como proceso hijo del Cuerpo — hereda su
71 /// entorno, `WAYLAND_DISPLAY` incluido, así el cliente se conecta
72 /// aquí. La cadena se pasa a `sh -c`.
73 Spawn(String),
74 /// Apaga el Cuerpo y libera el hardware.
75 Shutdown,
76}
77
78/// Un hecho del Cuerpo que el Cerebro debe conocer.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub enum BodyEvent {
81 /// Apareció un monitor (al arrancar o en caliente).
82 OutputAdded { id: OutputId, width: i32, height: i32 },
83 /// Desapareció un monitor.
84 OutputRemoved { id: OutputId },
85 /// Cambió el área útil de un monitor — porque se redimensionó la
86 /// ventana anfitriona, o porque el shell reservó o liberó su franja.
87 /// El escritorio que muestra **no** cambia (a diferencia de quitar y
88 /// volver a añadir la salida).
89 OutputResized { id: OutputId, width: i32, height: i32 },
90 /// Un cliente creó una ventana de nivel superior.
91 WindowOpened { id: WindowId, app_id: String, title: String },
92 /// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]).
93 WindowClosed { id: WindowId },
94 /// Una ventana cambió su título.
95 WindowRetitled { id: WindowId, title: String },
96 /// El usuario pulsó un atajo registrado con [`BrainCommand::GrabKeys`].
97 Keybind(String),
98 /// El puntero entró en una ventana — el Cerebro puede enfocar al pasar.
99 PointerEntered { id: WindowId },
100 /// Un cliente pidió pantalla completa para su ventana (`true`), o la
101 /// soltó (`false`) — `xdg_toplevel.set_fullscreen`.
102 FullscreenRequest { id: WindowId, fullscreen: bool },
103 /// El usuario arrastró una ventana con el ratón a un rectángulo nuevo
104 /// (mover o redimensionar interactivos). El Cerebro la hace flotar
105 /// ahí; si estaba teselada, deja de estarlo.
106 WindowFloatTo { id: WindowId, rect: Rect },
107}
108
109/// Tamaño máximo de un marco, en bytes. Acota el búfer de [`read_frame`]
110/// para que un prefijo de longitud corrupto no reserve gigabytes.
111pub const MAX_FRAME: usize = 16 * 1024 * 1024;
112
113/// Escribe `value` como un marco: prefijo `u32` LE con la longitud + el
114/// cuerpo serializado con `postcard`.
115pub fn write_frame<W: Write, T: Serialize>(w: &mut W, value: &T) -> io::Result<()> {
116 let body = postcard::to_stdvec(value)
117 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
118 if body.len() > MAX_FRAME {
119 return Err(io::Error::new(
120 io::ErrorKind::InvalidData,
121 "marco mayor que MAX_FRAME",
122 ));
123 }
124 w.write_all(&(body.len() as u32).to_le_bytes())?;
125 w.write_all(&body)?;
126 w.flush()
127}
128
129/// Lee un marco escrito por [`write_frame`]. Devuelve `Ok(None)` en un
130/// EOF limpio (el otro extremo cerró sin datos a medias).
131pub fn read_frame<R: Read, T: DeserializeOwned>(r: &mut R) -> io::Result<Option<T>> {
132 let mut len = [0u8; 4];
133 match r.read_exact(&mut len) {
134 Ok(()) => {}
135 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
136 Err(e) => return Err(e),
137 }
138 let len = u32::from_le_bytes(len) as usize;
139 if len > MAX_FRAME {
140 return Err(io::Error::new(
141 io::ErrorKind::InvalidData,
142 "prefijo de longitud mayor que MAX_FRAME",
143 ));
144 }
145 let mut body = vec![0u8; len];
146 r.read_exact(&mut body)?;
147 let value = postcard::from_bytes(&body)
148 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
149 Ok(Some(value))
150}
151
152/// Traduce un [`Workspace`] de mirada-layout a la geometría de cable.
153///
154/// Es el puente del Cerebro: toma el estado abstracto (ventanas, foco,
155/// modo) y la pantalla física, y produce el [`Vec<WindowPlacement>`] que
156/// va dentro de un [`BrainCommand::Place`].
157///
158/// En modo [`LayoutMode::Monocle`] sólo la ventana enfocada queda
159/// `visible`; en el resto de modos todas lo están.
160pub fn placements(ws: &Workspace, screen: Rect) -> Vec<WindowPlacement> {
161 let fullscreen = ws.fullscreen();
162 let monocle = ws.params().mode == LayoutMode::Monocle;
163 let focused = ws.focused();
164 ws.layout(screen)
165 .into_iter()
166 .map(|(id, rect)| {
167 let floating = ws.is_floating(id);
168 let is_fs = fullscreen == Some(id);
169 // Con una ventana en pantalla completa manda ella: ocupa toda
170 // la salida, es la única visible y se lleva el foco.
171 let (rect, visible, is_focused) = match fullscreen {
172 Some(_) => (if is_fs { screen } else { rect }, is_fs, is_fs),
173 None => {
174 let f = focused == Some(id);
175 // Una flotante siempre se ve; en `Monocle`, sólo la enfocada.
176 (rect, floating || !monocle || f, f)
177 }
178 };
179 WindowPlacement {
180 id,
181 rect,
182 visible,
183 focused: is_focused,
184 floating,
185 fullscreen: is_fs,
186 }
187 })
188 .collect()
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use mirada_layout::LayoutParams;
195 use std::io::Cursor;
196
197 fn ws(mode: LayoutMode) -> Workspace {
198 let mut w = Workspace::new(LayoutParams { mode, ..LayoutParams::default() });
199 for id in [10, 20, 30] {
200 w.add(id);
201 }
202 w
203 }
204
205 const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
206
207 #[test]
208 fn frame_round_trips_a_brain_command() {
209 let cmd = BrainCommand::Place(vec![WindowPlacement {
210 id: 7,
211 rect: Rect::new(0, 0, 800, 600),
212 visible: true,
213 focused: true,
214 floating: false,
215 fullscreen: false,
216 }]);
217 let mut buf = Vec::new();
218 write_frame(&mut buf, &cmd).unwrap();
219 let mut cur = Cursor::new(buf);
220 let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
221 assert_eq!(back, cmd);
222 }
223
224 #[test]
225 fn frame_round_trips_a_body_event() {
226 let ev = BodyEvent::WindowOpened {
227 id: 42,
228 app_id: "org.brahman.shuma".into(),
229 title: "shell".into(),
230 };
231 let mut buf = Vec::new();
232 write_frame(&mut buf, &ev).unwrap();
233 let mut cur = Cursor::new(buf);
234 let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
235 assert_eq!(back, ev);
236 }
237
238 #[test]
239 fn several_frames_stream_in_order() {
240 let evs = vec![
241 BodyEvent::OutputAdded { id: 0, width: 2560, height: 1440 },
242 BodyEvent::WindowOpened { id: 1, app_id: "a".into(), title: "t".into() },
243 BodyEvent::Keybind("Super+Return".into()),
244 ];
245 let mut buf = Vec::new();
246 for ev in &evs {
247 write_frame(&mut buf, ev).unwrap();
248 }
249 let mut cur = Cursor::new(buf);
250 for ev in &evs {
251 let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
252 assert_eq!(&back, ev);
253 }
254 // Agotado el stream, un EOF limpio.
255 assert!(read_frame::<_, BodyEvent>(&mut cur).unwrap().is_none());
256 }
257
258 #[test]
259 fn empty_reader_is_a_clean_eof() {
260 let mut cur = Cursor::new(Vec::new());
261 assert!(read_frame::<_, BrainCommand>(&mut cur).unwrap().is_none());
262 }
263
264 #[test]
265 fn an_oversized_length_prefix_is_rejected() {
266 let mut buf = Vec::new();
267 buf.extend_from_slice(&(u32::MAX).to_le_bytes());
268 let mut cur = Cursor::new(buf);
269 assert!(read_frame::<_, BrainCommand>(&mut cur).is_err());
270 }
271
272 #[test]
273 fn placements_cover_every_window() {
274 let p = placements(&ws(LayoutMode::Columns), SCREEN);
275 assert_eq!(p.len(), 3);
276 assert!(p.iter().all(|w| w.visible));
277 // Sólo una enfocada — la última añadida.
278 assert_eq!(p.iter().filter(|w| w.focused).count(), 1);
279 assert!(p.iter().find(|w| w.id == 30).unwrap().focused);
280 }
281
282 #[test]
283 fn monocle_keeps_only_the_focused_window_visible() {
284 let p = placements(&ws(LayoutMode::Monocle), SCREEN);
285 assert_eq!(p.len(), 3);
286 assert_eq!(p.iter().filter(|w| w.visible).count(), 1);
287 let shown = p.iter().find(|w| w.visible).unwrap();
288 assert!(shown.focused);
289 assert_eq!(shown.id, 30);
290 }
291
292 #[test]
293 fn an_empty_workspace_places_nothing() {
294 let empty = Workspace::new(LayoutParams::default());
295 assert!(placements(&empty, SCREEN).is_empty());
296 }
297
298 #[test]
299 fn a_floating_window_is_marked_and_stays_visible_in_monocle() {
300 let mut w = ws(LayoutMode::Monocle); // Monocle oculta las no enfocadas
301 w.set_floating(10, Some(Rect::new(0, 0, 200, 200)));
302 let p = placements(&w, SCREEN);
303 let f = p.iter().find(|x| x.id == 10).unwrap();
304 assert!(f.floating);
305 assert!(f.visible, "una flotante se ve aunque el modo sea Monocle");
306 // Y conserva su rectángulo flotante.
307 assert_eq!(f.rect, Rect::new(0, 0, 200, 200));
308 }
309
310 #[test]
311 fn a_fullscreen_window_covers_the_screen_and_hides_the_rest() {
312 let mut w = ws(LayoutMode::Columns);
313 w.set_fullscreen(Some(20));
314 let p = placements(&w, SCREEN);
315 let fs = p.iter().find(|x| x.id == 20).unwrap();
316 assert!(fs.fullscreen);
317 assert!(fs.focused, "la ventana en pantalla completa se lleva el foco");
318 assert_eq!(fs.rect, SCREEN);
319 // El resto queda oculto.
320 assert!(p.iter().filter(|x| x.id != 20).all(|x| !x.visible));
321 }
322
323 #[test]
324 fn placements_fill_a_place_command_round_trip() {
325 let cmd = BrainCommand::Place(placements(&ws(LayoutMode::Grid), SCREEN));
326 let mut buf = Vec::new();
327 write_frame(&mut buf, &cmd).unwrap();
328 let mut cur = Cursor::new(buf);
329 let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
330 assert_eq!(back, cmd);
331 }
332}