gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

feat(mirada): multi-monitor real — cada salida tesela su escritorio

El Desktop deja de teselar sólo la salida primaria. Cada Output muestra
un escritorio virtual distinto y relayout() las tesela todas en un solo
Place que cubre todas las pantallas.

- Output { id, rect, workspace }; focused_output reemplaza al índice
  global active. active_index() = el escritorio de la salida enfocada.
- OutputAdded asigna el primer escritorio libre; OutputRemoved deja sus
  ventanas en su escritorio y reajusta el foco. reflow_outputs() las
  recoloca en fila.
- SwitchWorkspace actúa sobre la salida enfocada; si el escritorio
  pedido ya lo muestra otra salida, las intercambia (invariante: un
  escritorio se ve en una salida como mucho).
- DesktopAction::FocusOutputNext (Super+o) mueve el foco entre
  monitores. El foco del teclado es único — relayout() lo unifica a la
  ventana enfocada de la salida enfocada.

Verificado end-to-end con headless-ctl (ahora 2 salidas).
mirada-brain 52->58 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

799dcef22eea4fabcce54c7f398ffefc5e11cb9a · ruwaq sergio · (5 killa ñawpaq) · tayta be61ddb6e · kay commitpi sach'ata qhaway

Semántico sayk'uy · 34

crates/apps/mirada-ctl/src/main.rs

crates/apps/mirada/src/main.rs

crates/modules/mirada/mirada-brain/examples/headless-ctl.rs

crates/modules/mirada/mirada-brain/src/action.rs

crates/modules/mirada/mirada-brain/src/desktop.rs

crates/modules/mirada/mirada-brain/src/keymap.rs

9 qillqakuna · +258 −69

diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs
index 3185a1a..005b442 100644
--- a/crates/apps/mirada-ctl/src/main.rs
+++ b/crates/apps/mirada-ctl/src/main.rs
@@ -131,6 +131,7 @@ Acciones de mirada-ctl:
   promote-to-master          la ventana enfocada al puesto maestro
   workspace <n>              activa el escritorio n (1..9)
   send-to-workspace <n>      manda la enfocada al escritorio n
+  focus-output-next          pasa el foco al siguiente monitor
   quit                       apaga el compositor
 "
     );
diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
index 71c0476..d57103b 100644
--- a/crates/apps/mirada/src/main.rs
+++ b/crates/apps/mirada/src/main.rs
@@ -25,6 +25,7 @@
 //!   j / k        foco siguiente/anterior   , / .          nmaster −/+
 //!   Shift+j / k  mueve la enfocada         1..9           ir a escritorio
 //!   Enter        promueve a maestra        Ctrl+1..9      enviar a escritorio
+//!   o            siguiente monitor
 //! ```
 //!
 //! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
