diff --git a/Cargo.lock b/Cargo.lock index c5b854e..38d5968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7390,6 +7390,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mirada-brain" +version = "0.1.0" +dependencies = [ + "mirada-layout", + "mirada-protocol", +] + [[package]] name = "mirada-layout" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 334c019..c1f2ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,7 @@ members = [ # ============================================================ "crates/modules/mirada/mirada-layout", "crates/modules/mirada/mirada-protocol", + "crates/modules/mirada/mirada-brain", # ============================================================ # modules/nakui/ — ERP matemático (categórico) diff --git a/crates/modules/mirada/mirada-brain/Cargo.toml b/crates/modules/mirada/mirada-brain/Cargo.toml new file mode 100644 index 0000000..2c06d6a --- /dev/null +++ b/crates/modules/mirada/mirada-brain/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mirada-brain" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "mirada — orquestador de escritorio del compositor: mantiene salidas, escritorios virtuales, ventanas y foco; consume BodyEvent y produce BrainCommand. Agnóstico de GPUI y de smithay." + +[dependencies] +mirada-layout = { path = "../mirada-layout" } +mirada-protocol = { path = "../mirada-protocol" } diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs new file mode 100644 index 0000000..2b54d26 --- /dev/null +++ b/crates/modules/mirada/mirada-brain/src/action.rs @@ -0,0 +1,95 @@ +//! Acciones de escritorio y su mapa de teclas por defecto. +//! +//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya +//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las +//! aplica sin saber qué combinación las disparó. + +use mirada_layout::LayoutMode; + +/// Número de escritorios virtuales que mantiene el `Desktop`. +pub const WORKSPACE_COUNT: usize = 9; + +/// Una orden de escritorio de alto nivel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DesktopAction { + /// Mueve el foco a la ventana siguiente del escritorio activo. + FocusNext, + /// Mueve el foco a la ventana anterior. + FocusPrev, + /// Adelanta la ventana enfocada en el orden de teselado. + MoveForward, + /// Atrasa la ventana enfocada en el orden de teselado. + MoveBackward, + /// Cierra la ventana enfocada (cierre ordenado). + CloseFocused, + /// Pasa al siguiente modo de teselado. + CycleLayout, + /// Fija un modo de teselado concreto. + SetLayout(LayoutMode), + /// Activa el escritorio virtual `n` (índice 0-based). + SwitchWorkspace(usize), + /// Manda la ventana enfocada al escritorio virtual `n`. + SendToWorkspace(usize), + /// Apaga el compositor. + Quit, +} + +/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`). +/// +/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite +/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son +/// también las que se registran con +/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). +pub fn default_keymap() -> Vec<(String, DesktopAction)> { + let mut map = vec![ + ("Super+j".into(), DesktopAction::FocusNext), + ("Super+k".into(), DesktopAction::FocusPrev), + ("Super+Shift+j".into(), DesktopAction::MoveForward), + ("Super+Shift+k".into(), DesktopAction::MoveBackward), + ("Super+q".into(), DesktopAction::CloseFocused), + ("Super+space".into(), DesktopAction::CycleLayout), + ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)), + ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)), + ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)), + ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)), + ("Super+Shift+e".into(), DesktopAction::Quit), + ]; + // Un escritorio por dígito: `Super+1`..`Super+9` lo activan, + // `Super+Shift+1`.. mandan la ventana enfocada allí. + for n in 0..WORKSPACE_COUNT { + map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n))); + map.push(( + format!("Super+Shift+{}", n + 1), + DesktopAction::SendToWorkspace(n), + )); + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keymap_has_no_duplicate_bindings() { + let map = default_keymap(); + let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect(); + keys.sort(); + let unique = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), unique, "hay un atajo repetido"); + } + + #[test] + fn keymap_covers_every_virtual_workspace() { + let map = default_keymap(); + for n in 0..WORKSPACE_COUNT { + assert!(map + .iter() + .any(|(_, a)| *a == DesktopAction::SwitchWorkspace(n))); + assert!(map + .iter() + .any(|(_, a)| *a == DesktopAction::SendToWorkspace(n))); + } + } +} diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs new file mode 100644 index 0000000..526bd29 --- /dev/null +++ b/crates/modules/mirada/mirada-brain/src/desktop.rs @@ -0,0 +1,431 @@ +//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`. + +use std::collections::HashMap; + +use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace}; +use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId}; + +use crate::action::{default_keymap, DesktopAction, WORKSPACE_COUNT}; + +/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct WindowInfo { + pub app_id: String, + pub title: String, +} + +/// El estado completo del escritorio. +/// +/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios +/// virtuales, el registro de ventanas y el mapa de atajos. El único +/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`], +/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo. +/// +/// Limitación de v1: el teselado se calcula sobre la salida primaria +/// (la primera conectada). El multi-monitor real llegará después. +pub struct Desktop { + /// Salidas físicas, en fila horizontal y en orden de aparición. + outputs: Vec<(OutputId, Rect)>, + /// Escritorios virtuales — `WORKSPACE_COUNT` fijos. + workspaces: Vec, + /// Índice del escritorio activo. + active: usize, + /// Identidad de cada ventana conocida. + windows: HashMap, + /// Atajos globales → acción. + keymap: Vec<(String, DesktopAction)>, +} + +impl Default for Desktop { + fn default() -> Self { + Self::new() + } +} + +impl Desktop { + /// Escritorio recién arrancado: sin salidas ni ventanas, con los + /// escritorios virtuales vacíos y el mapa de teclas por defecto. + pub fn new() -> Self { + let workspaces = (0..WORKSPACE_COUNT) + .map(|_| Workspace::new(LayoutParams::default())) + .collect(); + Self { + outputs: Vec::new(), + workspaces, + active: 0, + windows: HashMap::new(), + keymap: default_keymap(), + } + } + + /// El comando que registra los atajos globales en el Cuerpo. La app + /// GPUI lo envía una vez, al conectar. + pub fn grab_keys(&self) -> BrainCommand { + BrainCommand::GrabKeys(self.keymap.iter().map(|(k, _)| k.clone()).collect()) + } + + /// Geometría de la salida primaria, si hay alguna conectada. + pub fn screen(&self) -> Option { + self.outputs.first().map(|(_, r)| *r) + } + + /// Procesa un evento del Cuerpo: muta el estado y devuelve los + /// comandos a enviar de vuelta. + pub fn on_event(&mut self, event: BodyEvent) -> Vec { + match event { + BodyEvent::OutputAdded { id, width, height } => { + // Las salidas se alinean en fila a la derecha de las previas. + let x: i32 = self.outputs.iter().map(|(_, r)| r.w).sum(); + self.outputs.push((id, Rect::new(x, 0, width, height))); + self.relayout() + } + BodyEvent::OutputRemoved { id } => { + self.outputs.retain(|(o, _)| *o != id); + self.relayout() + } + BodyEvent::WindowOpened { id, app_id, title } => { + self.windows.insert(id, WindowInfo { app_id, title }); + self.workspaces[self.active].add(id); + self.relayout() + } + BodyEvent::WindowClosed { id } => { + self.windows.remove(&id); + for ws in &mut self.workspaces { + ws.remove(id); + } + self.relayout() + } + BodyEvent::WindowRetitled { id, title } => { + if let Some(info) = self.windows.get_mut(&id) { + info.title = title; + } + // Un cambio de título no altera la geometría. + Vec::new() + } + BodyEvent::PointerEntered { id } => { + // Foco al pasar el puntero, sólo si la ventana está en el + // escritorio activo. + if self.workspaces[self.active].focus_window(id) { + self.relayout() + } else { + Vec::new() + } + } + BodyEvent::Keybind(key) => { + match self.keymap.iter().find(|(k, _)| *k == key).map(|(_, a)| *a) { + Some(action) => self.apply(action), + None => Vec::new(), + } + } + } + } + + /// Aplica una acción de escritorio directamente (sin pasar por una + /// tecla). Útil para disparar acciones desde un HUD. + pub fn apply(&mut self, action: DesktopAction) -> Vec { + match action { + DesktopAction::FocusNext => { + self.workspaces[self.active].focus_next(); + self.relayout() + } + DesktopAction::FocusPrev => { + self.workspaces[self.active].focus_prev(); + self.relayout() + } + DesktopAction::MoveForward => { + self.workspaces[self.active].move_focused_forward(); + self.relayout() + } + DesktopAction::MoveBackward => { + self.workspaces[self.active].move_focused_backward(); + self.relayout() + } + DesktopAction::CloseFocused => { + // Pedimos el cierre; el estado se actualiza al recibir el + // `WindowClosed` de vuelta, no antes. + match self.workspaces[self.active].focused() { + Some(id) => vec![BrainCommand::Close(id)], + None => Vec::new(), + } + } + DesktopAction::CycleLayout => { + let next = cycle_mode(self.workspaces[self.active].params().mode); + self.workspaces[self.active].set_mode(next); + self.relayout() + } + DesktopAction::SetLayout(mode) => { + self.workspaces[self.active].set_mode(mode); + self.relayout() + } + DesktopAction::SwitchWorkspace(n) => { + if n < self.workspaces.len() && n != self.active { + self.active = n; + self.relayout() + } else { + Vec::new() + } + } + DesktopAction::SendToWorkspace(n) => { + if n >= self.workspaces.len() || n == self.active { + return Vec::new(); + } + match self.workspaces[self.active].focused() { + Some(id) => { + self.workspaces[self.active].remove(id); + self.workspaces[n].add(id); + self.relayout() + } + None => Vec::new(), + } + } + DesktopAction::Quit => vec![BrainCommand::Shutdown], + } + } + + /// Recalcula la geometría del escritorio activo y la empaqueta en un + /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que + /// colocar. + fn relayout(&self) -> Vec { + match self.screen() { + Some(screen) => { + vec![BrainCommand::Place(placements( + &self.workspaces[self.active], + screen, + ))] + } + None => Vec::new(), + } + } + + // --- Accesores de sólo lectura, para el HUD de la app GPUI --------- + + /// Índice del escritorio activo. + pub fn active_index(&self) -> usize { + self.active + } + + /// El escritorio activo. + pub fn active_workspace(&self) -> &Workspace { + &self.workspaces[self.active] + } + + /// Las salidas conectadas, en orden. + pub fn outputs(&self) -> &[(OutputId, Rect)] { + &self.outputs + } + + /// Identidad de una ventana conocida. + pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> { + self.windows.get(&id) + } + + /// La ventana enfocada en el escritorio activo. + pub fn focused_window(&self) -> Option { + self.workspaces[self.active].focused() + } + + /// Cuántas ventanas hay en cada escritorio virtual. + pub fn workspace_loads(&self) -> Vec { + self.workspaces.iter().map(Workspace::len).collect() + } +} + +/// El siguiente modo en el ciclo de [`DesktopAction::CycleLayout`]. +fn cycle_mode(mode: LayoutMode) -> LayoutMode { + match mode { + LayoutMode::MasterStack => LayoutMode::Monocle, + LayoutMode::Monocle => LayoutMode::Grid, + LayoutMode::Grid => LayoutMode::Columns, + LayoutMode::Columns => LayoutMode::MasterStack, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Un escritorio con una salida 1920×1080 ya conectada. + fn desktop_with_screen() -> Desktop { + let mut d = Desktop::new(); + d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 }); + d + } + + fn open(d: &mut Desktop, id: WindowId) -> Vec { + d.on_event(BodyEvent::WindowOpened { + id, + app_id: format!("app{id}"), + title: format!("win {id}"), + }) + } + + /// Extrae las colocaciones de un único `Place`. + fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] { + match cmds { + [BrainCommand::Place(p)] => p, + other => panic!("se esperaba un solo Place, no {other:?}"), + } + } + + #[test] + fn grab_keys_lists_the_whole_keymap() { + let d = Desktop::new(); + match d.grab_keys() { + BrainCommand::GrabKeys(keys) => { + assert!(keys.contains(&"Super+j".to_string())); + assert!(keys.contains(&"Super+Shift+e".to_string())); + } + other => panic!("se esperaba GrabKeys, no {other:?}"), + } + } + + #[test] + fn without_a_screen_nothing_is_placed() { + let mut d = Desktop::new(); + assert!(open(&mut d, 1).is_empty()); + } + + #[test] + fn opening_a_window_places_it() { + let mut d = desktop_with_screen(); + let cmds = open(&mut d, 1); + assert_eq!(places(&cmds).len(), 1); + assert_eq!(d.focused_window(), Some(1)); + } + + #[test] + fn closing_a_window_removes_it_everywhere() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + open(&mut d, 2); + let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 }); + assert_eq!(places(&cmds).len(), 1); + assert!(d.window_info(1).is_none()); + assert_eq!(d.focused_window(), Some(2)); + } + + #[test] + fn focus_keybind_cycles_within_the_active_workspace() { + let mut d = desktop_with_screen(); + for id in [1, 2, 3] { + open(&mut d, id); + } + assert_eq!(d.focused_window(), Some(3)); + d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta + assert_eq!(d.focused_window(), Some(1)); + d.on_event(BodyEvent::Keybind("Super+k".into())); // prev + assert_eq!(d.focused_window(), Some(3)); + } + + #[test] + fn close_focused_keybind_asks_to_close_the_focused_window() { + let mut d = desktop_with_screen(); + open(&mut d, 7); + let cmds = d.on_event(BodyEvent::Keybind("Super+q".into())); + assert_eq!(cmds, vec![BrainCommand::Close(7)]); + // No se elimina hasta que el Cuerpo confirme con WindowClosed. + assert!(d.window_info(7).is_some()); + } + + #[test] + fn cycle_layout_walks_the_four_modes() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + assert_eq!(d.active_workspace().params().mode, LayoutMode::MasterStack); + for expected in [ + LayoutMode::Monocle, + LayoutMode::Grid, + LayoutMode::Columns, + LayoutMode::MasterStack, + ] { + d.on_event(BodyEvent::Keybind("Super+space".into())); + assert_eq!(d.active_workspace().params().mode, expected); + } + } + + #[test] + fn monocle_keybind_hides_all_but_the_focused_window() { + let mut d = desktop_with_screen(); + for id in [1, 2, 3] { + open(&mut d, id); + } + let cmds = d.on_event(BodyEvent::Keybind("Super+m".into())); + let visible = places(&cmds).iter().filter(|p| p.visible).count(); + assert_eq!(visible, 1); + } + + #[test] + fn switching_workspace_changes_what_is_placed() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + open(&mut d, 2); + // Escritorio 2 (índice 1) está vacío. + let cmds = d.on_event(BodyEvent::Keybind("Super+2".into())); + assert!(places(&cmds).is_empty()); + assert_eq!(d.active_index(), 1); + // Volver al 1 reaparece las dos ventanas. + let cmds = d.on_event(BodyEvent::Keybind("Super+1".into())); + assert_eq!(places(&cmds).len(), 2); + } + + #[test] + fn send_to_workspace_moves_the_focused_window() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + open(&mut d, 2); // enfocada + d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); + assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1 + assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3 + // La ventana 2 sigue registrada — sólo cambió de escritorio. + assert!(d.window_info(2).is_some()); + } + + #[test] + fn pointer_focuses_a_window_in_the_active_workspace() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + open(&mut d, 2); // enfocada + d.on_event(BodyEvent::PointerEntered { id: 1 }); + assert_eq!(d.focused_window(), Some(1)); + } + + #[test] + fn retitling_updates_the_registry_without_relayout() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + let cmds = d.on_event(BodyEvent::WindowRetitled { + id: 1, + title: "nuevo".into(), + }); + assert!(cmds.is_empty()); + assert_eq!(d.window_info(1).unwrap().title, "nuevo"); + } + + #[test] + fn an_unknown_keybind_does_nothing() { + let mut d = desktop_with_screen(); + open(&mut d, 1); + assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty()); + } + + #[test] + fn quit_emits_a_shutdown() { + let mut d = desktop_with_screen(); + assert_eq!( + d.on_event(BodyEvent::Keybind("Super+Shift+e".into())), + vec![BrainCommand::Shutdown] + ); + } + + #[test] + fn outputs_lay_side_by_side() { + let mut d = Desktop::new(); + d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 }); + d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 }); + assert_eq!(d.outputs().len(), 2); + // La segunda salida arranca donde acaba la primera. + assert_eq!(d.outputs()[1].1.x, 1920); + // El teselado sigue sobre la salida primaria. + assert_eq!(d.screen().unwrap().w, 1920); + } +} diff --git a/crates/modules/mirada/mirada-brain/src/lib.rs b/crates/modules/mirada/mirada-brain/src/lib.rs new file mode 100644 index 0000000..0d3696b --- /dev/null +++ b/crates/modules/mirada/mirada-brain/src/lib.rs @@ -0,0 +1,25 @@ +//! `mirada-brain` — el orquestador de escritorio del compositor. +//! +//! Es el "Cerebro" de mirada sin pantalla: mantiene el estado del +//! escritorio (salidas, escritorios virtuales, ventanas, foco), consume +//! los [`BodyEvent`]s que reporta el Cuerpo y produce los +//! [`BrainCommand`]s que el Cuerpo aplica. +//! +//! Es agnóstico de GPUI y de `smithay`: una app GPUI sólo lo *envuelve* +//! para pintar un HUD y para mover los bytes por el cable de +//! [`mirada_protocol`]. Toda la lógica vive aquí y es determinista — +//! la misma secuencia de eventos da siempre el mismo estado. +//! +//! - [`action`] — las acciones de escritorio y el mapa de teclas. +//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`. + +#![forbid(unsafe_code)] + +pub mod action; +pub mod desktop; + +pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT}; +pub use desktop::{Desktop, WindowInfo}; + +pub use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace}; +pub use mirada_protocol::{BodyEvent, BrainCommand, OutputId, WindowPlacement};