//! `mirada-body` — el estado del Cuerpo del compositor. //! //! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene //! dos mitades: el *backend*, que habla Wayland y posee el hardware, y //! esta *contabilidad* — qué salidas y superficies existen y con qué //! geometría. Aislarla deja el backend reducido a "ejecuta estas //! [`BodyOp`]" y la hace testeable sin un servidor gráfico. //! //! El flujo es simétrico al del Cerebro: //! //! - El backend avisa de cambios de hardware/clientes con los mutadores //! ([`BodyState::open_surface`], [`BodyState::add_output`], …), que //! devuelven el [`BodyEvent`] a mandar al Cerebro. //! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los //! traduce a [`BodyOp`]s concretas que el backend ejecuta. #![forbid(unsafe_code)] use std::collections::BTreeMap; use mirada_protocol::{BodyEvent, BrainCommand, OutputId, Rect, WindowId}; /// Una superficie Wayland desde la óptica del Cuerpo. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Surface { pub app_id: String, pub title: String, /// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`]. pub geometry: Option, pub visible: bool, pub focused: bool, /// `true` si flota: el backend la pinta por encima de las teseladas. pub floating: bool, /// `true` si está en pantalla completa. pub fullscreen: bool, } impl Surface { fn new(app_id: String, title: String) -> Self { Self { app_id, title, geometry: None, visible: false, focused: false, floating: false, fullscreen: false, } } } /// Una orden concreta para el backend (smithay, headless, …). #[derive(Debug, Clone, PartialEq, Eq)] pub enum BodyOp { /// Recoloca una superficie, la muestra u oculta y dice si flota o /// está en pantalla completa (el backend ajusta el orden de pintado /// y el estado `xdg_toplevel` en consecuencia). Configure { id: WindowId, rect: Rect, visible: bool, floating: bool, fullscreen: bool, }, /// Da el foco del teclado a una superficie. Focus(WindowId), /// Quita el foco a todas las superficies. Unfocus, /// Pide el cierre ordenado de un cliente. CloseClient(WindowId), /// Mata a un cliente que no responde. KillClient(WindowId), /// Registra los atajos globales a interceptar. SetGrabs(Vec), /// Cambia el cursor del puntero. SetCursor(String), /// Lanza un programa como proceso hijo del compositor. Spawn(String), /// Apaga el compositor y libera el hardware. Shutdown, } /// La contabilidad del Cuerpo: salidas y superficies. #[derive(Debug, Default)] pub struct BodyState { outputs: Vec<(OutputId, Rect)>, /// `BTreeMap` para que el orden de las `BodyOp` sea determinista. surfaces: BTreeMap, focused: Option, } impl BodyState { /// Cuerpo recién arrancado: sin salidas ni superficies. pub fn new() -> Self { Self::default() } // --- Traducción de comandos del Cerebro -------------------------- /// Traduce un comando del Cerebro a las operaciones de backend que lo /// materializan. Sólo emite lo que de verdad cambia: un `Place` /// idéntico al estado actual no produce ninguna `BodyOp`. pub fn apply(&mut self, cmd: BrainCommand) -> Vec { match cmd { BrainCommand::Place(placements) => { let mut ops = Vec::new(); let listed: Vec = placements.iter().map(|p| p.id).collect(); let mut new_focus = None; // Reconfigura las superficies que aparecen en la lista. for p in &placements { if p.focused { new_focus = Some(p.id); } if let Some(s) = self.surfaces.get_mut(&p.id) { if s.geometry != Some(p.rect) || s.visible != p.visible || s.floating != p.floating || s.fullscreen != p.fullscreen { s.geometry = Some(p.rect); s.visible = p.visible; s.floating = p.floating; s.fullscreen = p.fullscreen; ops.push(BodyOp::Configure { id: p.id, rect: p.rect, visible: p.visible, floating: p.floating, fullscreen: p.fullscreen, }); } } } // Oculta lo que el Cerebro ya no coloca. for (id, s) in &mut self.surfaces { if !listed.contains(id) && s.visible { s.visible = false; let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0)); ops.push(BodyOp::Configure { id: *id, rect, visible: false, floating: s.floating, fullscreen: s.fullscreen, }); } } // Reasigna el foco sólo si cambió. if new_focus != self.focused { self.focused = new_focus; for (id, s) in &mut self.surfaces { s.focused = Some(*id) == new_focus; } ops.push(match new_focus { Some(id) => BodyOp::Focus(id), None => BodyOp::Unfocus, }); } ops } BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)], BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)], BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)], BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)], BrainCommand::Spawn(cmd) => vec![BodyOp::Spawn(cmd)], BrainCommand::Shutdown => vec![BodyOp::Shutdown], } } // --- Mutadores del backend → eventos para el Cerebro ------------- /// Registra una salida recién conectada. pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent { self.outputs.push((id, Rect::new(0, 0, width, height))); BodyEvent::OutputAdded { id, width, height } } /// Da de baja una salida desconectada. pub fn remove_output(&mut self, id: OutputId) -> BodyEvent { self.outputs.retain(|(o, _)| *o != id); BodyEvent::OutputRemoved { id } } /// Cambia el área útil de una salida sin desconectarla — al /// redimensionar la ventana anfitriona o al reservar/liberar la /// franja del shell. Conserva el escritorio que muestra. pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent { if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) { rect.w = width; rect.h = height; } BodyEvent::OutputResized { id, width, height } } /// Registra una superficie recién creada por un cliente. pub fn open_surface( &mut self, id: WindowId, app_id: impl Into, title: impl Into, ) -> BodyEvent { let app_id = app_id.into(); let title = title.into(); self.surfaces .insert(id, Surface::new(app_id.clone(), title.clone())); BodyEvent::WindowOpened { id, app_id, title } } /// Da de baja una superficie destruida. `None` si no se conocía. pub fn close_surface(&mut self, id: WindowId) -> Option { self.surfaces.remove(&id)?; if self.focused == Some(id) { self.focused = None; } Some(BodyEvent::WindowClosed { id }) } /// Actualiza el título de una superficie. `None` si no se conocía. pub fn retitle_surface(&mut self, id: WindowId, title: impl Into) -> Option { let title = title.into(); let s = self.surfaces.get_mut(&id)?; s.title = title.clone(); Some(BodyEvent::WindowRetitled { id, title }) } /// Construye un evento de puntero entrando en una superficie. pub fn pointer_enter(&self, id: WindowId) -> BodyEvent { BodyEvent::PointerEntered { id } } /// Construye un evento de atajo pulsado. pub fn keybind(&self, combo: impl Into) -> BodyEvent { BodyEvent::Keybind(combo.into()) } // --- Accesores de sólo lectura ----------------------------------- /// Las salidas conectadas. pub fn outputs(&self) -> &[(OutputId, Rect)] { &self.outputs } /// Una superficie conocida. pub fn surface(&self, id: WindowId) -> Option<&Surface> { self.surfaces.get(&id) } /// Número de superficies registradas. pub fn surface_count(&self) -> usize { self.surfaces.len() } /// Las superficies visibles, en orden de id. pub fn visible(&self) -> impl Iterator { self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s)) } /// La superficie enfocada. pub fn focused(&self) -> Option { self.focused } } #[cfg(test)] mod tests { use super::*; use mirada_protocol::WindowPlacement; fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement { WindowPlacement { id, rect: Rect::new(0, 0, 800, 600), visible, focused, floating: false, fullscreen: false, } } /// Cuerpo con dos superficies abiertas. fn body_with_two() -> BodyState { let mut b = BodyState::new(); b.add_output(0, 1920, 1080); b.open_surface(1, "app1", "uno"); b.open_surface(2, "app2", "dos"); b } #[test] fn opening_a_surface_yields_a_window_opened_event() { let mut b = BodyState::new(); let ev = b.open_surface(7, "org.brahman.shuma", "shell"); assert_eq!( ev, BodyEvent::WindowOpened { id: 7, app_id: "org.brahman.shuma".into(), title: "shell".into() } ); assert_eq!(b.surface_count(), 1); } #[test] fn placing_surfaces_configures_and_focuses_them() { let mut b = body_with_two(); let ops = b.apply(BrainCommand::Place(vec![ placement(1, true, false), placement(2, true, true), ])); // Dos Configure + un Focus. let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count(); assert_eq!(configures, 2); assert!(ops.contains(&BodyOp::Focus(2))); assert_eq!(b.focused(), Some(2)); assert!(b.surface(2).unwrap().focused); } #[test] fn an_identical_place_produces_no_ops() { let mut b = body_with_two(); let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]); assert!(!b.apply(cmd.clone()).is_empty()); // Repetir el mismo Place no cambia nada. assert!(b.apply(cmd).is_empty()); } #[test] fn dropping_a_surface_from_the_list_hides_it() { let mut b = body_with_two(); b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)])); // El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio). let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)])); assert!(ops.contains(&BodyOp::Configure { id: 2, rect: Rect::new(0, 0, 800, 600), visible: false, floating: false, fullscreen: false, })); assert!(!b.surface(2).unwrap().visible); } #[test] fn placement_for_an_unknown_surface_is_ignored() { let mut b = body_with_two(); // La 99 no existe — no debe producir Configure. let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)])); assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. }))); } #[test] fn close_and_kill_map_to_client_ops() { let mut b = body_with_two(); assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]); assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]); } #[test] fn grab_keys_cursor_and_shutdown_pass_through() { let mut b = BodyState::new(); assert_eq!( b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])), vec![BodyOp::SetGrabs(vec!["Super+q".into()])] ); assert_eq!( b.apply(BrainCommand::SetCursor("crosshair".into())), vec![BodyOp::SetCursor("crosshair".into())] ); assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]); } #[test] fn closing_a_surface_clears_its_focus() { let mut b = body_with_two(); b.apply(BrainCommand::Place(vec![placement(1, true, true)])); assert_eq!(b.focused(), Some(1)); let ev = b.close_surface(1); assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 })); assert_eq!(b.focused(), None); assert_eq!(b.surface_count(), 1); } #[test] fn closing_an_unknown_surface_yields_nothing() { let mut b = body_with_two(); assert!(b.close_surface(404).is_none()); } #[test] fn retitling_updates_the_surface() { let mut b = body_with_two(); let ev = b.retitle_surface(1, "uno bis"); assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() })); assert_eq!(b.surface(1).unwrap().title, "uno bis"); } #[test] fn outputs_are_tracked() { let mut b = BodyState::new(); b.add_output(0, 2560, 1440); b.add_output(1, 1920, 1080); assert_eq!(b.outputs().len(), 2); b.remove_output(0); assert_eq!(b.outputs().len(), 1); assert_eq!(b.outputs()[0].0, 1); } #[test] fn moving_focus_emits_a_single_focus_op() { let mut b = body_with_two(); b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)])); // Cambia el foco a la 2; geometría igual → sólo un Focus. let ops = b.apply(BrainCommand::Place(vec![ placement(1, true, false), placement(2, true, true), ])); assert_eq!(ops, vec![BodyOp::Focus(2)]); } #[test] fn a_floating_change_alone_triggers_a_configure() { let mut b = body_with_two(); let mut p1 = placement(1, true, true); b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)])); // Sólo cambia `floating` — misma geometría y visibilidad. p1.floating = true; let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)])); assert!(ops .iter() .any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. }))); assert!(b.surface(1).unwrap().floating); } #[test] fn visible_iterates_only_shown_surfaces() { let mut b = body_with_two(); b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)])); let shown: Vec<_> = b.visible().map(|(id, _)| id).collect(); assert_eq!(shown, vec![1]); } }