⎇ ee27108f6c9abe63ac3abdca743da82b467fc2ca
crates/modules/mirada/mirada-body/src/lib.rs
History
Who touched what
raw
feat(mirada): acople del shell — ventana-dock al pie de la pantalla
1//! `mirada-body` — el estado del Cuerpo del compositor.
2//!
3//! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene
4//! dos mitades: el *backend*, que habla Wayland y posee el hardware, y
5//! esta *contabilidad* — qué salidas y superficies existen y con qué
6//! geometría. Aislarla deja el backend reducido a "ejecuta estas
7//! [`BodyOp`]" y la hace testeable sin un servidor gráfico.
8//!
9//! El flujo es simétrico al del Cerebro:
10//!
11//! - El backend avisa de cambios de hardware/clientes con los mutadores
12//! ([`BodyState::open_surface`], [`BodyState::add_output`], …), que
13//! devuelven el [`BodyEvent`] a mandar al Cerebro.
14//! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los
15//! traduce a [`BodyOp`]s concretas que el backend ejecuta.
16
17#![forbid(unsafe_code)]
18
19use std::collections::BTreeMap;
20
21use mirada_protocol::{BodyEvent, BrainCommand, OutputId, Rect, WindowId};
22
23/// Una superficie Wayland desde la óptica del Cuerpo.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Surface {
26 pub app_id: String,
27 pub title: String,
28 /// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`].
29 pub geometry: Option<Rect>,
30 pub visible: bool,
31 pub focused: bool,
32 /// `true` si flota: el backend la pinta por encima de las teseladas.
33 pub floating: bool,
34 /// `true` si está en pantalla completa.
35 pub fullscreen: bool,
36}
37
38impl Surface {
39 fn new(app_id: String, title: String) -> Self {
40 Self {
41 app_id,
42 title,
43 geometry: None,
44 visible: false,
45 focused: false,
46 floating: false,
47 fullscreen: false,
48 }
49 }
50}
51
52/// Una orden concreta para el backend (smithay, headless, …).
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BodyOp {
55 /// Recoloca una superficie, la muestra u oculta y dice si flota o
56 /// está en pantalla completa (el backend ajusta el orden de pintado
57 /// y el estado `xdg_toplevel` en consecuencia).
58 Configure {
59 id: WindowId,
60 rect: Rect,
61 visible: bool,
62 floating: bool,
63 fullscreen: bool,
64 },
65 /// Da el foco del teclado a una superficie.
66 Focus(WindowId),
67 /// Quita el foco a todas las superficies.
68 Unfocus,
69 /// Pide el cierre ordenado de un cliente.
70 CloseClient(WindowId),
71 /// Mata a un cliente que no responde.
72 KillClient(WindowId),
73 /// Registra los atajos globales a interceptar.
74 SetGrabs(Vec<String>),
75 /// Cambia el cursor del puntero.
76 SetCursor(String),
77 /// Lanza un programa como proceso hijo del compositor.
78 Spawn(String),
79 /// Apaga el compositor y libera el hardware.
80 Shutdown,
81}
82
83/// La contabilidad del Cuerpo: salidas y superficies.
84#[derive(Debug, Default)]
85pub struct BodyState {
86 outputs: Vec<(OutputId, Rect)>,
87 /// `BTreeMap` para que el orden de las `BodyOp` sea determinista.
88 surfaces: BTreeMap<WindowId, Surface>,
89 focused: Option<WindowId>,
90}
91
92impl BodyState {
93 /// Cuerpo recién arrancado: sin salidas ni superficies.
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 // --- Traducción de comandos del Cerebro --------------------------
99
100 /// Traduce un comando del Cerebro a las operaciones de backend que lo
101 /// materializan. Sólo emite lo que de verdad cambia: un `Place`
102 /// idéntico al estado actual no produce ninguna `BodyOp`.
103 pub fn apply(&mut self, cmd: BrainCommand) -> Vec<BodyOp> {
104 match cmd {
105 BrainCommand::Place(placements) => {
106 let mut ops = Vec::new();
107 let listed: Vec<WindowId> = placements.iter().map(|p| p.id).collect();
108 let mut new_focus = None;
109
110 // Reconfigura las superficies que aparecen en la lista.
111 for p in &placements {
112 if p.focused {
113 new_focus = Some(p.id);
114 }
115 if let Some(s) = self.surfaces.get_mut(&p.id) {
116 if s.geometry != Some(p.rect)
117 || s.visible != p.visible
118 || s.floating != p.floating
119 || s.fullscreen != p.fullscreen
120 {
121 s.geometry = Some(p.rect);
122 s.visible = p.visible;
123 s.floating = p.floating;
124 s.fullscreen = p.fullscreen;
125 ops.push(BodyOp::Configure {
126 id: p.id,
127 rect: p.rect,
128 visible: p.visible,
129 floating: p.floating,
130 fullscreen: p.fullscreen,
131 });
132 }
133 }
134 }
135
136 // Oculta lo que el Cerebro ya no coloca.
137 for (id, s) in &mut self.surfaces {
138 if !listed.contains(id) && s.visible {
139 s.visible = false;
140 let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0));
141 ops.push(BodyOp::Configure {
142 id: *id,
143 rect,
144 visible: false,
145 floating: s.floating,
146 fullscreen: s.fullscreen,
147 });
148 }
149 }
150
151 // Reasigna el foco sólo si cambió.
152 if new_focus != self.focused {
153 self.focused = new_focus;
154 for (id, s) in &mut self.surfaces {
155 s.focused = Some(*id) == new_focus;
156 }
157 ops.push(match new_focus {
158 Some(id) => BodyOp::Focus(id),
159 None => BodyOp::Unfocus,
160 });
161 }
162 ops
163 }
164 BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)],
165 BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)],
166 BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)],
167 BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)],
168 BrainCommand::Spawn(cmd) => vec![BodyOp::Spawn(cmd)],
169 BrainCommand::Shutdown => vec![BodyOp::Shutdown],
170 }
171 }
172
173 // --- Mutadores del backend → eventos para el Cerebro -------------
174
175 /// Registra una salida recién conectada.
176 pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
177 self.outputs.push((id, Rect::new(0, 0, width, height)));
178 BodyEvent::OutputAdded { id, width, height }
179 }
180
181 /// Da de baja una salida desconectada.
182 pub fn remove_output(&mut self, id: OutputId) -> BodyEvent {
183 self.outputs.retain(|(o, _)| *o != id);
184 BodyEvent::OutputRemoved { id }
185 }
186
187 /// Cambia el área útil de una salida sin desconectarla — al
188 /// redimensionar la ventana anfitriona o al reservar/liberar la
189 /// franja del shell. Conserva el escritorio que muestra.
190 pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
191 if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) {
192 rect.w = width;
193 rect.h = height;
194 }
195 BodyEvent::OutputResized { id, width, height }
196 }
197
198 /// Registra una superficie recién creada por un cliente.
199 pub fn open_surface(
200 &mut self,
201 id: WindowId,
202 app_id: impl Into<String>,
203 title: impl Into<String>,
204 ) -> BodyEvent {
205 let app_id = app_id.into();
206 let title = title.into();
207 self.surfaces
208 .insert(id, Surface::new(app_id.clone(), title.clone()));
209 BodyEvent::WindowOpened { id, app_id, title }
210 }
211
212 /// Da de baja una superficie destruida. `None` si no se conocía.
213 pub fn close_surface(&mut self, id: WindowId) -> Option<BodyEvent> {
214 self.surfaces.remove(&id)?;
215 if self.focused == Some(id) {
216 self.focused = None;
217 }
218 Some(BodyEvent::WindowClosed { id })
219 }
220
221 /// Actualiza el título de una superficie. `None` si no se conocía.
222 pub fn retitle_surface(&mut self, id: WindowId, title: impl Into<String>) -> Option<BodyEvent> {
223 let title = title.into();
224 let s = self.surfaces.get_mut(&id)?;
225 s.title = title.clone();
226 Some(BodyEvent::WindowRetitled { id, title })
227 }
228
229 /// Construye un evento de puntero entrando en una superficie.
230 pub fn pointer_enter(&self, id: WindowId) -> BodyEvent {
231 BodyEvent::PointerEntered { id }
232 }
233
234 /// Construye un evento de atajo pulsado.
235 pub fn keybind(&self, combo: impl Into<String>) -> BodyEvent {
236 BodyEvent::Keybind(combo.into())
237 }
238
239 // --- Accesores de sólo lectura -----------------------------------
240
241 /// Las salidas conectadas.
242 pub fn outputs(&self) -> &[(OutputId, Rect)] {
243 &self.outputs
244 }
245
246 /// Una superficie conocida.
247 pub fn surface(&self, id: WindowId) -> Option<&Surface> {
248 self.surfaces.get(&id)
249 }
250
251 /// Número de superficies registradas.
252 pub fn surface_count(&self) -> usize {
253 self.surfaces.len()
254 }
255
256 /// Las superficies visibles, en orden de id.
257 pub fn visible(&self) -> impl Iterator<Item = (WindowId, &Surface)> {
258 self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s))
259 }
260
261 /// La superficie enfocada.
262 pub fn focused(&self) -> Option<WindowId> {
263 self.focused
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use mirada_protocol::WindowPlacement;
271
272 fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement {
273 WindowPlacement {
274 id,
275 rect: Rect::new(0, 0, 800, 600),
276 visible,
277 focused,
278 floating: false,
279 fullscreen: false,
280 }
281 }
282
283 /// Cuerpo con dos superficies abiertas.
284 fn body_with_two() -> BodyState {
285 let mut b = BodyState::new();
286 b.add_output(0, 1920, 1080);
287 b.open_surface(1, "app1", "uno");
288 b.open_surface(2, "app2", "dos");
289 b
290 }
291
292 #[test]
293 fn opening_a_surface_yields_a_window_opened_event() {
294 let mut b = BodyState::new();
295 let ev = b.open_surface(7, "org.brahman.shuma", "shell");
296 assert_eq!(
297 ev,
298 BodyEvent::WindowOpened {
299 id: 7,
300 app_id: "org.brahman.shuma".into(),
301 title: "shell".into()
302 }
303 );
304 assert_eq!(b.surface_count(), 1);
305 }
306
307 #[test]
308 fn placing_surfaces_configures_and_focuses_them() {
309 let mut b = body_with_two();
310 let ops = b.apply(BrainCommand::Place(vec![
311 placement(1, true, false),
312 placement(2, true, true),
313 ]));
314 // Dos Configure + un Focus.
315 let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count();
316 assert_eq!(configures, 2);
317 assert!(ops.contains(&BodyOp::Focus(2)));
318 assert_eq!(b.focused(), Some(2));
319 assert!(b.surface(2).unwrap().focused);
320 }
321
322 #[test]
323 fn an_identical_place_produces_no_ops() {
324 let mut b = body_with_two();
325 let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]);
326 assert!(!b.apply(cmd.clone()).is_empty());
327 // Repetir el mismo Place no cambia nada.
328 assert!(b.apply(cmd).is_empty());
329 }
330
331 #[test]
332 fn dropping_a_surface_from_the_list_hides_it() {
333 let mut b = body_with_two();
334 b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
335 // El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio).
336 let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
337 assert!(ops.contains(&BodyOp::Configure {
338 id: 2,
339 rect: Rect::new(0, 0, 800, 600),
340 visible: false,
341 floating: false,
342 fullscreen: false,
343 }));
344 assert!(!b.surface(2).unwrap().visible);
345 }
346
347 #[test]
348 fn placement_for_an_unknown_surface_is_ignored() {
349 let mut b = body_with_two();
350 // La 99 no existe — no debe producir Configure.
351 let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)]));
352 assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. })));
353 }
354
355 #[test]
356 fn close_and_kill_map_to_client_ops() {
357 let mut b = body_with_two();
358 assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]);
359 assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]);
360 }
361
362 #[test]
363 fn grab_keys_cursor_and_shutdown_pass_through() {
364 let mut b = BodyState::new();
365 assert_eq!(
366 b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])),
367 vec![BodyOp::SetGrabs(vec!["Super+q".into()])]
368 );
369 assert_eq!(
370 b.apply(BrainCommand::SetCursor("crosshair".into())),
371 vec![BodyOp::SetCursor("crosshair".into())]
372 );
373 assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]);
374 }
375
376 #[test]
377 fn closing_a_surface_clears_its_focus() {
378 let mut b = body_with_two();
379 b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
380 assert_eq!(b.focused(), Some(1));
381 let ev = b.close_surface(1);
382 assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 }));
383 assert_eq!(b.focused(), None);
384 assert_eq!(b.surface_count(), 1);
385 }
386
387 #[test]
388 fn closing_an_unknown_surface_yields_nothing() {
389 let mut b = body_with_two();
390 assert!(b.close_surface(404).is_none());
391 }
392
393 #[test]
394 fn retitling_updates_the_surface() {
395 let mut b = body_with_two();
396 let ev = b.retitle_surface(1, "uno bis");
397 assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() }));
398 assert_eq!(b.surface(1).unwrap().title, "uno bis");
399 }
400
401 #[test]
402 fn outputs_are_tracked() {
403 let mut b = BodyState::new();
404 b.add_output(0, 2560, 1440);
405 b.add_output(1, 1920, 1080);
406 assert_eq!(b.outputs().len(), 2);
407 b.remove_output(0);
408 assert_eq!(b.outputs().len(), 1);
409 assert_eq!(b.outputs()[0].0, 1);
410 }
411
412 #[test]
413 fn moving_focus_emits_a_single_focus_op() {
414 let mut b = body_with_two();
415 b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
416 // Cambia el foco a la 2; geometría igual → sólo un Focus.
417 let ops = b.apply(BrainCommand::Place(vec![
418 placement(1, true, false),
419 placement(2, true, true),
420 ]));
421 assert_eq!(ops, vec![BodyOp::Focus(2)]);
422 }
423
424 #[test]
425 fn a_floating_change_alone_triggers_a_configure() {
426 let mut b = body_with_two();
427 let mut p1 = placement(1, true, true);
428 b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
429 // Sólo cambia `floating` — misma geometría y visibilidad.
430 p1.floating = true;
431 let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
432 assert!(ops
433 .iter()
434 .any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. })));
435 assert!(b.surface(1).unwrap().floating);
436 }
437
438 #[test]
439 fn visible_iterates_only_shown_surfaces() {
440 let mut b = body_with_two();
441 b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)]));
442 let shown: Vec<_> = b.visible().map(|(id, _)| id).collect();
443 assert_eq!(shown, vec![1]);
444 }
445}