⎇ 90bffec3f169c244946d37d6a2a6d02805e52973
crates/modules/mirada/mirada-brain/src/desktop.rs
History
Who touched what
raw
feat(mirada): mover/redimensionar ventanas con el ratón
1//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
2
3use std::collections::HashMap;
4
5use mirada_layout::{LayoutParams, Rect, WindowId, Workspace};
6use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
7
8use crate::action::{DesktopAction, WORKSPACE_COUNT};
9use crate::keymap::Keymap;
10use crate::rules::Rules;
11
12/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct WindowInfo {
15 pub app_id: String,
16 pub title: String,
17}
18
19/// Una salida física y el escritorio virtual que muestra ahora mismo.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Output {
22 pub id: OutputId,
23 /// Rectángulo en el espacio global — las salidas van en fila horizontal.
24 pub rect: Rect,
25 /// Índice del escritorio que esta salida muestra.
26 pub workspace: usize,
27}
28
29/// El estado completo del escritorio.
30///
31/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
32/// virtuales, el registro de ventanas, el keymap y las reglas. El único
33/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
34/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
35///
36/// **Multi-monitor**: cada salida muestra un escritorio distinto; el
37/// teselado se calcula para todas y el `Place` resultante las cubre. Un
38/// escritorio se ve en una salida como mucho — pedir uno que ya muestra
39/// otra salida las intercambia.
40pub struct Desktop {
41 /// Salidas físicas, en fila horizontal y en orden de aparición.
42 outputs: Vec<Output>,
43 /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
44 workspaces: Vec<Workspace>,
45 /// Índice (en `outputs`) de la salida con el foco.
46 focused_output: usize,
47 /// Identidad de cada ventana conocida.
48 windows: HashMap<WindowId, WindowInfo>,
49 /// Atajos globales → acción. Configurable, recargable en caliente.
50 keymap: Keymap,
51 /// Reglas de ventana — escritorio/flotante por `app_id`/título.
52 rules: Rules,
53 /// Ventanas del scratchpad: se invocan flotando y se ocultan a
54 /// voluntad; mientras están guardadas no viven en ningún escritorio.
55 scratchpad: Vec<WindowId>,
56}
57
58impl Default for Desktop {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl Desktop {
65 /// Escritorio recién arrancado: sin salidas ni ventanas, con los
66 /// escritorios virtuales vacíos y el mapa de teclas por defecto.
67 pub fn new() -> Self {
68 Self::with_keymap(Keymap::default())
69 }
70
71 /// Como [`Desktop::new`], pero con un keymap dado — el que la app
72 /// cargó del archivo de configuración del usuario.
73 pub fn with_keymap(keymap: Keymap) -> Self {
74 let workspaces = (0..WORKSPACE_COUNT)
75 .map(|_| Workspace::new(LayoutParams::default()))
76 .collect();
77 Self {
78 outputs: Vec::new(),
79 workspaces,
80 focused_output: 0,
81 windows: HashMap::new(),
82 keymap,
83 rules: Rules::default(),
84 scratchpad: Vec::new(),
85 }
86 }
87
88 /// Reemplaza las reglas de ventana. Se aplican a las ventanas que se
89 /// abran a partir de ahora; las ya abiertas no se tocan.
90 pub fn set_rules(&mut self, rules: Rules) {
91 self.rules = rules;
92 }
93
94 /// El comando que registra los atajos globales en el Cuerpo. La app
95 /// lo envía al conectar, y de nuevo tras cada recarga del keymap.
96 pub fn grab_keys(&self) -> BrainCommand {
97 BrainCommand::GrabKeys(self.keymap.grab_list())
98 }
99
100 /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que
101 /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta.
102 pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand {
103 self.keymap = keymap;
104 self.grab_keys()
105 }
106
107 /// El keymap vigente — para un HUD o un editor visual de atajos.
108 pub fn keymap(&self) -> &Keymap {
109 &self.keymap
110 }
111
112 /// Geometría de la salida enfocada, si hay alguna conectada.
113 pub fn screen(&self) -> Option<Rect> {
114 self.outputs.get(self.focused_output).map(|o| o.rect)
115 }
116
117 /// Procesa un evento del Cuerpo: muta el estado y devuelve los
118 /// comandos a enviar de vuelta.
119 pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
120 match event {
121 BodyEvent::OutputAdded { id, width, height } => {
122 // La salida nueva muestra el primer escritorio que no
123 // muestre ya otra salida.
124 let taken: Vec<usize> = self.outputs.iter().map(|o| o.workspace).collect();
125 let workspace = (0..self.workspaces.len())
126 .find(|n| !taken.contains(n))
127 .unwrap_or(0);
128 self.outputs.push(Output {
129 id,
130 rect: Rect::new(0, 0, width, height),
131 workspace,
132 });
133 self.reflow_outputs();
134 self.relayout()
135 }
136 BodyEvent::OutputRemoved { id } => {
137 self.outputs.retain(|o| o.id != id);
138 if self.focused_output >= self.outputs.len() {
139 self.focused_output = self.outputs.len().saturating_sub(1);
140 }
141 self.reflow_outputs();
142 self.relayout()
143 }
144 BodyEvent::WindowOpened { id, app_id, title } => {
145 // Las reglas pueden mandarla a otro escritorio o hacerla flotar.
146 let outcome = self.rules.resolve(&app_id, &title);
147 self.windows.insert(id, WindowInfo { app_id, title });
148 let ws = outcome
149 .workspace
150 .filter(|&n| n < self.workspaces.len())
151 .unwrap_or(self.active_index());
152 self.workspaces[ws].add(id);
153 if outcome.floating {
154 let rect = self
155 .screen()
156 .map(centered_float_rect)
157 .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
158 self.workspaces[ws].set_floating(id, Some(rect));
159 }
160 self.relayout()
161 }
162 BodyEvent::WindowClosed { id } => {
163 self.windows.remove(&id);
164 self.scratchpad.retain(|&w| w != id);
165 for ws in &mut self.workspaces {
166 ws.remove(id);
167 }
168 self.relayout()
169 }
170 BodyEvent::WindowRetitled { id, title } => {
171 if let Some(info) = self.windows.get_mut(&id) {
172 info.title = title;
173 }
174 // Un cambio de título no altera la geometría.
175 Vec::new()
176 }
177 BodyEvent::PointerEntered { id } => {
178 // Foco al pasar el puntero, sólo si la ventana está en el
179 // escritorio activo.
180 let active = self.active_index();
181 if self.workspaces[active].focus_window(id) {
182 self.relayout()
183 } else {
184 Vec::new()
185 }
186 }
187 BodyEvent::Keybind(key) => match self.keymap.lookup(&key) {
188 Some(action) => self.apply(action),
189 None => Vec::new(),
190 },
191 BodyEvent::FullscreenRequest { id, fullscreen } => {
192 // El cliente (un reproductor, un juego) pidió pantalla
193 // completa: la fijamos en el escritorio que tiene la ventana.
194 let mut changed = false;
195 for ws in &mut self.workspaces {
196 if ws.windows().contains(&id) {
197 if fullscreen {
198 ws.set_fullscreen(Some(id));
199 } else if ws.fullscreen() == Some(id) {
200 ws.set_fullscreen(None);
201 }
202 changed = true;
203 break;
204 }
205 }
206 if changed {
207 self.relayout()
208 } else {
209 Vec::new()
210 }
211 }
212 BodyEvent::WindowFloatTo { id, rect } => {
213 // Arrastre interactivo: la ventana pasa a flotar en el
214 // rectángulo dado, en el escritorio donde viva.
215 let mut changed = false;
216 for ws in &mut self.workspaces {
217 if ws.windows().contains(&id) {
218 ws.set_floating(id, Some(rect));
219 changed = true;
220 break;
221 }
222 }
223 if changed {
224 self.relayout()
225 } else {
226 Vec::new()
227 }
228 }
229 }
230 }
231
232 /// Aplica una acción de escritorio directamente (sin pasar por una
233 /// tecla). Útil para disparar acciones desde un HUD.
234 pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
235 let active = self.active_index();
236 match action {
237 DesktopAction::FocusNext => {
238 self.workspaces[active].focus_next();
239 self.relayout()
240 }
241 DesktopAction::FocusPrev => {
242 self.workspaces[active].focus_prev();
243 self.relayout()
244 }
245 DesktopAction::FocusWindow(id) => {
246 // En el escritorio activo basta enfocar; si la ventana
247 // está en otro, lo traemos a la salida enfocada.
248 if self.workspaces[active].focus_window(id) {
249 return self.relayout();
250 }
251 for n in 0..self.workspaces.len() {
252 if n != active && self.workspaces[n].focus_window(id) {
253 self.show_workspace(n);
254 return self.relayout();
255 }
256 }
257 Vec::new()
258 }
259 DesktopAction::MoveForward => {
260 self.workspaces[active].move_focused_forward();
261 self.relayout()
262 }
263 DesktopAction::MoveBackward => {
264 self.workspaces[active].move_focused_backward();
265 self.relayout()
266 }
267 DesktopAction::CloseFocused => {
268 // Pedimos el cierre; el estado se actualiza al recibir el
269 // `WindowClosed` de vuelta, no antes.
270 match self.workspaces[active].focused() {
271 Some(id) => vec![BrainCommand::Close(id)],
272 None => Vec::new(),
273 }
274 }
275 DesktopAction::ToggleFloat => {
276 let Some(id) = self.workspaces[active].focused() else {
277 return Vec::new();
278 };
279 let screen = self.screen();
280 let ws = &mut self.workspaces[active];
281 if ws.is_floating(id) {
282 ws.set_floating(id, None);
283 } else {
284 let rect = screen
285 .map(centered_float_rect)
286 .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
287 ws.set_floating(id, Some(rect));
288 }
289 self.relayout()
290 }
291 DesktopAction::ToggleFullscreen => {
292 let Some(id) = self.workspaces[active].focused() else {
293 return Vec::new();
294 };
295 let ws = &mut self.workspaces[active];
296 if ws.fullscreen() == Some(id) {
297 ws.set_fullscreen(None);
298 } else {
299 ws.set_fullscreen(Some(id));
300 }
301 self.relayout()
302 }
303 DesktopAction::SendToScratchpad => {
304 let Some(id) = self.workspaces[active].focused() else {
305 return Vec::new();
306 };
307 for ws in &mut self.workspaces {
308 ws.remove(id);
309 }
310 if !self.scratchpad.contains(&id) {
311 self.scratchpad.push(id);
312 }
313 self.relayout()
314 }
315 DesktopAction::ToggleScratchpad => {
316 // ¿Hay alguna ventana del scratchpad en el escritorio activo?
317 let shown: Vec<WindowId> = self.workspaces[active]
318 .windows()
319 .iter()
320 .copied()
321 .filter(|id| self.scratchpad.contains(id))
322 .collect();
323 if !shown.is_empty() {
324 for id in shown {
325 self.workspaces[active].remove(id);
326 }
327 self.relayout()
328 } else if let Some(&id) = self.scratchpad.first() {
329 // La traemos de donde esté y la mostramos flotando.
330 for ws in &mut self.workspaces {
331 ws.remove(id);
332 }
333 let rect = self
334 .screen()
335 .map(centered_float_rect)
336 .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
337 self.workspaces[active].add(id);
338 self.workspaces[active].set_floating(id, Some(rect));
339 self.relayout()
340 } else {
341 Vec::new()
342 }
343 }
344 DesktopAction::CycleLayout => {
345 let next = self.workspaces[active].params().mode.next();
346 self.workspaces[active].set_mode(next);
347 self.relayout()
348 }
349 DesktopAction::SetLayout(mode) => {
350 self.workspaces[active].set_mode(mode);
351 self.relayout()
352 }
353 DesktopAction::GrowMaster => self.nudge_master(0.05),
354 DesktopAction::ShrinkMaster => self.nudge_master(-0.05),
355 DesktopAction::IncMaster => self.nudge_master_count(1),
356 DesktopAction::DecMaster => self.nudge_master_count(-1),
357 DesktopAction::PromoteToMaster => {
358 self.workspaces[active].promote_focused();
359 self.relayout()
360 }
361 DesktopAction::SwitchWorkspace(n) => {
362 if n < self.workspaces.len() && n != active {
363 self.show_workspace(n);
364 self.relayout()
365 } else {
366 Vec::new()
367 }
368 }
369 DesktopAction::SendToWorkspace(n) => {
370 if n >= self.workspaces.len() || n == active {
371 return Vec::new();
372 }
373 match self.workspaces[active].focused() {
374 Some(id) => {
375 self.workspaces[active].remove(id);
376 self.workspaces[n].add(id);
377 self.relayout()
378 }
379 None => Vec::new(),
380 }
381 }
382 DesktopAction::FocusOutputNext => {
383 if self.outputs.len() > 1 {
384 self.focused_output = (self.focused_output + 1) % self.outputs.len();
385 self.relayout()
386 } else {
387 Vec::new()
388 }
389 }
390 DesktopAction::Quit => vec![BrainCommand::Shutdown],
391 }
392 }
393
394 /// El índice del escritorio activo — el que muestra la salida
395 /// enfocada. `0` si todavía no hay ninguna salida.
396 pub fn active_index(&self) -> usize {
397 self.outputs
398 .get(self.focused_output)
399 .map(|o| o.workspace)
400 .unwrap_or(0)
401 }
402
403 /// Hace que la salida enfocada muestre el escritorio `n`. Si otra
404 /// salida ya lo mostraba, intercambian — así ningún escritorio se
405 /// ve en dos sitios a la vez.
406 fn show_workspace(&mut self, n: usize) {
407 if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() {
408 return;
409 }
410 let current = self.outputs[self.focused_output].workspace;
411 if current == n {
412 return;
413 }
414 if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) {
415 self.outputs[other].workspace = current;
416 }
417 self.outputs[self.focused_output].workspace = n;
418 }
419
420 /// Recoloca las salidas en fila horizontal, en su orden de aparición.
421 fn reflow_outputs(&mut self) {
422 let mut x = 0;
423 for o in &mut self.outputs {
424 o.rect.x = x;
425 o.rect.y = 0;
426 x += o.rect.w;
427 }
428 }
429
430 /// Ajusta la fracción del área maestra del escritorio activo (la usan
431 /// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`.
432 fn nudge_master(&mut self, delta: f32) -> Vec<BrainCommand> {
433 let active = self.active_index();
434 let ws = &mut self.workspaces[active];
435 let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
436 ws.set_master_ratio(ratio);
437 self.relayout()
438 }
439
440 /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`.
441 fn nudge_master_count(&mut self, delta: i32) -> Vec<BrainCommand> {
442 let active = self.active_index();
443 let ws = &mut self.workspaces[active];
444 let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize;
445 ws.set_master_count(n);
446 self.relayout()
447 }
448
449 /// Recalcula la geometría de **todas** las salidas y la empaqueta en
450 /// un único [`BrainCommand::Place`]. Sin salidas, no hay nada que
451 /// colocar.
452 fn relayout(&self) -> Vec<BrainCommand> {
453 if self.outputs.is_empty() {
454 return Vec::new();
455 }
456 let mut all = Vec::new();
457 for o in &self.outputs {
458 all.extend(placements(&self.workspaces[o.workspace], o.rect));
459 }
460 // El foco del teclado es único: sólo la ventana enfocada de la
461 // salida enfocada. `placements` marca el foco por escritorio (lo
462 // necesita para la visibilidad en `Monocle`); aquí lo unificamos.
463 let global_focus = self.focused_window();
464 for p in &mut all {
465 p.focused = Some(p.id) == global_focus;
466 }
467 vec![BrainCommand::Place(all)]
468 }
469
470 // --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
471
472 /// El escritorio activo — el de la salida enfocada.
473 pub fn active_workspace(&self) -> &Workspace {
474 &self.workspaces[self.active_index()]
475 }
476
477 /// Las salidas conectadas, en orden, con el escritorio que muestran.
478 pub fn outputs(&self) -> &[Output] {
479 &self.outputs
480 }
481
482 /// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada.
483 pub fn focused_output(&self) -> usize {
484 self.focused_output
485 }
486
487 /// Identidad de una ventana conocida.
488 pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
489 self.windows.get(&id)
490 }
491
492 /// La ventana con el foco del teclado: la enfocada del escritorio
493 /// activo — o su ventana en pantalla completa, si la hay.
494 pub fn focused_window(&self) -> Option<WindowId> {
495 let ws = &self.workspaces[self.active_index()];
496 ws.fullscreen().or_else(|| ws.focused())
497 }
498
499 /// Cuántas ventanas hay en cada escritorio virtual.
500 pub fn workspace_loads(&self) -> Vec<usize> {
501 self.workspaces.iter().map(Workspace::len).collect()
502 }
503
504 /// Una vista de todas las ventanas conocidas, en todos los
505 /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
506 pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
507 let active = self.active_index();
508 let mut lines = Vec::new();
509 for (n, ws) in self.workspaces.iter().enumerate() {
510 let ws_focus = ws.focused();
511 for &id in ws.windows() {
512 let info = self.windows.get(&id);
513 lines.push(crate::ctl::WindowLine {
514 id,
515 app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
516 title: info.map(|i| i.title.clone()).unwrap_or_default(),
517 workspace: n + 1,
518 focused: n == active && ws_focus == Some(id),
519 });
520 }
521 }
522 // Ventanas guardadas en el scratchpad — en ningún escritorio.
523 for &id in &self.scratchpad {
524 let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id));
525 if stashed {
526 let info = self.windows.get(&id);
527 lines.push(crate::ctl::WindowLine {
528 id,
529 app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
530 title: info.map(|i| i.title.clone()).unwrap_or_default(),
531 workspace: 0, // 0 = guardada en el scratchpad
532 focused: false,
533 });
534 }
535 }
536 lines
537 }
538}
539
540/// El rectángulo flotante por defecto: 60 % de la pantalla, centrado.
541fn centered_float_rect(screen: Rect) -> Rect {
542 let w = screen.w * 3 / 5;
543 let h = screen.h * 3 / 5;
544 Rect::new(
545 screen.x + (screen.w - w) / 2,
546 screen.y + (screen.h - h) / 2,
547 w,
548 h,
549 )
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use mirada_layout::LayoutMode;
556
557 /// Un escritorio con una salida 1920×1080 ya conectada.
558 fn desktop_with_screen() -> Desktop {
559 let mut d = Desktop::new();
560 d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
561 d
562 }
563
564 fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
565 d.on_event(BodyEvent::WindowOpened {
566 id,
567 app_id: format!("app{id}"),
568 title: format!("win {id}"),
569 })
570 }
571
572 /// Extrae las colocaciones de un único `Place`.
573 fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
574 match cmds {
575 [BrainCommand::Place(p)] => p,
576 other => panic!("se esperaba un solo Place, no {other:?}"),
577 }
578 }
579
580 #[test]
581 fn grab_keys_lists_the_whole_keymap() {
582 let d = Desktop::new();
583 match d.grab_keys() {
584 BrainCommand::GrabKeys(keys) => {
585 assert!(keys.contains(&"Super+j".to_string()));
586 assert!(keys.contains(&"Super+Shift+e".to_string()));
587 }
588 other => panic!("se esperaba GrabKeys, no {other:?}"),
589 }
590 }
591
592 #[test]
593 fn set_keymap_swaps_the_bindings_and_regrabs() {
594 let mut d = desktop_with_screen();
595 for id in [1, 2, 3] {
596 open(&mut d, id);
597 }
598 // El keymap por defecto no usa Alt.
599 assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty());
600 // Cargamos un keymap a medida; el comando devuelto re-registra grabs.
601 let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap();
602 match d.set_keymap(custom) {
603 BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]),
604 other => panic!("se esperaba GrabKeys, no {other:?}"),
605 }
606 // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no.
607 assert_eq!(d.focused_window(), Some(3));
608 d.on_event(BodyEvent::Keybind("Alt+x".into()));
609 assert_eq!(d.focused_window(), Some(2));
610 assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
611 }
612
613 #[test]
614 fn focus_window_addresses_a_specific_window() {
615 let mut d = desktop_with_screen();
616 for id in [1, 2, 3] {
617 open(&mut d, id);
618 }
619 assert_eq!(d.focused_window(), Some(3));
620 d.apply(DesktopAction::FocusWindow(1));
621 assert_eq!(d.focused_window(), Some(1));
622 }
623
624 #[test]
625 fn focus_window_jumps_to_the_workspace_that_holds_it() {
626 let mut d = desktop_with_screen();
627 open(&mut d, 1);
628 open(&mut d, 2); // enfocada
629 // Manda la 2 al escritorio 3; seguimos en el 1.
630 d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
631 assert_eq!(d.active_index(), 0);
632 // Enfocar la 2 nos lleva a su escritorio.
633 d.apply(DesktopAction::FocusWindow(2));
634 assert_eq!(d.active_index(), 2);
635 assert_eq!(d.focused_window(), Some(2));
636 }
637
638 #[test]
639 fn window_lines_cover_every_window_with_its_workspace() {
640 let mut d = desktop_with_screen();
641 open(&mut d, 1);
642 open(&mut d, 2);
643 d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
644 let lines = d.window_lines();
645 assert_eq!(lines.len(), 2);
646 let w1 = lines.iter().find(|l| l.id == 1).unwrap();
647 let w2 = lines.iter().find(|l| l.id == 2).unwrap();
648 assert_eq!(w1.workspace, 1);
649 assert_eq!(w2.workspace, 3);
650 // La 1 quedó enfocada en el escritorio activo (el 1).
651 assert!(w1.focused);
652 assert!(!w2.focused);
653 }
654
655 #[test]
656 fn toggle_float_marks_the_focused_window_and_floats_it_last() {
657 let mut d = desktop_with_screen();
658 open(&mut d, 1);
659 open(&mut d, 2); // enfocada
660 let cmds = d.apply(DesktopAction::ToggleFloat);
661 let p = places(&cmds);
662 assert!(p.iter().find(|x| x.id == 2).unwrap().floating);
663 // La flotante va al final de la lista — orden de pintado.
664 assert_eq!(p.last().unwrap().id, 2);
665 // Alternar de nuevo la devuelve al teselado.
666 let cmds = d.apply(DesktopAction::ToggleFloat);
667 assert!(!places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
668 }
669
670 #[test]
671 fn toggle_fullscreen_covers_the_screen_and_hides_the_rest() {
672 let mut d = desktop_with_screen();
673 for id in [1, 2, 3] {
674 open(&mut d, id);
675 }
676 let cmds = d.apply(DesktopAction::ToggleFullscreen); // sobre la 3
677 let p = places(&cmds);
678 let fs = p.iter().find(|x| x.id == 3).unwrap();
679 assert!(fs.fullscreen && fs.visible);
680 assert_eq!(fs.rect, d.screen().unwrap());
681 assert!(p.iter().filter(|x| x.id != 3).all(|x| !x.visible));
682 // Alternar de nuevo restaura el teselado: las tres visibles.
683 let cmds = d.apply(DesktopAction::ToggleFullscreen);
684 assert_eq!(places(&cmds).iter().filter(|x| x.visible).count(), 3);
685 }
686
687 #[test]
688 fn a_rule_sends_a_new_window_to_its_workspace() {
689 let mut d = desktop_with_screen();
690 d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app2", workspace: 3) ] )"#).unwrap());
691 open(&mut d, 1); // app1 → sin regla → escritorio activo (1)
692 open(&mut d, 2); // app2 → regla → escritorio 3
693 assert_eq!(d.workspace_loads()[0], 1);
694 assert_eq!(d.workspace_loads()[2], 1);
695 }
696
697 #[test]
698 fn a_rule_can_open_a_window_floating() {
699 let mut d = desktop_with_screen();
700 d.set_rules(Rules::from_ron(r#"( rules: [ (app_id: "app1", floating: true) ] )"#).unwrap());
701 let cmds = open(&mut d, 1);
702 assert!(places(&cmds).iter().find(|p| p.id == 1).unwrap().floating);
703 }
704
705 #[test]
706 fn without_a_screen_nothing_is_placed() {
707 let mut d = Desktop::new();
708 assert!(open(&mut d, 1).is_empty());
709 }
710
711 #[test]
712 fn opening_a_window_places_it() {
713 let mut d = desktop_with_screen();
714 let cmds = open(&mut d, 1);
715 assert_eq!(places(&cmds).len(), 1);
716 assert_eq!(d.focused_window(), Some(1));
717 }
718
719 #[test]
720 fn closing_a_window_removes_it_everywhere() {
721 let mut d = desktop_with_screen();
722 open(&mut d, 1);
723 open(&mut d, 2);
724 let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
725 assert_eq!(places(&cmds).len(), 1);
726 assert!(d.window_info(1).is_none());
727 assert_eq!(d.focused_window(), Some(2));
728 }
729
730 #[test]
731 fn focus_keybind_cycles_within_the_active_workspace() {
732 let mut d = desktop_with_screen();
733 for id in [1, 2, 3] {
734 open(&mut d, id);
735 }
736 assert_eq!(d.focused_window(), Some(3));
737 d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
738 assert_eq!(d.focused_window(), Some(1));
739 d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
740 assert_eq!(d.focused_window(), Some(3));
741 }
742
743 #[test]
744 fn close_focused_keybind_asks_to_close_the_focused_window() {
745 let mut d = desktop_with_screen();
746 open(&mut d, 7);
747 let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
748 assert_eq!(cmds, vec![BrainCommand::Close(7)]);
749 // No se elimina hasta que el Cuerpo confirme con WindowClosed.
750 assert!(d.window_info(7).is_some());
751 }
752
753 #[test]
754 fn cycle_layout_walks_every_mode_and_returns() {
755 let mut d = desktop_with_screen();
756 open(&mut d, 1);
757 let start = d.active_workspace().params().mode;
758 for _ in 0..LayoutMode::ALL.len() {
759 let before = d.active_workspace().params().mode;
760 d.on_event(BodyEvent::Keybind("Super+space".into()));
761 assert_eq!(d.active_workspace().params().mode, before.next());
762 }
763 // Una vuelta completa devuelve al modo inicial.
764 assert_eq!(d.active_workspace().params().mode, start);
765 }
766
767 #[test]
768 fn grow_and_shrink_master_adjust_the_ratio() {
769 let mut d = desktop_with_screen();
770 open(&mut d, 1);
771 let r0 = d.active_workspace().params().master_ratio;
772 d.apply(DesktopAction::GrowMaster);
773 assert!(d.active_workspace().params().master_ratio > r0);
774 d.apply(DesktopAction::ShrinkMaster);
775 assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6);
776 }
777
778 #[test]
779 fn inc_and_dec_master_adjust_nmaster() {
780 let mut d = desktop_with_screen();
781 for id in [1, 2, 3] {
782 open(&mut d, id);
783 }
784 assert_eq!(d.active_workspace().params().master_count, 1);
785 d.apply(DesktopAction::IncMaster);
786 assert_eq!(d.active_workspace().params().master_count, 2);
787 d.apply(DesktopAction::DecMaster);
788 d.apply(DesktopAction::DecMaster); // no baja de 1
789 assert_eq!(d.active_workspace().params().master_count, 1);
790 }
791
792 #[test]
793 fn promote_to_master_brings_the_focused_window_to_the_front() {
794 let mut d = desktop_with_screen();
795 for id in [1, 2, 3] {
796 open(&mut d, id);
797 }
798 d.apply(DesktopAction::FocusWindow(3));
799 d.apply(DesktopAction::PromoteToMaster);
800 assert_eq!(d.active_workspace().windows()[0], 3);
801 assert_eq!(d.focused_window(), Some(3));
802 }
803
804 #[test]
805 fn master_ratio_stays_within_bounds() {
806 let mut d = desktop_with_screen();
807 open(&mut d, 1);
808 for _ in 0..50 {
809 d.apply(DesktopAction::GrowMaster);
810 }
811 assert!(d.active_workspace().params().master_ratio <= 0.95);
812 for _ in 0..50 {
813 d.apply(DesktopAction::ShrinkMaster);
814 }
815 assert!(d.active_workspace().params().master_ratio >= 0.05);
816 }
817
818 #[test]
819 fn monocle_keybind_hides_all_but_the_focused_window() {
820 let mut d = desktop_with_screen();
821 for id in [1, 2, 3] {
822 open(&mut d, id);
823 }
824 let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
825 let visible = places(&cmds).iter().filter(|p| p.visible).count();
826 assert_eq!(visible, 1);
827 }
828
829 #[test]
830 fn switching_workspace_changes_what_is_placed() {
831 let mut d = desktop_with_screen();
832 open(&mut d, 1);
833 open(&mut d, 2);
834 // Escritorio 2 (índice 1) está vacío.
835 let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
836 assert!(places(&cmds).is_empty());
837 assert_eq!(d.active_index(), 1);
838 // Volver al 1 reaparece las dos ventanas.
839 let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
840 assert_eq!(places(&cmds).len(), 2);
841 }
842
843 #[test]
844 fn send_to_workspace_moves_the_focused_window() {
845 let mut d = desktop_with_screen();
846 open(&mut d, 1);
847 open(&mut d, 2); // enfocada
848 d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
849 assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
850 assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
851 // La ventana 2 sigue registrada — sólo cambió de escritorio.
852 assert!(d.window_info(2).is_some());
853 }
854
855 #[test]
856 fn pointer_focuses_a_window_in_the_active_workspace() {
857 let mut d = desktop_with_screen();
858 open(&mut d, 1);
859 open(&mut d, 2); // enfocada
860 d.on_event(BodyEvent::PointerEntered { id: 1 });
861 assert_eq!(d.focused_window(), Some(1));
862 }
863
864 #[test]
865 fn dragging_floats_a_window_at_the_given_rect() {
866 let mut d = desktop_with_screen();
867 open(&mut d, 1);
868 open(&mut d, 2);
869 assert!(!d.active_workspace().is_floating(2));
870 let target = Rect::new(300, 200, 640, 480);
871 let cmds = d.on_event(BodyEvent::WindowFloatTo { id: 2, rect: target });
872 // La 2 ahora flota exactamente en el rectángulo pedido.
873 assert!(d.active_workspace().is_floating(2));
874 let p = places(&cmds).iter().find(|p| p.id == 2).unwrap();
875 assert!(p.floating);
876 assert_eq!(p.rect, target);
877 }
878
879 #[test]
880 fn dragging_an_unknown_window_does_nothing() {
881 let mut d = desktop_with_screen();
882 open(&mut d, 1);
883 let cmds = d.on_event(BodyEvent::WindowFloatTo {
884 id: 99,
885 rect: Rect::new(0, 0, 100, 100),
886 });
887 assert!(cmds.is_empty());
888 }
889
890 #[test]
891 fn retitling_updates_the_registry_without_relayout() {
892 let mut d = desktop_with_screen();
893 open(&mut d, 1);
894 let cmds = d.on_event(BodyEvent::WindowRetitled {
895 id: 1,
896 title: "nuevo".into(),
897 });
898 assert!(cmds.is_empty());
899 assert_eq!(d.window_info(1).unwrap().title, "nuevo");
900 }
901
902 #[test]
903 fn an_unknown_keybind_does_nothing() {
904 let mut d = desktop_with_screen();
905 open(&mut d, 1);
906 assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
907 }
908
909 #[test]
910 fn quit_emits_a_shutdown() {
911 let mut d = desktop_with_screen();
912 assert_eq!(
913 d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
914 vec![BrainCommand::Shutdown]
915 );
916 }
917
918 // --- Multi-monitor -------------------------------------------------
919
920 /// Un escritorio con dos salidas 1920×1080.
921 fn desktop_with_two_outputs() -> Desktop {
922 let mut d = Desktop::new();
923 d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
924 d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
925 d
926 }
927
928 #[test]
929 fn outputs_lay_side_by_side() {
930 let mut d = Desktop::new();
931 d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
932 d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
933 assert_eq!(d.outputs().len(), 2);
934 // La segunda salida arranca donde acaba la primera.
935 assert_eq!(d.outputs()[1].rect.x, 1920);
936 }
937
938 #[test]
939 fn each_output_shows_a_distinct_workspace() {
940 let d = desktop_with_two_outputs();
941 assert_eq!(d.outputs()[0].workspace, 0);
942 assert_eq!(d.outputs()[1].workspace, 1);
943 }
944
945 #[test]
946 fn switching_to_a_workspace_shown_on_another_output_swaps_them() {
947 let mut d = desktop_with_two_outputs();
948 // La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap.
949 d.apply(DesktopAction::SwitchWorkspace(1));
950 assert_eq!(d.outputs()[0].workspace, 1);
951 assert_eq!(d.outputs()[1].workspace, 0);
952 }
953
954 #[test]
955 fn focus_output_next_moves_the_focus_between_outputs() {
956 let mut d = desktop_with_two_outputs();
957 assert_eq!(d.active_index(), 0); // salida 0 → ws 0
958 d.apply(DesktopAction::FocusOutputNext);
959 assert_eq!(d.active_index(), 1); // salida 1 → ws 1
960 d.apply(DesktopAction::FocusOutputNext); // envuelve
961 assert_eq!(d.active_index(), 0);
962 }
963
964 #[test]
965 fn relayout_places_windows_on_every_output() {
966 let mut d = Desktop::new();
967 d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
968 d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 });
969 open(&mut d, 1); // en la salida 0 (ws 0)
970 d.apply(DesktopAction::FocusOutputNext);
971 let cmds = open(&mut d, 2); // en la salida 1 (ws 1)
972 let p = places(&cmds);
973 assert_eq!(p.len(), 2);
974 // Cada ventana cae en el rectángulo de su salida.
975 assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0);
976 assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920);
977 }
978
979 #[test]
980 fn keyboard_focus_is_unique_across_outputs() {
981 let mut d = desktop_with_two_outputs();
982 open(&mut d, 1);
983 d.apply(DesktopAction::FocusOutputNext);
984 let cmds = open(&mut d, 2);
985 // Sólo una ventana con foco de teclado en todo el Place.
986 assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1);
987 }
988
989 #[test]
990 fn removing_an_output_keeps_its_windows_in_their_workspace() {
991 let mut d = desktop_with_two_outputs();
992 d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1)
993 open(&mut d, 1); // en ws 1
994 d.on_event(BodyEvent::OutputRemoved { id: 1 });
995 // La ventana sigue registrada, en el ws 1.
996 assert!(d.window_info(1).is_some());
997 assert_eq!(d.workspace_loads()[1], 1);
998 assert_eq!(d.outputs().len(), 1);
999 }
1000
1001 // --- Scratchpad ----------------------------------------------------
1002
1003 #[test]
1004 fn send_to_scratchpad_hides_the_focused_window() {
1005 let mut d = desktop_with_screen();
1006 open(&mut d, 1);
1007 open(&mut d, 2); // enfocada
1008 d.apply(DesktopAction::SendToScratchpad);
1009 assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
1010 assert!(d.window_info(2).is_some()); // sigue registrada
1011 }
1012
1013 #[test]
1014 fn toggle_scratchpad_shows_then_hides_the_stashed_window() {
1015 let mut d = desktop_with_screen();
1016 open(&mut d, 1);
1017 open(&mut d, 2);
1018 d.apply(DesktopAction::SendToScratchpad); // guarda la 2
1019 assert_eq!(d.workspace_loads()[0], 1);
1020 // Toggle la invoca, flotando.
1021 let cmds = d.apply(DesktopAction::ToggleScratchpad);
1022 assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
1023 assert_eq!(d.workspace_loads()[0], 2);
1024 // Toggle de nuevo la oculta.
1025 d.apply(DesktopAction::ToggleScratchpad);
1026 assert_eq!(d.workspace_loads()[0], 1);
1027 }
1028
1029 #[test]
1030 fn a_scratchpad_window_follows_you_across_workspaces() {
1031 let mut d = desktop_with_screen();
1032 open(&mut d, 1);
1033 d.apply(DesktopAction::SendToScratchpad);
1034 d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1
1035 assert_eq!(d.workspace_loads()[0], 1);
1036 d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2
1037 d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2
1038 assert_eq!(d.workspace_loads()[1], 1);
1039 assert_eq!(d.workspace_loads()[0], 0);
1040 }
1041
1042 #[test]
1043 fn closing_a_stashed_window_drops_it_from_the_scratchpad() {
1044 let mut d = desktop_with_screen();
1045 open(&mut d, 1);
1046 d.apply(DesktopAction::SendToScratchpad);
1047 d.on_event(BodyEvent::WindowClosed { id: 1 });
1048 // Ya no hay nada que invocar.
1049 assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty());
1050 }
1051
1052 #[test]
1053 fn a_client_fullscreen_request_is_honoured() {
1054 let mut d = desktop_with_screen();
1055 open(&mut d, 1);
1056 open(&mut d, 2);
1057 let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: true });
1058 assert!(places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
1059 // El cliente la suelta.
1060 let cmds = d.on_event(BodyEvent::FullscreenRequest { id: 1, fullscreen: false });
1061 assert!(!places(&cmds).iter().find(|x| x.id == 1).unwrap().fullscreen);
1062 }
1063
1064 #[test]
1065 fn a_fullscreen_request_for_an_unknown_window_does_nothing() {
1066 let mut d = desktop_with_screen();
1067 open(&mut d, 1);
1068 assert!(d
1069 .on_event(BodyEvent::FullscreenRequest { id: 99, fullscreen: true })
1070 .is_empty());
1071 }
1072
1073 #[test]
1074 fn window_lines_show_a_stashed_window_as_workspace_zero() {
1075 let mut d = desktop_with_screen();
1076 open(&mut d, 1);
1077 d.apply(DesktopAction::SendToScratchpad);
1078 let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap();
1079 assert_eq!(line.workspace, 0);
1080 }
1081}