⎇ 3e2777540f992e839c6bbf93e21452062d183f66
crates/modules/mirada/mirada-brain/src/desktop.rs
Historia
Quién tocó qué
crudo
feat(mirada): mirada-brain — orquestador de escritorio del compositor
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}