@@ -299,6 +300,7 @@ impl Mirada {
             "s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)),
             "h" => self.act(DesktopAction::ShrinkMaster),
             "l" => self.act(DesktopAction::GrowMaster),
+            "o" => self.act(DesktopAction::FocusOutputNext),
             "enter" => self.act(DesktopAction::PromoteToMaster),
             "," => self.act(DesktopAction::IncMaster),
             "." => self.act(DesktopAction::DecMaster),
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index 657de06..7760779 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -62,7 +62,8 @@ ejecuta operaciones de geometría".
 - **`mirada-brain`** — `Desktop`: salidas, 9 escritorios virtuales,
   registro de ventanas. `on_event(BodyEvent) -> Vec<BrainCommand>`;
   `DesktopAction` + `Keymap` configurable (`set_keymap` en caliente) +
-  `Rules` de ventana.
+  `Rules` de ventana. **Multi-monitor**: cada `Output` muestra un
+  escritorio; `relayout()` tesela todas las salidas en un solo `Place`.
 - **`mirada-link`** — `Link<Out,In>` sobre socket Unix; hilo lector de
   fondo + canal `mpsc` para sondeo no bloqueante. `BrainLink`/`BodyLink`,
   `connected_pair` (socketpair), `connect`/`listen` por ruta.
@@ -122,6 +123,11 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
   cubre toda la salida (sin gap), oculta al resto y se lleva el foco;
   `Workspace.fullscreen: Option<WindowId>`, y el Cuerpo le fija el estado
   `xdg_toplevel Fullscreen`.
+- **Multi-monitor** — cada `Output` muestra un escritorio distinto;
+  `SwitchWorkspace` actúa sobre la salida enfocada (y la intercambia si
+  el escritorio pedido ya lo muestra otra salida); `FocusOutputNext`
+  (`Super+o`) mueve el foco entre monitores. El foco del teclado es
+  único — sólo la ventana enfocada de la salida enfocada.
 - **Layout y área maestra por el API** — los 7 modos se intercambian
   (`SetLayout`/`CycleLayout`, `mirada-ctl layout spiral`); el área
   maestra se redimensiona (`grow`/`shrink-master`, `Super+l`/`Super+h`);
@@ -164,7 +170,7 @@ a las ya abiertas.
 ## Estado
 
 Implementado y verde: `mirada-layout` (32 tests), `mirada-protocol`
-(11), `mirada-brain` (52), `mirada-link` (7), `mirada-body` (14), las
+(11), `mirada-brain` (58), `mirada-link` (7), `mirada-body` (14), las
 apps `mirada` y `mirada-compositor` (compilan; verificación visual
 manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`).
 
diff --git a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
index 3129bf0..68fb788 100644
--- a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
+++ b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
@@ -29,9 +29,10 @@ fn main() {
     };
     eprintln!("Cerebro headless · control en {}", path.display());
 
-    // Una pantalla y tres ventanas de muestra.
+    // Dos pantallas y tres ventanas de muestra.
     let mut desktop = Desktop::new();
     desktop.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+    desktop.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
     for id in 1..=3 {
         desktop.on_event(BodyEvent::WindowOpened {
             id,
@@ -93,10 +94,21 @@ fn main() {
 fn print_state(d: &Desktop) {
     let ws = d.active_workspace();
     eprintln!(
-        "  escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
+        "  activo: escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
         d.active_index() + 1,
         ws.params().mode,
         ws.params().master_ratio * 100.0,
         d.focused_window(),
     );
+    for (i, o) in d.outputs().iter().enumerate() {
+        let mark = if i == d.focused_output() { '*' } else { ' ' };
+        eprintln!(
+            "  {mark} salida {} {}×{} @ x{} → escritorio {}",
+            o.id,
+            o.rect.w,
+            o.rect.h,
+            o.rect.x,
+            o.workspace + 1,
+        );
+    }
 }
diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs
index 819b36b..fd6445e 100644
--- a/crates/modules/mirada/mirada-brain/src/action.rs
+++ b/crates/modules/mirada/mirada-brain/src/action.rs
@@ -60,6 +60,8 @@ pub enum DesktopAction {
     SwitchWorkspace(usize),
     /// Manda la ventana enfocada al escritorio virtual `n`.
     SendToWorkspace(usize),
+    /// Mueve el foco a la siguiente salida (monitor).
+    FocusOutputNext,
     /// Apaga el compositor.
     Quit,
 }
@@ -114,6 +116,7 @@ impl fmt::Display for DesktopAction {
             // Los escritorios se numeran 1-based de cara al usuario.
             DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
             DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
+            DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
             DesktopAction::Quit => f.write_str("quit"),
         }
     }
@@ -139,6 +142,7 @@ impl FromStr for DesktopAction {
             "inc-master" => Self::IncMaster,
             "dec-master" => Self::DecMaster,
             "promote-to-master" => Self::PromoteToMaster,
+            "focus-output-next" => Self::FocusOutputNext,
             "quit" => Self::Quit,
             _ => {
                 if let Some(slug) = s.strip_prefix("layout:") {
@@ -203,6 +207,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> {
         ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
         ("Super+h".into(), DesktopAction::ShrinkMaster),
         ("Super+l".into(), DesktopAction::GrowMaster),
+        ("Super+o".into(), DesktopAction::FocusOutputNext),
         ("Super+Return".into(), DesktopAction::PromoteToMaster),
         ("Super+,".into(), DesktopAction::IncMaster),
         ("Super+.".into(), DesktopAction::DecMaster),
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index b7d5a6d..23407a8 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -16,22 +16,34 @@ pub struct WindowInfo {
     pub title: String,
 }
 
+/// Una salida física y el escritorio virtual que muestra ahora mismo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Output {
+    pub id: OutputId,
+    /// Rectángulo en el espacio global — las salidas van en fila horizontal.
+    pub rect: Rect,
+    /// Índice del escritorio que esta salida muestra.
+    pub workspace: usize,
+}
+
 /// 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
+/// virtuales, el registro de ventanas, el keymap y las reglas. 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.
+/// **Multi-monitor**: cada salida muestra un escritorio distinto; el
+/// teselado se calcula para todas y el `Place` resultante las cubre. Un
+/// escritorio se ve en una salida como mucho — pedir uno que ya muestra
+/// otra salida las intercambia.
 pub struct Desktop {
     /// Salidas físicas, en fila horizontal y en orden de aparición.
-    outputs: Vec<(OutputId, Rect)>,
+    outputs: Vec<Output>,
     /// Escritorios virtuales — `WORKSPACE_COUNT` fijos.
     workspaces: Vec<Workspace>,
-    /// Índice del escritorio activo.
-    active: usize,
+    /// Índice (en `outputs`) de la salida con el foco.
+    focused_output: usize,
     /// Identidad de cada ventana conocida.
     windows: HashMap<WindowId, WindowInfo>,
     /// Atajos globales → acción. Configurable, recargable en caliente.
@@ -62,7 +74,7 @@ impl Desktop {
         Self {
             outputs: Vec::new(),
             workspaces,
-            active: 0,
+            focused_output: 0,
             windows: HashMap::new(),
             keymap,
             rules: Rules::default(),
@@ -93,9 +105,9 @@ impl Desktop {
         &self.keymap
     }
 
-    /// Geometría de la salida primaria, si hay alguna conectada.
+    /// Geometría de la salida enfocada, si hay alguna conectada.
     pub fn screen(&self) -> Option<Rect> {
-        self.outputs.first().map(|(_, r)| *r)
+        self.outputs.get(self.focused_output).map(|o| o.rect)
     }
 
     /// Procesa un evento del Cuerpo: muta el estado y devuelve los
@@ -103,13 +115,26 @@ impl Desktop {
     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)));
+                // La salida nueva muestra el primer escritorio que no
+                // muestre ya otra salida.
+                let taken: Vec<usize> = self.outputs.iter().map(|o| o.workspace).collect();
+                let workspace = (0..self.workspaces.len())
+                    .find(|n| !taken.contains(n))
+                    .unwrap_or(0);
+                self.outputs.push(Output {
+                    id,
+                    rect: Rect::new(0, 0, width, height),
+                    workspace,
+                });
+                self.reflow_outputs();
                 self.relayout()
             }
             BodyEvent::OutputRemoved { id } => {
-                self.outputs.retain(|(o, _)| *o != id);
+                self.outputs.retain(|o| o.id != id);
+                if self.focused_output >= self.outputs.len() {
+                    self.focused_output = self.outputs.len().saturating_sub(1);
+                }
+                self.reflow_outputs();
                 self.relayout()
             }
             BodyEvent::WindowOpened { id, app_id, title } => {
@@ -119,7 +144,7 @@ impl Desktop {
                 let ws = outcome
                     .workspace
                     .filter(|&n| n < self.workspaces.len())
-                    .unwrap_or(self.active);
+                    .unwrap_or(self.active_index());
                 self.workspaces[ws].add(id);
                 if outcome.floating {
                     let rect = self
@@ -147,7 +172,8 @@ impl Desktop {
             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) {
+                let active = self.active_index();
+                if self.workspaces[active].focus_window(id) {
                     self.relayout()
                 } else {
                     Vec::new()
@@ -163,51 +189,52 @@ impl Desktop {
     /// 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> {
+        let active = self.active_index();
         match action {
             DesktopAction::FocusNext => {
-                self.workspaces[self.active].focus_next();
+                self.workspaces[active].focus_next();
                 self.relayout()
             }
             DesktopAction::FocusPrev => {
-                self.workspaces[self.active].focus_prev();
+                self.workspaces[active].focus_prev();
                 self.relayout()
             }
             DesktopAction::FocusWindow(id) => {
                 // En el escritorio activo basta enfocar; si la ventana
-                // está en otro, saltamos a ese escritorio.
-                if self.workspaces[self.active].focus_window(id) {
+                // está en otro, lo traemos a la salida enfocada.
+                if self.workspaces[active].focus_window(id) {
                     return self.relayout();
                 }
                 for n in 0..self.workspaces.len() {
-                    if n != self.active && self.workspaces[n].focus_window(id) {
-                        self.active = n;
+                    if n != active && self.workspaces[n].focus_window(id) {
+                        self.show_workspace(n);
                         return self.relayout();
                     }
                 }
                 Vec::new()
             }
             DesktopAction::MoveForward => {
-                self.workspaces[self.active].move_focused_forward();
+                self.workspaces[active].move_focused_forward();
                 self.relayout()
             }
             DesktopAction::MoveBackward => {
-                self.workspaces[self.active].move_focused_backward();
+                self.workspaces[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() {
+                match self.workspaces[active].focused() {
                     Some(id) => vec![BrainCommand::Close(id)],
                     None => Vec::new(),
                 }
             }
             DesktopAction::ToggleFloat => {
-                let Some(id) = self.workspaces[self.active].focused() else {
+                let Some(id) = self.workspaces[active].focused() else {
                     return Vec::new();
                 };
                 let screen = self.screen();
-                let ws = &mut self.workspaces[self.active];
+                let ws = &mut self.workspaces[active];
                 if ws.is_floating(id) {
                     ws.set_floating(id, None);
                 } else {
@@ -219,10 +246,10 @@ impl Desktop {
                 self.relayout()
             }
             DesktopAction::ToggleFullscreen => {
-                let Some(id) = self.workspaces[self.active].focused() else {
+                let Some(id) = self.workspaces[active].focused() else {
                     return Vec::new();
                 };
-                let ws = &mut self.workspaces[self.active];
+                let ws = &mut self.workspaces[active];
                 if ws.fullscreen() == Some(id) {
                     ws.set_fullscreen(None);
                 } else {
@@ -231,12 +258,12 @@ impl Desktop {
                 self.relayout()
             }
             DesktopAction::CycleLayout => {
-                let next = self.workspaces[self.active].params().mode.next();
-                self.workspaces[self.active].set_mode(next);
+                let next = self.workspaces[active].params().mode.next();
+                self.workspaces[active].set_mode(next);
                 self.relayout()
             }
             DesktopAction::SetLayout(mode) => {
-                self.workspaces[self.active].set_mode(mode);
+                self.workspaces[active].set_mode(mode);
                 self.relayout()
             }
             DesktopAction::GrowMaster => self.nudge_master(0.05),
@@ -244,38 +271,83 @@ impl Desktop {
             DesktopAction::IncMaster => self.nudge_master_count(1),
             DesktopAction::DecMaster => self.nudge_master_count(-1),
             DesktopAction::PromoteToMaster => {
-                self.workspaces[self.active].promote_focused();
+                self.workspaces[active].promote_focused();
                 self.relayout()
             }
             DesktopAction::SwitchWorkspace(n) => {
-                if n < self.workspaces.len() && n != self.active {
-                    self.active = n;
+                if n < self.workspaces.len() && n != active {
+                    self.show_workspace(n);
                     self.relayout()
                 } else {
                     Vec::new()
                 }
             }
             DesktopAction::SendToWorkspace(n) => {
-                if n >= self.workspaces.len() || n == self.active {
+                if n >= self.workspaces.len() || n == active {
                     return Vec::new();
                 }
-                match self.workspaces[self.active].focused() {
+                match self.workspaces[active].focused() {
                     Some(id) => {
-                        self.workspaces[self.active].remove(id);
+                        self.workspaces[active].remove(id);
                         self.workspaces[n].add(id);
                         self.relayout()
                     }
                     None => Vec::new(),
                 }
             }
+            DesktopAction::FocusOutputNext => {
+                if self.outputs.len() > 1 {
+                    self.focused_output = (self.focused_output + 1) % self.outputs.len();
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
             DesktopAction::Quit => vec![BrainCommand::Shutdown],
         }
     }
 
+    /// El índice del escritorio activo — el que muestra la salida
+    /// enfocada. `0` si todavía no hay ninguna salida.
+    pub fn active_index(&self) -> usize {
+        self.outputs
+            .get(self.focused_output)
+            .map(|o| o.workspace)
+            .unwrap_or(0)
+    }
+
+    /// Hace que la salida enfocada muestre el escritorio `n`. Si otra
+    /// salida ya lo mostraba, intercambian — así ningún escritorio se
+    /// ve en dos sitios a la vez.
+    fn show_workspace(&mut self, n: usize) {
+        if n >= self.workspaces.len() || self.focused_output >= self.outputs.len() {
+            return;
+        }
+        let current = self.outputs[self.focused_output].workspace;
+        if current == n {
+            return;
+        }
+        if let Some(other) = self.outputs.iter().position(|o| o.workspace == n) {
+            self.outputs[other].workspace = current;
+        }
+        self.outputs[self.focused_output].workspace = n;
+    }
+
+    /// Recoloca las salidas en fila horizontal, en su orden de aparición.
+    fn reflow_outputs(&mut self) {
+        let mut x = 0;
+        for o in &mut self.outputs {
+            o.rect.x = x;
+            o.rect.y = 0;
+            x += o.rect.w;
+        }
+    }
+
     /// Ajusta la fracción del área maestra del escritorio activo (la usan
     /// `MasterStack` y `CenteredMaster`), acotada a `0.05..=0.95`.
     fn nudge_master(&mut self, delta: f32) -> Vec<BrainCommand> {
-        let ws = &mut self.workspaces[self.active];
+        let active = self.active_index();
+        let ws = &mut self.workspaces[active];
         let ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
         ws.set_master_ratio(ratio);
         self.relayout()
@@ -283,52 +355,61 @@ impl Desktop {
 
     /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`.
     fn nudge_master_count(&mut self, delta: i32) -> Vec<BrainCommand> {
-        let ws = &mut self.workspaces[self.active];
+        let active = self.active_index();
+        let ws = &mut self.workspaces[active];
         let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize;
         ws.set_master_count(n);
         self.relayout()
     }
 
-    /// Recalcula la geometría del escritorio activo y la empaqueta en un
-    /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que
+    /// Recalcula la geometría de **todas** las salidas y la empaqueta en
+    /// un único [`BrainCommand::Place`]. Sin salidas, 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(),
+        if self.outputs.is_empty() {
+            return Vec::new();
+        }
+        let mut all = Vec::new();
+        for o in &self.outputs {
+            all.extend(placements(&self.workspaces[o.workspace], o.rect));
         }
+        // El foco del teclado es único: sólo la ventana enfocada de la
+        // salida enfocada. `placements` marca el foco por escritorio (lo
+        // necesita para la visibilidad en `Monocle`); aquí lo unificamos.
+        let global_focus = self.focused_window();
+        for p in &mut all {
+            p.focused = Some(p.id) == global_focus;
+        }
+        vec![BrainCommand::Place(all)]
     }
 
     // --- 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.
+    /// El escritorio activo — el de la salida enfocada.
     pub fn active_workspace(&self) -> &Workspace {
-        &self.workspaces[self.active]
+        &self.workspaces[self.active_index()]
     }
 
-    /// Las salidas conectadas, en orden.
-    pub fn outputs(&self) -> &[(OutputId, Rect)] {
+    /// Las salidas conectadas, en orden, con el escritorio que muestran.
+    pub fn outputs(&self) -> &[Output] {
         &self.outputs
     }
 
+    /// Índice (en [`outputs`](Desktop::outputs)) de la salida enfocada.
+    pub fn focused_output(&self) -> usize {
+        self.focused_output
+    }
+
     /// 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.
+    /// La ventana con el foco del teclado: la enfocada del escritorio
+    /// activo — o su ventana en pantalla completa, si la hay.
     pub fn focused_window(&self) -> Option<WindowId> {
-        self.workspaces[self.active].focused()
+        let ws = &self.workspaces[self.active_index()];
+        ws.fullscreen().or_else(|| ws.focused())
     }
 
     /// Cuántas ventanas hay en cada escritorio virtual.
@@ -339,6 +420,7 @@ impl Desktop {
     /// Una vista de todas las ventanas conocidas, en todos los
     /// escritorios — la base de `mirada-ctl windows` y de una taskbar.
     pub fn window_lines(&self) -> Vec<crate::ctl::WindowLine> {
+        let active = self.active_index();
         let mut lines = Vec::new();
         for (n, ws) in self.workspaces.iter().enumerate() {
             let ws_focus = ws.focused();
@@ -349,7 +431,7 @@ impl Desktop {
                     app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
                     title: info.map(|i| i.title.clone()).unwrap_or_default(),
                     workspace: n + 1,
-                    focused: n == self.active && ws_focus == Some(id),
+                    focused: n == active && ws_focus == Some(id),
                 });
             }
         }
@@ -709,6 +791,16 @@ mod tests {
         );
     }
 
+    // --- Multi-monitor -------------------------------------------------
+
+    /// Un escritorio con dos salidas 1920×1080.
+    fn desktop_with_two_outputs() -> Desktop {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
+        d
+    }
+
     #[test]
     fn outputs_lay_side_by_side() {
         let mut d = Desktop::new();
@@ -716,8 +808,69 @@ mod tests {
         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);
+        assert_eq!(d.outputs()[1].rect.x, 1920);
+    }
+
+    #[test]
+    fn each_output_shows_a_distinct_workspace() {
+        let d = desktop_with_two_outputs();
+        assert_eq!(d.outputs()[0].workspace, 0);
+        assert_eq!(d.outputs()[1].workspace, 1);
+    }
+
+    #[test]
+    fn switching_to_a_workspace_shown_on_another_output_swaps_them() {
+        let mut d = desktop_with_two_outputs();
+        // La salida enfocada (0, ws 0) pide el ws 1, que muestra la 1 → swap.
+        d.apply(DesktopAction::SwitchWorkspace(1));
+        assert_eq!(d.outputs()[0].workspace, 1);
+        assert_eq!(d.outputs()[1].workspace, 0);
+    }
+
+    #[test]
+    fn focus_output_next_moves_the_focus_between_outputs() {
+        let mut d = desktop_with_two_outputs();
+        assert_eq!(d.active_index(), 0); // salida 0 → ws 0
+        d.apply(DesktopAction::FocusOutputNext);
+        assert_eq!(d.active_index(), 1); // salida 1 → ws 1
+        d.apply(DesktopAction::FocusOutputNext); // envuelve
+        assert_eq!(d.active_index(), 0);
+    }
+
+    #[test]
+    fn relayout_places_windows_on_every_output() {
+        let mut d = Desktop::new();
+        d.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
+        d.on_event(BodyEvent::OutputAdded { id: 1, width: 1280, height: 720 });
+        open(&mut d, 1); // en la salida 0 (ws 0)
+        d.apply(DesktopAction::FocusOutputNext);
+        let cmds = open(&mut d, 2); // en la salida 1 (ws 1)
+        let p = places(&cmds);
+        assert_eq!(p.len(), 2);
+        // Cada ventana cae en el rectángulo de su salida.
+        assert_eq!(p.iter().find(|x| x.id == 1).unwrap().rect.x, 0);
+        assert_eq!(p.iter().find(|x| x.id == 2).unwrap().rect.x, 1920);
+    }
+
+    #[test]
+    fn keyboard_focus_is_unique_across_outputs() {
+        let mut d = desktop_with_two_outputs();
+        open(&mut d, 1);
+        d.apply(DesktopAction::FocusOutputNext);
+        let cmds = open(&mut d, 2);
+        // Sólo una ventana con foco de teclado en todo el Place.
+        assert_eq!(places(&cmds).iter().filter(|p| p.focused).count(), 1);
+    }
+
+    #[test]
+    fn removing_an_output_keeps_its_windows_in_their_workspace() {
+        let mut d = desktop_with_two_outputs();
+        d.apply(DesktopAction::FocusOutputNext); // foco en la salida 1 (ws 1)
+        open(&mut d, 1); // en ws 1
+        d.on_event(BodyEvent::OutputRemoved { id: 1 });
+        // La ventana sigue registrada, en el ws 1.
+        assert!(d.window_info(1).is_some());
+        assert_eq!(d.workspace_loads()[1], 1);
+        assert_eq!(d.outputs().len(), 1);
     }
 }
diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs
index f59985e..8318cb2 100644
--- a/crates/modules/mirada/mirada-brain/src/keymap.rs
+++ b/crates/modules/mirada/mirada-brain/src/keymap.rs
@@ -245,6 +245,7 @@ const KEYMAP_HEADER: &str = "\
 //   grow-master / shrink-master      redimensiona el área maestra
 //   inc-master / dec-master          nº de ventanas maestras (nmaster)
 //   promote-to-master                la enfocada al puesto maestro
+//   focus-output-next                pasa el foco al siguiente monitor
 //   workspace:N                      activa el escritorio N (1..9)
 //   send-to-workspace:N              manda la enfocada al escritorio N
 //   quit                             apaga el compositor
diff --git a/crates/modules/mirada/mirada-brain/src/lib.rs b/crates/modules/mirada/mirada-brain/src/lib.rs
index f467ea8..8dfa252 100644
--- a/crates/modules/mirada/mirada-brain/src/lib.rs
+++ b/crates/modules/mirada/mirada-brain/src/lib.rs
@@ -26,7 +26,7 @@ pub mod rules;
 
 pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
 pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
-pub use desktop::{Desktop, WindowInfo};
+pub use desktop::{Desktop, Output, WindowInfo};
 pub use keymap::{Keymap, KeymapError, KeymapWatch};
 pub use rules::{Rule, RuleOutcome, Rules};
 
diff --git a/vamos.txt b/vamos.txt
index 5f02e12..8a240bb 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1073,5 +1073,14 @@
 
 
 
+  Multi-monitor real — el Desktop tesela todas las salidas:
+  Cada Output muestra un escritorio distinto; relayout() las tesela todas en un solo Place.
+  Pedir un escritorio que ya muestra otra salida → las intercambia (ningún escritorio se ve 2 veces).
+  mirada-ctl focus-output-next   # Super+o — mueve el foco al siguiente monitor
+  El foco del teclado es único: sólo la ventana enfocada de la salida enfocada.
+  cargo run -p mirada-brain --example headless-ctl   # ahora levanta 2 salidas
+
+
+