⎇ b31f98883379a172efd309cd2cec98d16eb591db
crates/modules/mirada/mirada-brain/src/desktop.rs
Historia
Quién tocó qué
crudo
feat(mirada): API de acciones — mirada-ctl + HUD interactivo
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}