feat(mirada): mirada-brain — orquestador de escritorio del compositor

Desktop agnóstico de GPUI/smithay: salidas, 9 escritorios virtuales,
registro de ventanas y foco. on_event(BodyEvent) -> Vec<BrainCommand>;
DesktopAction + mapa de teclas estilo tiling WM (Super). 17 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 21:00:24 +00:00
parent e736587857
commit 3e2777540f
6 changed files with 572 additions and 0 deletions
@@ -0,0 +1,95 @@
//! Acciones de escritorio y su mapa de teclas por defecto.
//!
//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
//! aplica sin saber qué combinación las disparó.
use mirada_layout::LayoutMode;
/// Número de escritorios virtuales que mantiene el `Desktop`.
pub const WORKSPACE_COUNT: usize = 9;
/// Una orden de escritorio de alto nivel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesktopAction {
/// Mueve el foco a la ventana siguiente del escritorio activo.
FocusNext,
/// Mueve el foco a la ventana anterior.
FocusPrev,
/// Adelanta la ventana enfocada en el orden de teselado.
MoveForward,
/// Atrasa la ventana enfocada en el orden de teselado.
MoveBackward,
/// Cierra la ventana enfocada (cierre ordenado).
CloseFocused,
/// Pasa al siguiente modo de teselado.
CycleLayout,
/// Fija un modo de teselado concreto.
SetLayout(LayoutMode),
/// Activa el escritorio virtual `n` (índice 0-based).
SwitchWorkspace(usize),
/// Manda la ventana enfocada al escritorio virtual `n`.
SendToWorkspace(usize),
/// Apaga el compositor.
Quit,
}
/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
///
/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
/// también las que se registran con
/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
pub fn default_keymap() -> Vec<(String, DesktopAction)> {
let mut map = vec![
("Super+j".into(), DesktopAction::FocusNext),
("Super+k".into(), DesktopAction::FocusPrev),
("Super+Shift+j".into(), DesktopAction::MoveForward),
("Super+Shift+k".into(), DesktopAction::MoveBackward),
("Super+q".into(), DesktopAction::CloseFocused),
("Super+space".into(), DesktopAction::CycleLayout),
("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
("Super+Shift+e".into(), DesktopAction::Quit),
];
// Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
// `Super+Shift+1`.. mandan la ventana enfocada allí.
for n in 0..WORKSPACE_COUNT {
map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
map.push((
format!("Super+Shift+{}", n + 1),
DesktopAction::SendToWorkspace(n),
));
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keymap_has_no_duplicate_bindings() {
let map = default_keymap();
let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
keys.sort();
let unique = keys.len();
keys.dedup();
assert_eq!(keys.len(), unique, "hay un atajo repetido");
}
#[test]
fn keymap_covers_every_virtual_workspace() {
let map = default_keymap();
for n in 0..WORKSPACE_COUNT {
assert!(map
.iter()
.any(|(_, a)| *a == DesktopAction::SwitchWorkspace(n)));
assert!(map
.iter()
.any(|(_, a)| *a == DesktopAction::SendToWorkspace(n)));
}
}
}
@@ -0,0 +1,431 @@
//! El [`Desktop`] — el estado del escritorio y el bucle `evento → comandos`.
use std::collections::HashMap;
use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
use crate::action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
/// Lo que el Cerebro sabe de una ventana: su identidad de aplicación.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WindowInfo {
pub app_id: String,
pub title: String,
}
/// El estado completo del escritorio.
///
/// Mantiene las salidas físicas, [`WORKSPACE_COUNT`] escritorios
/// virtuales, el registro de ventanas y el mapa de atajos. El único
/// punto de entrada es [`Desktop::on_event`]: traga un [`BodyEvent`],
/// muta el estado y devuelve los [`BrainCommand`]s a enviar al Cuerpo.
///
/// Limitación de v1: el teselado se calcula sobre la salida primaria
/// (la primera conectada). El multi-monitor real llegará después.
pub struct Desktop {
/// Salidas físicas, en fila horizontal y en orden de aparición.
outputs: Vec<(OutputId, Rect)>,
/// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
workspaces: Vec<Workspace>,
/// Índice del escritorio activo.
active: usize,
/// Identidad de cada ventana conocida.
windows: HashMap<WindowId, WindowInfo>,
/// Atajos globales → acción.
keymap: Vec<(String, DesktopAction)>,
}
impl Default for Desktop {
fn default() -> Self {
Self::new()
}
}
impl Desktop {
/// Escritorio recién arrancado: sin salidas ni ventanas, con los
/// escritorios virtuales vacíos y el mapa de teclas por defecto.
pub fn new() -> Self {
let workspaces = (0..WORKSPACE_COUNT)
.map(|_| Workspace::new(LayoutParams::default()))
.collect();
Self {
outputs: Vec::new(),
workspaces,
active: 0,
windows: HashMap::new(),
keymap: default_keymap(),
}
}
/// El comando que registra los atajos globales en el Cuerpo. La app
/// GPUI lo envía una vez, al conectar.
pub fn grab_keys(&self) -> BrainCommand {
BrainCommand::GrabKeys(self.keymap.iter().map(|(k, _)| k.clone()).collect())
}
/// Geometría de la salida primaria, si hay alguna conectada.
pub fn screen(&self) -> Option<Rect> {
self.outputs.first().map(|(_, r)| *r)
}
/// Procesa un evento del Cuerpo: muta el estado y devuelve los
/// comandos a enviar de vuelta.
pub fn on_event(&mut self, event: BodyEvent) -> Vec<BrainCommand> {
match event {
BodyEvent::OutputAdded { id, width, height } => {
// Las salidas se alinean en fila a la derecha de las previas.
let x: i32 = self.outputs.iter().map(|(_, r)| r.w).sum();
self.outputs.push((id, Rect::new(x, 0, width, height)));
self.relayout()
}
BodyEvent::OutputRemoved { id } => {
self.outputs.retain(|(o, _)| *o != id);
self.relayout()
}
BodyEvent::WindowOpened { id, app_id, title } => {
self.windows.insert(id, WindowInfo { app_id, title });
self.workspaces[self.active].add(id);
self.relayout()
}
BodyEvent::WindowClosed { id } => {
self.windows.remove(&id);
for ws in &mut self.workspaces {
ws.remove(id);
}
self.relayout()
}
BodyEvent::WindowRetitled { id, title } => {
if let Some(info) = self.windows.get_mut(&id) {
info.title = title;
}
// Un cambio de título no altera la geometría.
Vec::new()
}
BodyEvent::PointerEntered { id } => {
// Foco al pasar el puntero, sólo si la ventana está en el
// escritorio activo.
if self.workspaces[self.active].focus_window(id) {
self.relayout()
} else {
Vec::new()
}
}
BodyEvent::Keybind(key) => {
match self.keymap.iter().find(|(k, _)| *k == key).map(|(_, a)| *a) {
Some(action) => self.apply(action),
None => Vec::new(),
}
}
}
}
/// Aplica una acción de escritorio directamente (sin pasar por una
/// tecla). Útil para disparar acciones desde un HUD.
pub fn apply(&mut self, action: DesktopAction) -> Vec<BrainCommand> {
match action {
DesktopAction::FocusNext => {
self.workspaces[self.active].focus_next();
self.relayout()
}
DesktopAction::FocusPrev => {
self.workspaces[self.active].focus_prev();
self.relayout()
}
DesktopAction::MoveForward => {
self.workspaces[self.active].move_focused_forward();
self.relayout()
}
DesktopAction::MoveBackward => {
self.workspaces[self.active].move_focused_backward();
self.relayout()
}
DesktopAction::CloseFocused => {
// Pedimos el cierre; el estado se actualiza al recibir el
// `WindowClosed` de vuelta, no antes.
match self.workspaces[self.active].focused() {
Some(id) => vec![BrainCommand::Close(id)],
None => Vec::new(),
}
}
DesktopAction::CycleLayout => {
let next = cycle_mode(self.workspaces[self.active].params().mode);
self.workspaces[self.active].set_mode(next);
self.relayout()
}
DesktopAction::SetLayout(mode) => {
self.workspaces[self.active].set_mode(mode);
self.relayout()
}
DesktopAction::SwitchWorkspace(n) => {
if n < self.workspaces.len() && n != self.active {
self.active = n;
self.relayout()
} else {
Vec::new()
}
}
DesktopAction::SendToWorkspace(n) => {
if n >= self.workspaces.len() || n == self.active {
return Vec::new();
}
match self.workspaces[self.active].focused() {
Some(id) => {
self.workspaces[self.active].remove(id);
self.workspaces[n].add(id);
self.relayout()
}
None => Vec::new(),
}
}
DesktopAction::Quit => vec![BrainCommand::Shutdown],
}
}
/// Recalcula la geometría del escritorio activo y la empaqueta en un
/// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que
/// colocar.
fn relayout(&self) -> Vec<BrainCommand> {
match self.screen() {
Some(screen) => {
vec![BrainCommand::Place(placements(
&self.workspaces[self.active],
screen,
))]
}
None => Vec::new(),
}
}
// --- Accesores de sólo lectura, para el HUD de la app GPUI ---------
/// Índice del escritorio activo.
pub fn active_index(&self) -> usize {
self.active
}
/// El escritorio activo.
pub fn active_workspace(&self) -> &Workspace {
&self.workspaces[self.active]
}
/// Las salidas conectadas, en orden.
pub fn outputs(&self) -> &[(OutputId, Rect)] {
&self.outputs
}
/// Identidad de una ventana conocida.
pub fn window_info(&self, id: WindowId) -> Option<&WindowInfo> {
self.windows.get(&id)
}
/// La ventana enfocada en el escritorio activo.
pub fn focused_window(&self) -> Option<WindowId> {
self.workspaces[self.active].focused()
}
/// Cuántas ventanas hay en cada escritorio virtual.
pub fn workspace_loads(&self) -> Vec<usize> {
self.workspaces.iter().map(Workspace::len).collect()
}
}
/// El siguiente modo en el ciclo de [`DesktopAction::CycleLayout`].
fn cycle_mode(mode: LayoutMode) -> LayoutMode {
match mode {
LayoutMode::MasterStack => LayoutMode::Monocle,
LayoutMode::Monocle => LayoutMode::Grid,
LayoutMode::Grid => LayoutMode::Columns,
LayoutMode::Columns => LayoutMode::MasterStack,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Un escritorio con una salida 1920×1080 ya conectada.
fn desktop_with_screen() -> Desktop {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d
}
fn open(d: &mut Desktop, id: WindowId) -> Vec<BrainCommand> {
d.on_event(BodyEvent::WindowOpened {
id,
app_id: format!("app{id}"),
title: format!("win {id}"),
})
}
/// Extrae las colocaciones de un único `Place`.
fn places(cmds: &[BrainCommand]) -> &[mirada_protocol::WindowPlacement] {
match cmds {
[BrainCommand::Place(p)] => p,
other => panic!("se esperaba un solo Place, no {other:?}"),
}
}
#[test]
fn grab_keys_lists_the_whole_keymap() {
let d = Desktop::new();
match d.grab_keys() {
BrainCommand::GrabKeys(keys) => {
assert!(keys.contains(&"Super+j".to_string()));
assert!(keys.contains(&"Super+Shift+e".to_string()));
}
other => panic!("se esperaba GrabKeys, no {other:?}"),
}
}
#[test]
fn without_a_screen_nothing_is_placed() {
let mut d = Desktop::new();
assert!(open(&mut d, 1).is_empty());
}
#[test]
fn opening_a_window_places_it() {
let mut d = desktop_with_screen();
let cmds = open(&mut d, 1);
assert_eq!(places(&cmds).len(), 1);
assert_eq!(d.focused_window(), Some(1));
}
#[test]
fn closing_a_window_removes_it_everywhere() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
let cmds = d.on_event(BodyEvent::WindowClosed { id: 1 });
assert_eq!(places(&cmds).len(), 1);
assert!(d.window_info(1).is_none());
assert_eq!(d.focused_window(), Some(2));
}
#[test]
fn focus_keybind_cycles_within_the_active_workspace() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
assert_eq!(d.focused_window(), Some(3));
d.on_event(BodyEvent::Keybind("Super+j".into())); // next, da la vuelta
assert_eq!(d.focused_window(), Some(1));
d.on_event(BodyEvent::Keybind("Super+k".into())); // prev
assert_eq!(d.focused_window(), Some(3));
}
#[test]
fn close_focused_keybind_asks_to_close_the_focused_window() {
let mut d = desktop_with_screen();
open(&mut d, 7);
let cmds = d.on_event(BodyEvent::Keybind("Super+q".into()));
assert_eq!(cmds, vec![BrainCommand::Close(7)]);
// No se elimina hasta que el Cuerpo confirme con WindowClosed.
assert!(d.window_info(7).is_some());
}
#[test]
fn cycle_layout_walks_the_four_modes() {
let mut d = desktop_with_screen();
open(&mut d, 1);
assert_eq!(d.active_workspace().params().mode, LayoutMode::MasterStack);
for expected in [
LayoutMode::Monocle,
LayoutMode::Grid,
LayoutMode::Columns,
LayoutMode::MasterStack,
] {
d.on_event(BodyEvent::Keybind("Super+space".into()));
assert_eq!(d.active_workspace().params().mode, expected);
}
}
#[test]
fn monocle_keybind_hides_all_but_the_focused_window() {
let mut d = desktop_with_screen();
for id in [1, 2, 3] {
open(&mut d, id);
}
let cmds = d.on_event(BodyEvent::Keybind("Super+m".into()));
let visible = places(&cmds).iter().filter(|p| p.visible).count();
assert_eq!(visible, 1);
}
#[test]
fn switching_workspace_changes_what_is_placed() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2);
// Escritorio 2 (índice 1) está vacío.
let cmds = d.on_event(BodyEvent::Keybind("Super+2".into()));
assert!(places(&cmds).is_empty());
assert_eq!(d.active_index(), 1);
// Volver al 1 reaparece las dos ventanas.
let cmds = d.on_event(BodyEvent::Keybind("Super+1".into()));
assert_eq!(places(&cmds).len(), 2);
}
#[test]
fn send_to_workspace_moves_the_focused_window() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
d.on_event(BodyEvent::Keybind("Super+Shift+3".into()));
assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
assert_eq!(d.workspace_loads()[2], 1); // la 2 viajó al escritorio 3
// La ventana 2 sigue registrada — sólo cambió de escritorio.
assert!(d.window_info(2).is_some());
}
#[test]
fn pointer_focuses_a_window_in_the_active_workspace() {
let mut d = desktop_with_screen();
open(&mut d, 1);
open(&mut d, 2); // enfocada
d.on_event(BodyEvent::PointerEntered { id: 1 });
assert_eq!(d.focused_window(), Some(1));
}
#[test]
fn retitling_updates_the_registry_without_relayout() {
let mut d = desktop_with_screen();
open(&mut d, 1);
let cmds = d.on_event(BodyEvent::WindowRetitled {
id: 1,
title: "nuevo".into(),
});
assert!(cmds.is_empty());
assert_eq!(d.window_info(1).unwrap().title, "nuevo");
}
#[test]
fn an_unknown_keybind_does_nothing() {
let mut d = desktop_with_screen();
open(&mut d, 1);
assert!(d.on_event(BodyEvent::Keybind("Super+F12".into())).is_empty());
}
#[test]
fn quit_emits_a_shutdown() {
let mut d = desktop_with_screen();
assert_eq!(
d.on_event(BodyEvent::Keybind("Super+Shift+e".into())),
vec![BrainCommand::Shutdown]
);
}
#[test]
fn outputs_lay_side_by_side() {
let mut d = Desktop::new();
d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
d.on_event(BodyEvent::OutputAdded { id: 1, width: 2560, height: 1440 });
assert_eq!(d.outputs().len(), 2);
// La segunda salida arranca donde acaba la primera.
assert_eq!(d.outputs()[1].1.x, 1920);
// El teselado sigue sobre la salida primaria.
assert_eq!(d.screen().unwrap().w, 1920);
}
}
@@ -0,0 +1,25 @@
//! `mirada-brain` — el orquestador de escritorio del compositor.
//!
//! Es el "Cerebro" de mirada sin pantalla: mantiene el estado del
//! escritorio (salidas, escritorios virtuales, ventanas, foco), consume
//! los [`BodyEvent`]s que reporta el Cuerpo y produce los
//! [`BrainCommand`]s que el Cuerpo aplica.
//!
//! Es agnóstico de GPUI y de `smithay`: una app GPUI sólo lo *envuelve*
//! para pintar un HUD y para mover los bytes por el cable de
//! [`mirada_protocol`]. Toda la lógica vive aquí y es determinista —
//! la misma secuencia de eventos da siempre el mismo estado.
//!
//! - [`action`] — las acciones de escritorio y el mapa de teclas.
//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
#![forbid(unsafe_code)]
pub mod action;
pub mod desktop;
pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
pub use desktop::{Desktop, WindowInfo};
pub use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
pub use mirada_protocol::{BodyEvent, BrainCommand, OutputId, WindowPlacement};