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