gioser

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ ee27108f6c9abe63ac3abdca743da82b467fc2ca crates/modules/mirada/mirada-brain/src/desktop.rs Rust · 1118 siq'ikuna · 41.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(mirada): acople del shell — ventana-dock al pie de la pantalla ee27108f6 · 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::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}