//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`. use std::collections::HashMap; use mirada_layout::{LayoutParams, Rect, WindowId, Workspace}; use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId}; use crate::action::{DesktopAction, WORKSPACE_COUNT}; use crate::keymap::Keymap; use crate::rules::Rules; /// 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, } /// Una salida física y el escritorio virtual que muestra ahora mismo. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Output { pub id: OutputId, /// Rectángulo en el espacio global — las salidas van en fila horizontal. pub rect: Rect, /// Índice del escritorio que esta salida muestra. pub workspace: usize, } /// El estado completo del escritorio. /// /// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios /// virtuales, el registro de ventanas, el keymap y las reglas. El único /// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`], /// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo. /// /// **Multi-monitor**: cada salida muestra un escritorio distinto; el /// teselado se calcula para todas y el `Place` resultante las cubre. Un /// escritorio se ve en una salida como mucho — pedir uno que ya muestra /// otra salida las intercambia. pub struct Desktop { /// Salidas físicas, en fila horizontal y en orden de aparición. outputs: Vec, /// Escritorios virtuales — `WORKSPACE_COUNT` fijos. workspaces: Vec, /// Índice (en `outputs`) de la salida con el foco. focused_output: usize, /// Identidad de cada ventana conocida. windows: HashMap, /// Atajos globales → acción. Configurable, recargable en caliente. keymap: Keymap, /// Reglas de ventana — escritorio/flotante por `app_id`/título. rules: Rules, /// Ventanas del scratchpad: se invocan flotando y se ocultan a /// voluntad; mientras están guardadas no viven en ningún escritorio. scratchpad: Vec, } 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 { Self::with_keymap(Keymap::default()) } /// Como [`Desktop::new`], pero con un keymap dado — el que la app /// cargó del archivo de configuración del usuario. pub fn with_keymap(keymap: Keymap) -> Self { let workspaces = (0..WORKSPACE_COUNT) .map(|_| Workspace::new(LayoutParams::default())) .collect(); Self { outputs: Vec::new(), workspaces, focused_output: 0, windows: HashMap::new(), keymap, rules: Rules::default(), scratchpad: Vec::new(), } } /// Reemplaza las reglas de ventana. Se aplican a las ventanas que se /// abran a partir de ahora; las ya abiertas no se tocan. pub fn set_rules(&mut self, rules: Rules) { self.rules = rules; } /// El comando que registra los atajos globales en el Cuerpo. La app /// lo envía al conectar, y de nuevo tras cada recarga del keymap. pub fn grab_keys(&self) -> BrainCommand { BrainCommand::GrabKeys(self.keymap.grab_list()) } /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta. pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand { self.keymap = keymap; self.grab_keys() } /// El keymap vigente — para un HUD o un editor visual de atajos. pub fn keymap(&self) -> &Keymap { &self.keymap } /// Geometría de la salida enfocada, si hay alguna conectada. pub fn screen(&self) -> Option { self.outputs.get(self.focused_output).map(|o| o.rect) } /// 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 } => { // La salida nueva muestra el primer escritorio que no // muestre ya otra salida. let taken: Vec = self.outputs.iter().map(|o| o.workspace).collect(); let workspace = (0..self.workspaces.len()) .find(|n| !taken.contains(n)) .unwrap_or(0); self.outputs.push(Output { id, rect: Rect::new(0, 0, width, height), workspace, }); self.reflow_outputs(); self.relayout() } BodyEvent::OutputRemoved { id } => { self.outputs.retain(|o| o.id != id); if self.focused_output >= self.outputs.len() { self.focused_output = self.outputs.len().saturating_sub(1); } self.reflow_outputs(); self.relayout() } BodyEvent::OutputResized { id, width, height } => { // Sólo cambia el área útil; el escritorio que muestra la // salida se conserva. if let Some(o) = self.outputs.iter_mut().find(|o| o.id == id) { o.rect.w = width; o.rect.h = height; self.reflow_outputs(); self.relayout() } else { Vec::new() } } BodyEvent::WindowOpened { id, app_id, title } => { // Las reglas pueden mandarla a otro escritorio o hacerla flotar. let outcome = self.rules.resolve(&app_id, &title); self.windows.insert(id, WindowInfo { app_id, title }); let ws = outcome .workspace .filter(|&n| n < self.workspaces.len()) .unwrap_or(self.active_index()); self.workspaces[ws].add(id); if outcome.floating { let rect = self .screen() .map(centered_float_rect) .unwrap_or_else(|| Rect::new(100, 100, 800, 600)); self.workspaces[ws].set_floating(id, Some(rect)); } self.relayout() } BodyEvent::WindowClosed { id } => { self.windows.remove(&id); self.scratchpad.retain(|&w| w != 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. let active = self.active_index(); if self.workspaces[active].focus_window(id) { self.relayout() } else { Vec::new() } } BodyEvent::Keybind(key) => match self.keymap.lookup(&key) { Some(action) => self.apply(action), None => Vec::new(), }, BodyEvent::FullscreenRequest { id, fullscreen } => { // El cliente (un reproductor, un juego) pidió pantalla // completa: la fijamos en el escritorio que tiene la ventana. let mut changed = false; for ws in &mut self.workspaces { if ws.windows().contains(&id) { if fullscreen { ws.set_fullscreen(Some(id)); } else if ws.fullscreen() == Some(id) { ws.set_fullscreen(None); } changed = true; break; } } if changed { self.relayout() } else { Vec::new() } } BodyEvent::WindowFloatTo { id, rect } => { // Arrastre interactivo: la ventana pasa a flotar en el // rectángulo dado, en el escritorio donde viva. let mut changed = false; for ws in &mut self.workspaces { if ws.windows().contains(&id) { ws.set_floating(id, Some(rect)); changed = true; break; } } if changed { self.relayout() } else { 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 { let active = self.active_index(); match action { DesktopAction::FocusNext => { self.workspaces[active].focus_next(); self.relayout() } DesktopAction::FocusPrev => { self.workspaces[active].focus_prev(); self.relayout() } DesktopAction::FocusWindow(id) => { // En el escritorio activo basta enfocar; si la ventana // está en otro, lo traemos a la salida enfocada. if self.workspaces[active].focus_window(id) { return self.relayout(); } for n in 0..self.workspaces.len() { if n != active && self.workspaces[n].focus_window(id) { self.show_workspace(n); return self.relayout(); } } Vec::new() } DesktopAction::MoveForward => { self.workspaces[active].move_focused_forward(); self.relayout() } DesktopAction::MoveBackward => { self.workspaces[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[active].focused() { Some(id) => vec![BrainCommand::Close(id)], None => Vec::new(), } } DesktopAction::ToggleFloat => { let Some(id) = self.workspaces[active].focused() else { return Vec::new(); }; let screen = self.screen(); let ws = &mut self.workspaces[active]; if ws.is_floating(id) { ws.set_floating(id, None); } else { let rect = screen .map(centered_float_rect) .unwrap_or_else(|| Rect::new(100, 100, 800, 600)); ws.set_floating(id, Some(rect)); } self.relayout() } DesktopAction::ToggleFullscreen => { let Some(id) = self.workspaces[active].focused() else { return Vec::new(); }; let ws = &mut self.workspaces[active]; if ws.fullscreen() == Some(id) { ws.set_fullscreen(None); } else { ws.set_fullscreen(Some(id)); } self.relayout() } DesktopAction::SendToScratchpad => { let Some(id) = self.workspaces[active].focused() else { return Vec::new(); }; for ws in &mut self.workspaces { ws.remove(id); } if !self.scratchpad.contains(&id) { self.scratchpad.push(id); } self.relayout() } DesktopAction::ToggleScratchpad => { // ¿Hay alguna ventana del scratchpad en el escritorio activo? let shown: Vec = self.workspaces[active] .windows() .iter() .copied() .filter(|id| self.scratchpad.contains(id)) .collect(); if !shown.is_empty() { for id in shown { self.workspaces[active].remove(id); } self.relayout() } else if let Some(&id) = self.scratchpad.first() { // La traemos de donde esté y la mostramos flotando. for ws in &mut self.workspaces { ws.remove(id); } let rect = self .screen() .map(centered_float_rect) .unwrap_or_else(|| Rect::new(100, 100, 800, 600)); self.workspaces[active].add(id); self.workspaces[active].set_floating(id, Some(rect)); self.relayout() } else { Vec::new() } } DesktopAction::CycleLayout => { let next = self.workspaces[active].params().mode.next(); self.workspaces[active].set_mode(next); self.relayout() } DesktopAction::SetLayout(mode) => { self.workspaces[active].set_mode(mode); self.relayout() } DesktopAction::GrowMaster => self.nudge_master(0.05), DesktopAction::ShrinkMaster => self.nudge_master(-0.05), DesktopAction::IncMaster => self.nudge_master_count(1), DesktopAction::DecMaster => self.nudge_master_count(-1), DesktopAction::PromoteToMaster => { self.workspaces[active].promote_focused(); self.relayout() } DesktopAction::SwitchWorkspace(n) => { if n < self.workspaces.len() && n != active { self.show_workspace(n); self.relayout() } else { Vec::new() } } DesktopAction::SendToWorkspace(n) => { if n >= self.workspaces.len() || n == active { return Vec::new(); } match self.workspaces[active].focused() { Some(id) => { self.workspaces[active].remove(id); self.workspaces[n].add(id); self.relayout() } None => Vec::new(), } } DesktopAction::FocusOutputNext => { if self.outputs.len() > 1 { self.focused_output = (self.focused_output + 1) % self.outputs.len(); self.relayout() } else { Vec::new() } } DesktopAction::Spawn(cmd) => vec![BrainCommand::Spawn(cmd)], DesktopAction::Quit => vec![BrainCommand::Shutdown], } } /// El índice del escritorio activo — el que muestra la salida /// enfocada. `0` si todavía no hay ninguna salida. pub fn active_index(&self) -> usize { self.outputs .get(self.focused_output) .map(|o| o.workspace) .unwrap_or(0) } /// Hace que la salida enfocada muestre el escritorio `n`. Si otra /// salida ya lo mostraba, intercambian — así ningún escritorio se /// ve en dos sitios a la vez. fn show_workspace(&mut self, n: usize) { if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() { return; } let current = self.outputs[self.focused_output].workspace; if current == n { return; } if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) { self.outputs[other].workspace = current; } self.outputs[self.focused_output].workspace = n; } /// Recoloca las salidas en fila horizontal, en su orden de aparición. fn reflow_outputs(&mut self) { let mut x = 0; for o in &mut self.outputs { o.rect.x = x; o.rect.y = 0; x += o.rect.w; } } /// Ajusta la fracción del área maestra del escritorio activo (la usan /// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`. fn nudge_master(&mut self, delta: f32) -> Vec { let active = self.active_index(); let ws = &mut self.workspaces[active]; let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95); ws.set_master_ratio(ratio); self.relayout() } /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`. fn nudge_master_count(&mut self, delta: i32) -> Vec { let active = self.active_index(); let ws = &mut self.workspaces[active]; let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize; ws.set_master_count(n); self.relayout() } /// Recalcula la geometría de **todas** las salidas y la empaqueta en /// un único [`BrainCommand::Place`]. Sin salidas, no hay nada que /// colocar. fn relayout(&self) -> Vec { if self.outputs.is_empty() { return Vec::new(); } let mut all = Vec::new(); for o in &self.outputs { all.extend(placements(&self.workspaces[o.workspace], o.rect)); } // El foco del teclado es único: sólo la ventana enfocada de la // salida enfocada. `placements` marca el foco por escritorio (lo // necesita para la visibilidad en `Monocle`); aquí lo unificamos. let global_focus = self.focused_window(); for p in &mut all { p.focused = Some(p.id) == global_focus; } vec![BrainCommand::Place(all)] } // --- Accesores de sólo lectura, para el HUD de la app GPUI --------- /// El escritorio activo — el de la salida enfocada. pub fn active_workspace(&self) -> &Workspace { &self.workspaces[self.active_index()] } /// Las salidas conectadas, en orden, con el escritorio que muestran. pub fn outputs(&self) -> &[Output] { &self.outputs } /// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada. pub fn focused_output(&self) -> usize { self.focused_output } /// Identidad de una ventana conocida. pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> { self.windows.get(&id) } /// La ventana con el foco del teclado: la enfocada del escritorio /// activo — o su ventana en pantalla completa, si la hay. pub fn focused_window(&self) -> Option { let ws = &self.workspaces[self.active_index()]; ws.fullscreen().or_else(|| ws.focused()) } /// Cuántas ventanas hay en cada escritorio virtual. pub fn workspace_loads(&self) -> Vec { self.workspaces.iter().map(Workspace::len).collect() } /// Una vista de todas las ventanas conocidas, en todos los /// escritorios — la base de `mirada-ctl windows` y de una taskbar. pub fn window_lines(&self) -> Vec { let active = self.active_index(); let mut lines = Vec::new(); for (n, ws) in self.workspaces.iter().enumerate() { let ws_focus = ws.focused(); for &id in ws.windows() { let info = self.windows.get(&id); lines.push(crate::ctl::WindowLine { id, app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(), title: info.map(|i| i.title.clone()).unwrap_or_default(), workspace: n + 1, focused: n == active && ws_focus == Some(id), }); } } // Ventanas guardadas en el scratchpad — en ningún escritorio. for &id in &self.scratchpad { let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id)); if stashed { let info = self.windows.get(&id); lines.push(crate::ctl::WindowLine { id, app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(), title: info.map(|i| i.title.clone()).unwrap_or_default(), workspace: 0, // 0 = guardada en el scratchpad focused: false, }); } } lines } } /// El rectángulo flotante por defecto: 60 % de la pantalla, centrado. fn centered_float_rect(screen: Rect) -> Rect { let w = screen.w * 3 / 5; let h = screen.h * 3 / 5; Rect::new( screen.x + (screen.w - w) / 2, screen.y + (screen.h - h) / 2, w, h, ) } #[cfg(test)] mod tests { use super::*; use mirada_layout::LayoutMode; /// 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 set_keymap_swaps_the_bindings_and_regrabs() { let mut d = desktop_with_screen(); for id in [1, 2, 3] { open(&mut d, id); } // El keymap por defecto no usa Alt. assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty()); // Cargamos un keymap a medida; el comando devuelto re-registra grabs. let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap(); match d.set_keymap(custom) { BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]), other => panic!("se esperaba GrabKeys, no {other:?}"), } // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no. assert_eq!(d.focused_window(), Some(3)); d.on_event(BodyEvent::Keybind("Alt+x".into())); assert_eq!(d.focused_window(), Some(2)); assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty()); } #[test] fn focus_window_addresses_a_specific_window() { let mut d = desktop_with_screen(); for id in [1, 2, 3] { open(&mut d, id); } assert_eq!(d.focused_window(), Some(3)); d.apply(DesktopAction::FocusWindow(1)); assert_eq!(d.focused_window(), Some(1)); } #[test] fn focus_window_jumps_to_the_workspace_that_holds_it() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); // enfocada // Manda la 2 al escritorio 3; seguimos en el 1. d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); assert_eq!(d.active_index(), 0); // Enfocar la 2 nos lleva a su escritorio. d.apply(DesktopAction::FocusWindow(2)); assert_eq!(d.active_index(), 2); assert_eq!(d.focused_window(), Some(2)); } #[test] fn window_lines_cover_every_window_with_its_workspace() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3 let lines = d.window_lines(); assert_eq!(lines.len(), 2); let w1 = lines.iter().find(|l| l.id == 1).unwrap(); let w2 = lines.iter().find(|l| l.id == 2).unwrap(); assert_eq!(w1.workspace, 1); assert_eq!(w2.workspace, 3); // La 1 quedó enfocada en el escritorio activo (el 1). assert!(w1.focused); assert!(!w2.focused); } #[test] fn toggle_float_marks_the_focused_window_and_floats_it_last() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); // enfocada let cmds = d.apply(DesktopAction::ToggleFloat); let p = places(&cmds); assert!(p.iter().find(|x| x.id == 2).unwrap().floating); // La flotante va al final de la lista — orden de pintado. assert_eq!(p.last().unwrap().id, 2); // Alternar de nuevo la devuelve al teselado. let cmds = d.apply(DesktopAction::ToggleFloat); assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating); } #[test] fn toggle_fullscreen_covers_the_screen_and_hides_the_rest() { let mut d = desktop_with_screen(); for id in [1, 2, 3] { open(&mut d, id); } let cmds = d.apply(DesktopAction::ToggleFullscreen); // sobre la 3 let p = places(&cmds); let fs = p.iter().find(|x| x.id == 3).unwrap(); assert!(fs.fullscreen && fs.visible); assert_eq!(fs.rect, d.screen().unwrap()); assert!(p.iter().filter(|x| x.id != 3).all(|x| !x.visible)); // Alternar de nuevo restaura el teselado: las tres visibles. let cmds = d.apply(DesktopAction::ToggleFullscreen); assert_eq!(places(&cmds).iter().filter(|x| x.visible).count(), 3); } #[test] fn a_rule_sends_a_new_window_to_its_workspace() { let mut d = desktop_with_screen(); d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app2", workspace: 3) ] )"#).unwrap()); open(&mut d, 1); // app1 → sin regla → escritorio activo (1) open(&mut d, 2); // app2 → regla → escritorio 3 assert_eq!(d.workspace_loads()[0], 1); assert_eq!(d.workspace_loads()[2], 1); } #[test] fn a_rule_can_open_a_window_floating() { let mut d = desktop_with_screen(); d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app1", floating: true) ] )"#).unwrap()); let cmds = open(&mut d, 1); assert!(places(&cmds).iter().find(|p| p.id == 1).unwrap().floating); } #[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_every_mode_and_returns() { let mut d = desktop_with_screen(); open(&mut d, 1); let start = d.active_workspace().params().mode; for _ in 0..LayoutMode::ALL.len() { let before = d.active_workspace().params().mode; d.on_event(BodyEvent::Keybind("Super+space".into())); assert_eq!(d.active_workspace().params().mode, before.next()); } // Una vuelta completa devuelve al modo inicial. assert_eq!(d.active_workspace().params().mode, start); } #[test] fn grow_and_shrink_master_adjust_the_ratio() { let mut d = desktop_with_screen(); open(&mut d, 1); let r0 = d.active_workspace().params().master_ratio; d.apply(DesktopAction::GrowMaster); assert!(d.active_workspace().params().master_ratio > r0); d.apply(DesktopAction::ShrinkMaster); assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6); } #[test] fn inc_and_dec_master_adjust_nmaster() { let mut d = desktop_with_screen(); for id in [1, 2, 3] { open(&mut d, id); } assert_eq!(d.active_workspace().params().master_count, 1); d.apply(DesktopAction::IncMaster); assert_eq!(d.active_workspace().params().master_count, 2); d.apply(DesktopAction::DecMaster); d.apply(DesktopAction::DecMaster); // no baja de 1 assert_eq!(d.active_workspace().params().master_count, 1); } #[test] fn promote_to_master_brings_the_focused_window_to_the_front() { let mut d = desktop_with_screen(); for id in [1, 2, 3] { open(&mut d, id); } d.apply(DesktopAction::FocusWindow(3)); d.apply(DesktopAction::PromoteToMaster); assert_eq!(d.active_workspace().windows()[0], 3); assert_eq!(d.focused_window(), Some(3)); } #[test] fn master_ratio_stays_within_bounds() { let mut d = desktop_with_screen(); open(&mut d, 1); for _ in 0..50 { d.apply(DesktopAction::GrowMaster); } assert!(d.active_workspace().params().master_ratio <= 0.95); for _ in 0..50 { d.apply(DesktopAction::ShrinkMaster); } assert!(d.active_workspace().params().master_ratio >= 0.05); } #[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 dragging_floats_a_window_at_the_given_rect() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); assert!(!d.active_workspace().is_floating(2)); let target = Rect::new(300, 200, 640, 480); let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 2, rect: target }); // La 2 ahora flota exactamente en el rectángulo pedido. assert!(d.active_workspace().is_floating(2)); let p = places(&cmds).iter().find(|p| p.id == 2).unwrap(); assert!(p.floating); assert_eq!(p.rect, target); } #[test] fn resizing_an_output_retiles_without_losing_the_workspace() { let mut d = desktop_with_screen(); open(&mut d, 1); d.on_event(BodyEvent::Keybind("Super+2".into())); // escritorio activo → 2 assert_eq!(d.active_index(), 1); let cmds = d.on_event(BodyEvent::OutputResized { id: 0, width: 1920, height: 1040, }); // A diferencia de quitar y volver a añadir la salida, el // escritorio activo se conserva. assert_eq!(d.active_index(), 1); assert!(matches!(cmds.as_slice(), [BrainCommand::Place(_)])); } #[test] fn a_spawn_keybind_becomes_a_spawn_command() { let mut d = desktop_with_screen(); let cmds = d.on_event(BodyEvent::Keybind("Super+Shift+Return".into())); assert_eq!(cmds, vec![BrainCommand::Spawn("foot".into())]); } #[test] fn dragging_an_unknown_window_does_nothing() { let mut d = desktop_with_screen(); open(&mut d, 1); let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 99, rect: Rect::new(0, 0, 100, 100), }); assert!(cmds.is_empty()); } #[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] ); } // --- Multi-monitor ------------------------------------------------- /// Un escritorio con dos salidas 1920×1080. fn desktop_with_two_outputs() -> Desktop { let mut d = Desktop::new(); d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 }); d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 }); d } #[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].rect.x, 1920); } #[test] fn each_output_shows_a_distinct_workspace() { let d = desktop_with_two_outputs(); assert_eq!(d.outputs()[0].workspace, 0); assert_eq!(d.outputs()[1].workspace, 1); } #[test] fn switching_to_a_workspace_shown_on_another_output_swaps_them() { let mut d = desktop_with_two_outputs(); // La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap. d.apply(DesktopAction::SwitchWorkspace(1)); assert_eq!(d.outputs()[0].workspace, 1); assert_eq!(d.outputs()[1].workspace, 0); } #[test] fn focus_output_next_moves_the_focus_between_outputs() { let mut d = desktop_with_two_outputs(); assert_eq!(d.active_index(), 0); // salida 0 → ws 0 d.apply(DesktopAction::FocusOutputNext); assert_eq!(d.active_index(), 1); // salida 1 → ws 1 d.apply(DesktopAction::FocusOutputNext); // envuelve assert_eq!(d.active_index(), 0); } #[test] fn relayout_places_windows_on_every_output() { let mut d = Desktop::new(); d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 }); d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 }); open(&mut d, 1); // en la salida 0 (ws 0) d.apply(DesktopAction::FocusOutputNext); let cmds = open(&mut d, 2); // en la salida 1 (ws 1) let p = places(&cmds); assert_eq!(p.len(), 2); // Cada ventana cae en el rectángulo de su salida. assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0); assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920); } #[test] fn keyboard_focus_is_unique_across_outputs() { let mut d = desktop_with_two_outputs(); open(&mut d, 1); d.apply(DesktopAction::FocusOutputNext); let cmds = open(&mut d, 2); // Sólo una ventana con foco de teclado en todo el Place. assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1); } #[test] fn removing_an_output_keeps_its_windows_in_their_workspace() { let mut d = desktop_with_two_outputs(); d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1) open(&mut d, 1); // en ws 1 d.on_event(BodyEvent::OutputRemoved { id: 1 }); // La ventana sigue registrada, en el ws 1. assert!(d.window_info(1).is_some()); assert_eq!(d.workspace_loads()[1], 1); assert_eq!(d.outputs().len(), 1); } // --- Scratchpad ---------------------------------------------------- #[test] fn send_to_scratchpad_hides_the_focused_window() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); // enfocada d.apply(DesktopAction::SendToScratchpad); assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1 assert!(d.window_info(2).is_some()); // sigue registrada } #[test] fn toggle_scratchpad_shows_then_hides_the_stashed_window() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); d.apply(DesktopAction::SendToScratchpad); // guarda la 2 assert_eq!(d.workspace_loads()[0], 1); // Toggle la invoca, flotando. let cmds = d.apply(DesktopAction::ToggleScratchpad); assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating); assert_eq!(d.workspace_loads()[0], 2); // Toggle de nuevo la oculta. d.apply(DesktopAction::ToggleScratchpad); assert_eq!(d.workspace_loads()[0], 1); } #[test] fn a_scratchpad_window_follows_you_across_workspaces() { let mut d = desktop_with_screen(); open(&mut d, 1); d.apply(DesktopAction::SendToScratchpad); d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1 assert_eq!(d.workspace_loads()[0], 1); d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2 d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2 assert_eq!(d.workspace_loads()[1], 1); assert_eq!(d.workspace_loads()[0], 0); } #[test] fn closing_a_stashed_window_drops_it_from_the_scratchpad() { let mut d = desktop_with_screen(); open(&mut d, 1); d.apply(DesktopAction::SendToScratchpad); d.on_event(BodyEvent::WindowClosed { id: 1 }); // Ya no hay nada que invocar. assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty()); } #[test] fn a_client_fullscreen_request_is_honoured() { let mut d = desktop_with_screen(); open(&mut d, 1); open(&mut d, 2); let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: true }); assert!(places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen); // El cliente la suelta. let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: false }); assert!(!places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen); } #[test] fn a_fullscreen_request_for_an_unknown_window_does_nothing() { let mut d = desktop_with_screen(); open(&mut d, 1); assert!(d .on_event(BodyEvent::FullscreenRequest { id: 99, fullscreen: true }) .is_empty()); } #[test] fn window_lines_show_a_stashed_window_as_workspace_zero() { let mut d = desktop_with_screen(); open(&mut d, 1); d.apply(DesktopAction::SendToScratchpad); let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap(); assert_eq!(line.workspace, 0); } }