gioser

tawasuyu/brahman

Rust 574TOML 218Markdown 68JSON 198352 Definiciones

⎇ 90bffec3f169c244946d37d6a2a6d02805e52973 crates/modules/mirada/mirada-brain/src/desktop.rs Rust · 1081 líneas · 40.3 KB Historia Quién tocó qué crudo

feat(mirada): mover/redimensionar ventanas con el ratón 90bffec3f · sergio ·

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}