gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ b31f98883379a172efd309cd2cec98d16eb591db crates/modules/mirada/mirada-brain/src/desktop.rs Rust · 545 líneas · 19.2 KB Historia Quién tocó qué crudo

feat(mirada): API de acciones — mirada-ctl + HUD interactivo b31f98883 · sergio ·

1//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
2
3use std::collections::HashMap;
4
5use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
6use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
7
8use crate::action::{DesktopAction, WORKSPACE_COUNT};
9use crate::keymap::Keymap;
10
11/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct WindowInfo {
14    pub app_id: String,
15    pub title: String,
16}
17
18/// El estado completo del escritorio.
19///
20/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
21/// virtuales, el registro de ventanas y el mapa de atajos. El único
22/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
23/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
24///
25/// Limitación de v1: el teselado se calcula sobre la salida primaria
26/// (la primera conectada). El multi-monitor real llegará después.
27pub struct Desktop {
28    /// Salidas físicas, en fila horizontal y en orden de aparición.
29    outputs: Vec<(OutputId, Rect)>,
30    /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
31    workspaces: Vec<Workspace>,
32    /// Índice del escritorio activo.
33    active: usize,
34    /// Identidad de cada ventana conocida.
35    windows: HashMap<WindowId, WindowInfo>,
36    /// Atajos globales → acción. Configurable, recargable en caliente.
37    keymap: Keymap,
38}
39
40impl Default for Desktop {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl Desktop {
47    /// Escritorio recién arrancado: sin salidas ni ventanas, con los
48    /// escritorios virtuales vacíos y el mapa de teclas por defecto.
49    pub fn new() -> Self {
50        Self::with_keymap(Keymap::default())
51    }
52
53    /// Como [`Desktop::new`], pero con un keymap dado — el que la app
54    /// cargó del archivo de configuración del usuario.
55    pub fn with_keymap(keymap: Keymap) -> Self {
56        let workspaces = (0..WORKSPACE_COUNT)
57            .map(|_| Workspace::new(LayoutParams::default()))
58            .collect();
59        Self {
60            outputs: Vec::new(),
61            workspaces,
62            active: 0,
63            windows: HashMap::new(),
64            keymap,
65        }
66    }
67
68    /// El comando que registra los atajos globales en el Cuerpo. La app
69    /// lo envía al conectar, y de nuevo tras cada recarga del keymap.
70    pub fn grab_keys(&self) -> BrainCommand {
71        BrainCommand::GrabKeys(self.keymap.grab_list())
72    }
73
74    /// Reemplaza el keymap en caliente. Devuelve el [`BrainCommand`] que
75    /// el dueño debe enviar al Cuerpo para reajustar qué teclas intercepta.
76    pub fn set_keymap(&mut self, keymap: Keymap) -> BrainCommand {
77        self.keymap = keymap;
78        self.grab_keys()
79    }
80
81    /// El keymap vigente — para un HUD o un editor visual de atajos.
82    pub fn keymap(&self) -> &Keymap {
83        &self.keymap
84    }
85
86    /// Geometría de la salida primaria, si hay alguna conectada.
87    pub fn screen(&self) -> Option<Rect> {
88        self.outputs.first().map(|(_, r)| *r)
89    }
90
91    /// Procesa un evento del Cuerpo: muta el estado y devuelve los
92    /// comandos a enviar de vuelta.
93    pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
94        match event {
95            BodyEvent::OutputAdded { id, width, height } => {
96                // Las salidas se alinean en fila a la derecha de las previas.
97                let x: i32 = self.outputs.iter().map(|(_, r)| r.w).sum();
98                self.outputs.push((id, Rect::new(x, 0, width, height)));
99                self.relayout()
100            }
101            BodyEvent::OutputRemoved { id } => {
102                self.outputs.retain(|(o, _)| *o != id);
103                self.relayout()
104            }
105            BodyEvent::WindowOpened { id, app_id, title } => {
106                self.windows.insert(id, WindowInfo { app_id, title });
107                self.workspaces[self.active].add(id);
108                self.relayout()
109            }
110            BodyEvent::WindowClosed { id } => {
111                self.windows.remove(&id);
112                for ws in &mut self.workspaces {
113                    ws.remove(id);
114                }
115                self.relayout()
116            }
117            BodyEvent::WindowRetitled { id, title } => {
118                if let Some(info) = self.windows.get_mut(&id) {
119                    info.title = title;
120                }
121                // Un cambio de título no altera la geometría.
122                Vec::new()
123            }
124            BodyEvent::PointerEntered { id } => {
125                // Foco al pasar el puntero, sólo si la ventana está en el
126                // escritorio activo.
127                if self.workspaces[self.active].focus_window(id) {
128                    self.relayout()
129                } else {
130                    Vec::new()
131                }
132            }
133            BodyEvent::Keybind(key) => match self.keymap.lookup(&key) {
134                Some(action) => self.apply(action),
135                None => Vec::new(),
136            },
137        }
138    }
139
140    /// Aplica una acción de escritorio directamente (sin pasar por una
141    /// tecla). Útil para disparar acciones desde un HUD.
142    pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
143        match action {
144            DesktopAction::FocusNext => {
145                self.workspaces[self.active].focus_next();
146                self.relayout()
147            }
148            DesktopAction::FocusPrev => {
149                self.workspaces[self.active].focus_prev();
150                self.relayout()
151            }
152            DesktopAction::FocusWindow(id) => {
153                // En el escritorio activo basta enfocar; si la ventana
154                // está en otro, saltamos a ese escritorio.
155                if self.workspaces[self.active].focus_window(id) {
156                    return self.relayout();
157                }
158                for n in 0..self.workspaces.len() {
159                    if n != self.active && self.workspaces[n].focus_window(id) {
160                        self.active = n;
161                        return self.relayout();
162                    }
163                }
164                Vec::new()
165            }
166            DesktopAction::MoveForward => {
167                self.workspaces[self.active].move_focused_forward();
168                self.relayout()
169            }
170            DesktopAction::MoveBackward => {
171                self.workspaces[self.active].move_focused_backward();
172                self.relayout()
173            }
174            DesktopAction::CloseFocused => {
175                // Pedimos el cierre; el estado se actualiza al recibir el
176                // `WindowClosed` de vuelta, no antes.
177                match self.workspaces[self.active].focused() {
178                    Some(id) => vec![BrainCommand::Close(id)],
179                    None => Vec::new(),
180                }
181            }
182            DesktopAction::CycleLayout => {
183                let next = cycle_mode(self.workspaces[self.active].params().mode);
184                self.workspaces[self.active].set_mode(next);
185                self.relayout()
186            }
187            DesktopAction::SetLayout(mode) => {
188                self.workspaces[self.active].set_mode(mode);
189                self.relayout()
190            }
191            DesktopAction::SwitchWorkspace(n) => {
192                if n < self.workspaces.len() && n != self.active {
193                    self.active = n;
194                    self.relayout()
195                } else {
196                    Vec::new()
197                }
198            }
199            DesktopAction::SendToWorkspace(n) => {
200                if n >= self.workspaces.len() || n == self.active {
201                    return Vec::new();
202                }
203                match self.workspaces[self.active].focused() {
204                    Some(id) => {
205                        self.workspaces[self.active].remove(id);
206                        self.workspaces[n].add(id);
207                        self.relayout()
208                    }
209                    None => Vec::new(),
210                }
211            }
212            DesktopAction::Quit => vec![BrainCommand::Shutdown],
213        }
214    }
215
216    /// Recalcula la geometría del escritorio activo y la empaqueta en un
217    /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que
218    /// colocar.
219    fn relayout(&self) -> Vec<BrainCommand> {
220        match self.screen() {
221            Some(screen) => {
222                vec![BrainCommand::Place(placements(
223                    &self.workspaces[self.active],
224                    screen,
225                ))]
226            }
227            None => Vec::new(),
228        }
229    }
230
231    // --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
232
233    /// Índice del escritorio activo.
234    pub fn active_index(&self) -> usize {
235        self.active
236    }
237
238    /// El escritorio activo.
239    pub fn active_workspace(&self) -> &Workspace {
240        &self.workspaces[self.active]
241    }
242
243    /// Las salidas conectadas, en orden.
244    pub fn outputs(&self) -> &[(OutputId, Rect)] {
245        &self.outputs
246    }
247
248    /// Identidad de una ventana conocida.
249    pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
250        self.windows.get(&id)
251    }
252
253    /// La ventana enfocada en el escritorio activo.
254    pub fn focused_window(&self) -> Option<WindowId> {
255        self.workspaces[self.active].focused()
256    }
257
258    /// Cuántas ventanas hay en cada escritorio virtual.
259    pub fn workspace_loads(&self) -> Vec<usize> {
260        self.workspaces.iter().map(Workspace::len).collect()
261    }
262
263    /// Una vista de todas las ventanas conocidas, en todos los
264    /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
265    pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
266        let mut lines = Vec::new();
267        for (n, ws) in self.workspaces.iter().enumerate() {
268            let ws_focus = ws.focused();
269            for &id in ws.windows() {
270                let info = self.windows.get(&id);
271                lines.push(crate::ctl::WindowLine {
272                    id,
273                    app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
274                    title: info.map(|i| i.title.clone()).unwrap_or_default(),
275                    workspace: n + 1,
276                    focused: n == self.active && ws_focus == Some(id),
277                });
278            }
279        }
280        lines
281    }
282}
283
284/// El siguiente modo en el ciclo de [`DesktopAction::CycleLayout`].
285fn cycle_mode(mode: LayoutMode) -> LayoutMode {
286    match mode {
287        LayoutMode::MasterStack => LayoutMode::Monocle,
288        LayoutMode::Monocle => LayoutMode::Grid,
289        LayoutMode::Grid => LayoutMode::Columns,
290        LayoutMode::Columns => LayoutMode::MasterStack,
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// Un escritorio con una salida 1920×1080 ya conectada.
299    fn desktop_with_screen() -> Desktop {
300        let mut d = Desktop::new();
301        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
302        d
303    }
304
305    fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
306        d.on_event(BodyEvent::WindowOpened {
307            id,
308            app_id: format!("app{id}"),
309            title: format!("win {id}"),
310        })
311    }
312
313    /// Extrae las colocaciones de un único `Place`.
314    fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
315        match cmds {
316            [BrainCommand::Place(p)] => p,
317            other => panic!("se esperaba un solo Place, no {other:?}"),
318        }
319    }
320
321    #[test]
322    fn grab_keys_lists_the_whole_keymap() {
323        let d = Desktop::new();
324        match d.grab_keys() {
325            BrainCommand::GrabKeys(keys) => {
326                assert!(keys.contains(&"Super+j".to_string()));
327                assert!(keys.contains(&"Super+Shift+e".to_string()));
328            }
329            other => panic!("se esperaba GrabKeys, no {other:?}"),
330        }
331    }
332
333    #[test]
334    fn set_keymap_swaps_the_bindings_and_regrabs() {
335        let mut d = desktop_with_screen();
336        for id in [1, 2, 3] {
337            open(&mut d, id);
338        }
339        // El keymap por defecto no usa Alt.
340        assert!(d.on_event(BodyEvent::Keybind("Alt+x".into())).is_empty());
341        // Cargamos un keymap a medida; el comando devuelto re-registra grabs.
342        let custom = crate::Keymap::from_ron(r#"( bindings: { "Alt+x": "focus-prev" } )"#).unwrap();
343        match d.set_keymap(custom) {
344            BrainCommand::GrabKeys(keys) => assert_eq!(keys, vec!["Alt+x".to_string()]),
345            other => panic!("se esperaba GrabKeys, no {other:?}"),
346        }
347        // Ahora «Alt+x» sí mueve el foco, y «Super+j» ya no.
348        assert_eq!(d.focused_window(), Some(3));
349        d.on_event(BodyEvent::Keybind("Alt+x".into()));
350        assert_eq!(d.focused_window(), Some(2));
351        assert!(d.on_event(BodyEvent::Keybind("Super+j".into())).is_empty());
352    }
353
354    #[test]
355    fn focus_window_addresses_a_specific_window() {
356        let mut d = desktop_with_screen();
357        for id in [1, 2, 3] {
358            open(&mut d, id);
359        }
360        assert_eq!(d.focused_window(), Some(3));
361        d.apply(DesktopAction::FocusWindow(1));
362        assert_eq!(d.focused_window(), Some(1));
363    }
364
365    #[test]
366    fn focus_window_jumps_to_the_workspace_that_holds_it() {
367        let mut d = desktop_with_screen();
368        open(&mut d, 1);
369        open(&mut d, 2); // enfocada
370        // Manda la 2 al escritorio 3; seguimos en el 1.
371        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
372        assert_eq!(d.active_index(), 0);
373        // Enfocar la 2 nos lleva a su escritorio.
374        d.apply(DesktopAction::FocusWindow(2));
375        assert_eq!(d.active_index(), 2);
376        assert_eq!(d.focused_window(), Some(2));
377    }
378
379    #[test]
380    fn window_lines_cover_every_window_with_its_workspace() {
381        let mut d = desktop_with_screen();
382        open(&mut d, 1);
383        open(&mut d, 2);
384        d.on_event(BodyEvent::Keybind("Super+Shift+3".into())); // la 2 al esc. 3
385        let lines = d.window_lines();
386        assert_eq!(lines.len(), 2);
387        let w1 = lines.iter().find(|l| l.id == 1).unwrap();
388        let w2 = lines.iter().find(|l| l.id == 2).unwrap();
389        assert_eq!(w1.workspace, 1);
390        assert_eq!(w2.workspace, 3);
391        // La 1 quedó enfocada en el escritorio activo (el 1).
392        assert!(w1.focused);
393        assert!(!w2.focused);
394    }
395
396    #[test]
397    fn without_a_screen_nothing_is_placed() {
398        let mut d = Desktop::new();
399        assert!(open(&mut d, 1).is_empty());
400    }
401
402    #[test]
403    fn opening_a_window_places_it() {
404        let mut d = desktop_with_screen();
405        let cmds = open(&mut d, 1);
406        assert_eq!(places(&cmds).len(), 1);
407        assert_eq!(d.focused_window(), Some(1));
408    }
409
410    #[test]
411    fn closing_a_window_removes_it_everywhere() {
412        let mut d = desktop_with_screen();
413        open(&mut d, 1);
414        open(&mut d, 2);
415        let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
416        assert_eq!(places(&cmds).len(), 1);
417        assert!(d.window_info(1).is_none());
418        assert_eq!(d.focused_window(), Some(2));
419    }
420
421    #[test]
422    fn focus_keybind_cycles_within_the_active_workspace() {
423        let mut d = desktop_with_screen();
424        for id in [1, 2, 3] {
425            open(&mut d, id);
426        }
427        assert_eq!(d.focused_window(), Some(3));
428        d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
429        assert_eq!(d.focused_window(), Some(1));
430        d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
431        assert_eq!(d.focused_window(), Some(3));
432    }
433
434    #[test]
435    fn close_focused_keybind_asks_to_close_the_focused_window() {
436        let mut d = desktop_with_screen();
437        open(&mut d, 7);
438        let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
439        assert_eq!(cmds, vec![BrainCommand::Close(7)]);
440        // No se elimina hasta que el Cuerpo confirme con WindowClosed.
441        assert!(d.window_info(7).is_some());
442    }
443
444    #[test]
445    fn cycle_layout_walks_the_four_modes() {
446        let mut d = desktop_with_screen();
447        open(&mut d, 1);
448        assert_eq!(d.active_workspace().params().mode, LayoutMode::MasterStack);
449        for expected in [
450            LayoutMode::Monocle,
451            LayoutMode::Grid,
452            LayoutMode::Columns,
453            LayoutMode::MasterStack,
454        ] {
455            d.on_event(BodyEvent::Keybind("Super+space".into()));
456            assert_eq!(d.active_workspace().params().mode, expected);
457        }
458    }
459
460    #[test]
461    fn monocle_keybind_hides_all_but_the_focused_window() {
462        let mut d = desktop_with_screen();
463        for id in [1, 2, 3] {
464            open(&mut d, id);
465        }
466        let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
467        let visible = places(&cmds).iter().filter(|p| p.visible).count();
468        assert_eq!(visible, 1);
469    }
470
471    #[test]
472    fn switching_workspace_changes_what_is_placed() {
473        let mut d = desktop_with_screen();
474        open(&mut d, 1);
475        open(&mut d, 2);
476        // Escritorio 2 (índice 1) está vacío.
477        let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
478        assert!(places(&cmds).is_empty());
479        assert_eq!(d.active_index(), 1);
480        // Volver al 1 reaparece las dos ventanas.
481        let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
482        assert_eq!(places(&cmds).len(), 2);
483    }
484
485    #[test]
486    fn send_to_workspace_moves_the_focused_window() {
487        let mut d = desktop_with_screen();
488        open(&mut d, 1);
489        open(&mut d, 2); // enfocada
490        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
491        assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
492        assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
493        // La ventana 2 sigue registrada — sólo cambió de escritorio.
494        assert!(d.window_info(2).is_some());
495    }
496
497    #[test]
498    fn pointer_focuses_a_window_in_the_active_workspace() {
499        let mut d = desktop_with_screen();
500        open(&mut d, 1);
501        open(&mut d, 2); // enfocada
502        d.on_event(BodyEvent::PointerEntered { id: 1 });
503        assert_eq!(d.focused_window(), Some(1));
504    }
505
506    #[test]
507    fn retitling_updates_the_registry_without_relayout() {
508        let mut d = desktop_with_screen();
509        open(&mut d, 1);
510        let cmds = d.on_event(BodyEvent::WindowRetitled {
511            id: 1,
512            title: "nuevo".into(),
513        });
514        assert!(cmds.is_empty());
515        assert_eq!(d.window_info(1).unwrap().title, "nuevo");
516    }
517
518    #[test]
519    fn an_unknown_keybind_does_nothing() {
520        let mut d = desktop_with_screen();
521        open(&mut d, 1);
522        assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
523    }
524
525    #[test]
526    fn quit_emits_a_shutdown() {
527        let mut d = desktop_with_screen();
528        assert_eq!(
529            d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
530            vec![BrainCommand::Shutdown]
531        );
532    }
533
534    #[test]
535    fn outputs_lay_side_by_side() {
536        let mut d = Desktop::new();
537        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
538        d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
539        assert_eq!(d.outputs().len(), 2);
540        // La segunda salida arranca donde acaba la primera.
541        assert_eq!(d.outputs()[1].1.x, 1920);
542        // El teselado sigue sobre la salida primaria.
543        assert_eq!(d.screen().unwrap().w, 1920);
544    }
545}