gioser

tawasuyu/brahman

Rust 574TOML 218Markdown 68JSON 198352 Definiciones

⎇ 3e2777540f992e839c6bbf93e21452062d183f66 crates/modules/mirada/mirada-brain/src/desktop.rs Rust · 431 líneas · 14.8 KB Historia Quién tocó qué crudo

feat(mirada): mirada-brain — orquestador de escritorio del compositor 3e2777540 · 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::{default_keymap, DesktopAction, WORKSPACE_COUNT};
9
10/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct WindowInfo {
13    pub app_id: String,
14    pub title: String,
15}
16
17/// El estado completo del escritorio.
18///
19/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
20/// virtuales, el registro de ventanas y el mapa de atajos. El único
21/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
22/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
23///
24/// Limitación de v1: el teselado se calcula sobre la salida primaria
25/// (la primera conectada). El multi-monitor real llegará después.
26pub struct Desktop {
27    /// Salidas físicas, en fila horizontal y en orden de aparición.
28    outputs: Vec<(OutputId, Rect)>,
29    /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
30    workspaces: Vec<Workspace>,
31    /// Índice del escritorio activo.
32    active: usize,
33    /// Identidad de cada ventana conocida.
34    windows: HashMap<WindowId, WindowInfo>,
35    /// Atajos globales → acción.
36    keymap: Vec<(String, DesktopAction)>,
37}
38
39impl Default for Desktop {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl Desktop {
46    /// Escritorio recién arrancado: sin salidas ni ventanas, con los
47    /// escritorios virtuales vacíos y el mapa de teclas por defecto.
48    pub fn new() -> Self {
49        let workspaces = (0..WORKSPACE_COUNT)
50            .map(|_| Workspace::new(LayoutParams::default()))
51            .collect();
52        Self {
53            outputs: Vec::new(),
54            workspaces,
55            active: 0,
56            windows: HashMap::new(),
57            keymap: default_keymap(),
58        }
59    }
60
61    /// El comando que registra los atajos globales en el Cuerpo. La app
62    /// GPUI lo envía una vez, al conectar.
63    pub fn grab_keys(&self) -> BrainCommand {
64        BrainCommand::GrabKeys(self.keymap.iter().map(|(k, _)| k.clone()).collect())
65    }
66
67    /// Geometría de la salida primaria, si hay alguna conectada.
68    pub fn screen(&self) -> Option<Rect> {
69        self.outputs.first().map(|(_, r)| *r)
70    }
71
72    /// Procesa un evento del Cuerpo: muta el estado y devuelve los
73    /// comandos a enviar de vuelta.
74    pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
75        match event {
76            BodyEvent::OutputAdded { id, width, height } => {
77                // Las salidas se alinean en fila a la derecha de las previas.
78                let x: i32 = self.outputs.iter().map(|(_, r)| r.w).sum();
79                self.outputs.push((id, Rect::new(x, 0, width, height)));
80                self.relayout()
81            }
82            BodyEvent::OutputRemoved { id } => {
83                self.outputs.retain(|(o, _)| *o != id);
84                self.relayout()
85            }
86            BodyEvent::WindowOpened { id, app_id, title } => {
87                self.windows.insert(id, WindowInfo { app_id, title });
88                self.workspaces[self.active].add(id);
89                self.relayout()
90            }
91            BodyEvent::WindowClosed { id } => {
92                self.windows.remove(&id);
93                for ws in &mut self.workspaces {
94                    ws.remove(id);
95                }
96                self.relayout()
97            }
98            BodyEvent::WindowRetitled { id, title } => {
99                if let Some(info) = self.windows.get_mut(&id) {
100                    info.title = title;
101                }
102                // Un cambio de título no altera la geometría.
103                Vec::new()
104            }
105            BodyEvent::PointerEntered { id } => {
106                // Foco al pasar el puntero, sólo si la ventana está en el
107                // escritorio activo.
108                if self.workspaces[self.active].focus_window(id) {
109                    self.relayout()
110                } else {
111                    Vec::new()
112                }
113            }
114            BodyEvent::Keybind(key) => {
115                match self.keymap.iter().find(|(k, _)| *k == key).map(|(_, a)| *a) {
116                    Some(action) => self.apply(action),
117                    None => Vec::new(),
118                }
119            }
120        }
121    }
122
123    /// Aplica una acción de escritorio directamente (sin pasar por una
124    /// tecla). Útil para disparar acciones desde un HUD.
125    pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
126        match action {
127            DesktopAction::FocusNext => {
128                self.workspaces[self.active].focus_next();
129                self.relayout()
130            }
131            DesktopAction::FocusPrev => {
132                self.workspaces[self.active].focus_prev();
133                self.relayout()
134            }
135            DesktopAction::MoveForward => {
136                self.workspaces[self.active].move_focused_forward();
137                self.relayout()
138            }
139            DesktopAction::MoveBackward => {
140                self.workspaces[self.active].move_focused_backward();
141                self.relayout()
142            }
143            DesktopAction::CloseFocused => {
144                // Pedimos el cierre; el estado se actualiza al recibir el
145                // `WindowClosed` de vuelta, no antes.
146                match self.workspaces[self.active].focused() {
147                    Some(id) => vec![BrainCommand::Close(id)],
148                    None => Vec::new(),
149                }
150            }
151            DesktopAction::CycleLayout => {
152                let next = cycle_mode(self.workspaces[self.active].params().mode);
153                self.workspaces[self.active].set_mode(next);
154                self.relayout()
155            }
156            DesktopAction::SetLayout(mode) => {
157                self.workspaces[self.active].set_mode(mode);
158                self.relayout()
159            }
160            DesktopAction::SwitchWorkspace(n) => {
161                if n < self.workspaces.len() && n != self.active {
162                    self.active = n;
163                    self.relayout()
164                } else {
165                    Vec::new()
166                }
167            }
168            DesktopAction::SendToWorkspace(n) => {
169                if n >= self.workspaces.len() || n == self.active {
170                    return Vec::new();
171                }
172                match self.workspaces[self.active].focused() {
173                    Some(id) => {
174                        self.workspaces[self.active].remove(id);
175                        self.workspaces[n].add(id);
176                        self.relayout()
177                    }
178                    None => Vec::new(),
179                }
180            }
181            DesktopAction::Quit => vec![BrainCommand::Shutdown],
182        }
183    }
184
185    /// Recalcula la geometría del escritorio activo y la empaqueta en un
186    /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que
187    /// colocar.
188    fn relayout(&self) -> Vec<BrainCommand> {
189        match self.screen() {
190            Some(screen) => {
191                vec![BrainCommand::Place(placements(
192                    &self.workspaces[self.active],
193                    screen,
194                ))]
195            }
196            None => Vec::new(),
197        }
198    }
199
200    // --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
201
202    /// Índice del escritorio activo.
203    pub fn active_index(&self) -> usize {
204        self.active
205    }
206
207    /// El escritorio activo.
208    pub fn active_workspace(&self) -> &Workspace {
209        &self.workspaces[self.active]
210    }
211
212    /// Las salidas conectadas, en orden.
213    pub fn outputs(&self) -> &[(OutputId, Rect)] {
214        &self.outputs
215    }
216
217    /// Identidad de una ventana conocida.
218    pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
219        self.windows.get(&id)
220    }
221
222    /// La ventana enfocada en el escritorio activo.
223    pub fn focused_window(&self) -> Option<WindowId> {
224        self.workspaces[self.active].focused()
225    }
226
227    /// Cuántas ventanas hay en cada escritorio virtual.
228    pub fn workspace_loads(&self) -> Vec<usize> {
229        self.workspaces.iter().map(Workspace::len).collect()
230    }
231}
232
233/// El siguiente modo en el ciclo de [`DesktopAction::CycleLayout`].
234fn cycle_mode(mode: LayoutMode) -> LayoutMode {
235    match mode {
236        LayoutMode::MasterStack => LayoutMode::Monocle,
237        LayoutMode::Monocle => LayoutMode::Grid,
238        LayoutMode::Grid => LayoutMode::Columns,
239        LayoutMode::Columns => LayoutMode::MasterStack,
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// Un escritorio con una salida 1920×1080 ya conectada.
248    fn desktop_with_screen() -> Desktop {
249        let mut d = Desktop::new();
250        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
251        d
252    }
253
254    fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
255        d.on_event(BodyEvent::WindowOpened {
256            id,
257            app_id: format!("app{id}"),
258            title: format!("win {id}"),
259        })
260    }
261
262    /// Extrae las colocaciones de un único `Place`.
263    fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
264        match cmds {
265            [BrainCommand::Place(p)] => p,
266            other => panic!("se esperaba un solo Place, no {other:?}"),
267        }
268    }
269
270    #[test]
271    fn grab_keys_lists_the_whole_keymap() {
272        let d = Desktop::new();
273        match d.grab_keys() {
274            BrainCommand::GrabKeys(keys) => {
275                assert!(keys.contains(&"Super+j".to_string()));
276                assert!(keys.contains(&"Super+Shift+e".to_string()));
277            }
278            other => panic!("se esperaba GrabKeys, no {other:?}"),
279        }
280    }
281
282    #[test]
283    fn without_a_screen_nothing_is_placed() {
284        let mut d = Desktop::new();
285        assert!(open(&mut d, 1).is_empty());
286    }
287
288    #[test]
289    fn opening_a_window_places_it() {
290        let mut d = desktop_with_screen();
291        let cmds = open(&mut d, 1);
292        assert_eq!(places(&cmds).len(), 1);
293        assert_eq!(d.focused_window(), Some(1));
294    }
295
296    #[test]
297    fn closing_a_window_removes_it_everywhere() {
298        let mut d = desktop_with_screen();
299        open(&mut d, 1);
300        open(&mut d, 2);
301        let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
302        assert_eq!(places(&cmds).len(), 1);
303        assert!(d.window_info(1).is_none());
304        assert_eq!(d.focused_window(), Some(2));
305    }
306
307    #[test]
308    fn focus_keybind_cycles_within_the_active_workspace() {
309        let mut d = desktop_with_screen();
310        for id in [1, 2, 3] {
311            open(&mut d, id);
312        }
313        assert_eq!(d.focused_window(), Some(3));
314        d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
315        assert_eq!(d.focused_window(), Some(1));
316        d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
317        assert_eq!(d.focused_window(), Some(3));
318    }
319
320    #[test]
321    fn close_focused_keybind_asks_to_close_the_focused_window() {
322        let mut d = desktop_with_screen();
323        open(&mut d, 7);
324        let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
325        assert_eq!(cmds, vec![BrainCommand::Close(7)]);
326        // No se elimina hasta que el Cuerpo confirme con WindowClosed.
327        assert!(d.window_info(7).is_some());
328    }
329
330    #[test]
331    fn cycle_layout_walks_the_four_modes() {
332        let mut d = desktop_with_screen();
333        open(&mut d, 1);
334        assert_eq!(d.active_workspace().params().mode, LayoutMode::MasterStack);
335        for expected in [
336            LayoutMode::Monocle,
337            LayoutMode::Grid,
338            LayoutMode::Columns,
339            LayoutMode::MasterStack,
340        ] {
341            d.on_event(BodyEvent::Keybind("Super+space".into()));
342            assert_eq!(d.active_workspace().params().mode, expected);
343        }
344    }
345
346    #[test]
347    fn monocle_keybind_hides_all_but_the_focused_window() {
348        let mut d = desktop_with_screen();
349        for id in [1, 2, 3] {
350            open(&mut d, id);
351        }
352        let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
353        let visible = places(&cmds).iter().filter(|p| p.visible).count();
354        assert_eq!(visible, 1);
355    }
356
357    #[test]
358    fn switching_workspace_changes_what_is_placed() {
359        let mut d = desktop_with_screen();
360        open(&mut d, 1);
361        open(&mut d, 2);
362        // Escritorio 2 (índice 1) está vacío.
363        let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
364        assert!(places(&cmds).is_empty());
365        assert_eq!(d.active_index(), 1);
366        // Volver al 1 reaparece las dos ventanas.
367        let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
368        assert_eq!(places(&cmds).len(), 2);
369    }
370
371    #[test]
372    fn send_to_workspace_moves_the_focused_window() {
373        let mut d = desktop_with_screen();
374        open(&mut d, 1);
375        open(&mut d, 2); // enfocada
376        d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
377        assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
378        assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
379        // La ventana 2 sigue registrada — sólo cambió de escritorio.
380        assert!(d.window_info(2).is_some());
381    }
382
383    #[test]
384    fn pointer_focuses_a_window_in_the_active_workspace() {
385        let mut d = desktop_with_screen();
386        open(&mut d, 1);
387        open(&mut d, 2); // enfocada
388        d.on_event(BodyEvent::PointerEntered { id: 1 });
389        assert_eq!(d.focused_window(), Some(1));
390    }
391
392    #[test]
393    fn retitling_updates_the_registry_without_relayout() {
394        let mut d = desktop_with_screen();
395        open(&mut d, 1);
396        let cmds = d.on_event(BodyEvent::WindowRetitled {
397            id: 1,
398            title: "nuevo".into(),
399        });
400        assert!(cmds.is_empty());
401        assert_eq!(d.window_info(1).unwrap().title, "nuevo");
402    }
403
404    #[test]
405    fn an_unknown_keybind_does_nothing() {
406        let mut d = desktop_with_screen();
407        open(&mut d, 1);
408        assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
409    }
410
411    #[test]
412    fn quit_emits_a_shutdown() {
413        let mut d = desktop_with_screen();
414        assert_eq!(
415            d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
416            vec![BrainCommand::Shutdown]
417        );
418    }
419
420    #[test]
421    fn outputs_lay_side_by_side() {
422        let mut d = Desktop::new();
423        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
424        d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
425        assert_eq!(d.outputs().len(), 2);
426        // La segunda salida arranca donde acaba la primera.
427        assert_eq!(d.outputs()[1].1.x, 1920);
428        // El teselado sigue sobre la salida primaria.
429        assert_eq!(d.screen().unwrap().w, 1920);
430    }
431}