tawasuyu

tawasuyu/brahman

Rust 226TOML 67JSON 14HTML 12860 Definiciones

feat(mirada): 3 layouts nuevos + redimensionar el área maestra

mirada-layout pasa de 4 a 7 modos de teselado, todos intercambiables
por el API (SetLayout / CycleLayout / mirada-ctl layout <modo>):

- Rows: filas horizontales de igual alto (complemento de Columns).
- Spiral: espiral de Fibonacci — cada ventana parte por la mitad el
  espacio restante, alternando el sentido del corte.
- CenteredMaster: maestra centrada + pila a ambos lados (monitores
  anchos).

LayoutMode::ALL + next() definen el ciclo. Añade dos acciones,
GrowMaster/ShrinkMaster (Super+l / Super+h), que ajustan master_ratio
en caliente — ese parámetro existía pero no había forma de tocarlo.

Cableado completo: tile(), cycle, slugs Display/FromStr, keymap por
defecto (Super+r/d/s), HUD de mirada, mirada-ctl actions. El ejemplo
headless-ctl ahora imprime la geometría para verificar los layouts.

mirada-layout 22->26 tests, mirada-brain 37->39.

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

8821d34bd59227892ae42b95d09409d0467ae125 · por sergio · (hace 5 meses) · padre b31f98883 · ver el árbol en este commit

Delta semántico · 32

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

crates/modules/mirada/mirada-layout/src/layout.rs

10 archivos · +293 −68

diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md
index f3a0b9e..3169dc6 100644
--- a/crates/apps/mirada-compositor/README.md
+++ b/crates/apps/mirada-compositor/README.md
@@ -61,8 +61,9 @@ WAYLAND_DISPLAY=wayland-1 foot      # o weston-terminal, alacritty, …
 
 Las ventanas se teselan solas. El teclado, con la ventana del compositor
 enfocada, maneja el escritorio con atajos `Super+…`: foco `Super+j/k`,
-ciclar layout `Super+space`, escritorios `Super+1..9`, cerrar `Super+q`.
-Cierra la ventana del compositor para salir.
+los 7 layouts en `Super+t/m/g/c/r/d/s` (o ciclar con `Super+space`), área
+maestra `Super+h/l`, escritorios `Super+1..9`, cerrar `Super+q`. Cierra
+la ventana del compositor para salir.
 
 ## Atajos de teclado
 
diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs
index 572ea59..9ebc18e 100644
--- a/crates/apps/mirada-ctl/src/main.rs
+++ b/crates/apps/mirada-ctl/src/main.rs
@@ -109,18 +109,25 @@ fn print_help() {
 }
 
 fn print_actions() {
-    println!(
-        "Acciones de mirada-ctl:\n  \
-           focus-next                 mueve el foco a la siguiente ventana\n  \
-           focus-prev                 mueve el foco a la anterior\n  \
-           focus-window <id>          enfoca la ventana <id>  (ver: mirada-ctl windows)\n  \
-           move-forward               adelanta la ventana enfocada en el teselado\n  \
-           move-backward              la atrasa\n  \
-           close-focused              cierra la ventana enfocada\n  \
-           cycle-layout               pasa al siguiente modo de teselado\n  \
-           layout <modo>              master-stack | monocle | grid | columns\n  \
-           workspace <n>              activa el escritorio n (1..9)\n  \
-           send-to-workspace <n>      manda la enfocada al escritorio n\n  \
-           quit                       apaga el compositor"
+    // Cadena multilínea literal: la indentación de cada línea es la que
+    // se imprime (el `\` tras la comilla se come sólo el primer salto).
+    print!(
+        "\
+Acciones de mirada-ctl:
+  focus-next                 mueve el foco a la siguiente ventana
+  focus-prev                 mueve el foco a la anterior
+  focus-window <id>          enfoca la ventana <id>  (ver: mirada-ctl windows)
+  move-forward               adelanta la ventana enfocada en el teselado
+  move-backward              la atrasa
+  close-focused              cierra la ventana enfocada
+  cycle-layout               pasa al siguiente modo de teselado
+  layout <modo>              master-stack · centered-master · spiral
+                             grid · columns · rows · monocle
+  grow-master                agranda el área de la ventana maestra
+  shrink-master              la encoge
+  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/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
index 36982a7..7fc3e7c 100644
--- a/crates/apps/mirada/src/main.rs
+++ b/crates/apps/mirada/src/main.rs
@@ -20,9 +20,10 @@
 //!
 //! ```text
 //!   n            abre una ventana          tab / espacio  cicla layout
-//!   w            cierra la enfocada        t g c m        layout directo
-//!   j / k        foco siguiente/anterior   1..9           ir a escritorio
-//!   Shift+j / k  mueve la enfocada         Ctrl+1..9      enviar a escritorio
+//!   w            cierra la enfocada        t m g c r d s  layout directo
+//!   j / k        foco siguiente/anterior   h / l          área maestra −/+
+//!   Shift+j / k  mueve la enfocada         1..9           ir a escritorio
+//!                                          Ctrl+1..9      enviar a escritorio
 //! ```
 //!
 //! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
@@ -285,6 +286,11 @@ impl Mirada {
             "m" => self.act(DesktopAction::SetLayout(LayoutMode::Monocle)),
             "g" => self.act(DesktopAction::SetLayout(LayoutMode::Grid)),
             "c" => self.act(DesktopAction::SetLayout(LayoutMode::Columns)),
+            "r" => self.act(DesktopAction::SetLayout(LayoutMode::Rows)),
+            "d" => self.act(DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+            "s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)),
+            "h" => self.act(DesktopAction::ShrinkMaster),
+            "l" => self.act(DesktopAction::GrowMaster),
             d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => {
                 let n = (d.as_bytes()[0] - b'1') as usize;
                 if ctrl {
@@ -312,6 +318,9 @@ fn mode_name(m: LayoutMode) -> &'static str {
         LayoutMode::Monocle => "monóculo",
         LayoutMode::Grid => "rejilla",
         LayoutMode::Columns => "columnas",
+        LayoutMode::Rows => "filas",
+        LayoutMode::CenteredMaster => "maestro centrado",
+        LayoutMode::Spiral => "espiral",
     }
 }
 
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index efc03b6..10760f1 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -49,8 +49,10 @@ ejecuta operaciones de geometría".
 ## Detalle por crate
 
 - **`mirada-layout`** — `Rect` + `split` (reparto exacto de píxeles),
-  `LayoutMode` (`MasterStack`/`Monocle`/`Grid`/`Columns`), `Workspace`
-  con foco cíclico y reordenado. Determinista.
+  `LayoutMode` con 7 modos (`MasterStack`, `CenteredMaster`, `Spiral`
+  —espiral de Fibonacci—, `Grid`, `Columns`, `Rows`, `Monocle`) y
+  `LayoutMode::next()` para el ciclo, `Workspace` con foco cíclico y
+  reordenado. Determinista.
 - **`mirada-protocol`** — `WindowPlacement`, los enums `BrainCommand` y
   `BodyEvent`, el marco `postcard` con prefijo `u32` LE
   (`write_frame`/`read_frame`, guard `MAX_FRAME`) y el puente
@@ -109,6 +111,10 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
 - **`DesktopAction::FocusWindow(WindowId)`** — direccionamiento directo de
   una ventana (no sólo ciclar con `FocusNext`/`Prev`); si está en otro
   escritorio, salta a él. Lo usan la taskbar y `mirada-ctl`.
+- **`SetLayout`/`CycleLayout`/`GrowMaster`/`ShrinkMaster`** — los 7 modos
+  de teselado se intercambian por el API (`mirada-ctl layout spiral`), y
+  el área maestra se redimensiona en caliente (`grow`/`shrink-master`,
+  atajos `Super+l`/`Super+h`).
 - **HUD interactivo** (app `mirada`) — los pips de escritorio y las
   ventanas del lienzo son clicables: clic = `apply` de la acción.
 - **`mirada-ctl`** — control externo por línea de comandos
@@ -130,8 +136,8 @@ gráficos para ejercitar `mirada-ctl` en modo desatendido.
 
 ## Estado
 
-Implementado y verde: `mirada-layout` (22 tests), `mirada-protocol`
-(9), `mirada-brain` (37), `mirada-link` (7), `mirada-body` (13), las
+Implementado y verde: `mirada-layout` (26 tests), `mirada-protocol`
+(9), `mirada-brain` (39), `mirada-link` (7), `mirada-body` (13), 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 9848ad8..77240d9 100644
--- a/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
+++ b/crates/modules/mirada/mirada-brain/examples/headless-ctl.rs
@@ -27,7 +27,7 @@ fn main() {
             std::process::exit(1);
         }
     };
-    println!("Cerebro headless · control en {}", path.display());
+    eprintln!("Cerebro headless · control en {}", path.display());
 
     // Una pantalla y tres ventanas de muestra.
     let mut desktop = Desktop::new();
@@ -40,21 +40,37 @@ fn main() {
         });
     }
     print_state(&desktop);
-    println!("   esperando a mirada-ctl …");
+    eprintln!("   esperando a mirada-ctl …");
 
     loop {
         if let Some(mut conn) = server.poll() {
             if let Ok(Some(req)) = conn.read_request() {
                 let reply = match req {
                     CtlRequest::Do(action) => {
-                        let cmds = desktop.apply(action);
-                        // Sin Cuerpo: simulamos nosotros el cierre.
-                        for cmd in cmds {
-                            if let BrainCommand::Close(id) | BrainCommand::Kill(id) = cmd {
-                                desktop.on_event(BodyEvent::WindowClosed { id });
+                        eprintln!("· {action}");
+                        for cmd in desktop.apply(action) {
+                            match cmd {
+                                // La geometría que el Cerebro mandaría al Cuerpo.
+                                BrainCommand::Place(places) => {
+                                    for p in places {
+                                        eprintln!(
+                                            "    win {} → {:>5}×{:<4} @ ({:>5},{:>4}){}",
+                                            p.id,
+                                            p.rect.w,
+                                            p.rect.h,
+                                            p.rect.x,
+                                            p.rect.y,
+                                            if p.focused { "  *" } else { "" },
+                                        );
+                                    }
+                                }
+                                // Sin Cuerpo: simulamos nosotros el cierre.
+                                BrainCommand::Close(id) | BrainCommand::Kill(id) => {
+                                    desktop.on_event(BodyEvent::WindowClosed { id });
+                                }
+                                _ => {}
                             }
                         }
-                        println!("· {action}");
                         print_state(&desktop);
                         CtlReply::Ok
                     }
@@ -68,10 +84,12 @@ fn main() {
 }
 
 fn print_state(d: &Desktop) {
-    println!(
-        "  escritorio {} · foco {:?} · ventanas/escritorio {:?}",
+    let ws = d.active_workspace();
+    eprintln!(
+        "  escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
         d.active_index() + 1,
+        ws.params().mode,
+        ws.params().master_ratio * 100.0,
         d.focused_window(),
-        d.workspace_loads(),
     );
 }
diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs
index 4d8295b..fe4e3c6 100644
--- a/crates/modules/mirada/mirada-brain/src/action.rs
+++ b/crates/modules/mirada/mirada-brain/src/action.rs
@@ -42,6 +42,10 @@ pub enum DesktopAction {
     CycleLayout,
     /// Fija un modo de teselado concreto.
     SetLayout(LayoutMode),
+    /// Agranda el área de la ventana maestra (`MasterStack`/`CenteredMaster`).
+    GrowMaster,
+    /// Encoge el área de la ventana maestra.
+    ShrinkMaster,
     /// Activa el escritorio virtual `n` (índice 0-based).
     SwitchWorkspace(usize),
     /// Manda la ventana enfocada al escritorio virtual `n`.
@@ -58,6 +62,9 @@ fn layout_slug(mode: LayoutMode) -> &'static str {
         LayoutMode::Monocle => "monocle",
         LayoutMode::Grid => "grid",
         LayoutMode::Columns => "columns",
+        LayoutMode::Rows => "rows",
+        LayoutMode::CenteredMaster => "centered-master",
+        LayoutMode::Spiral => "spiral",
     }
 }
 
@@ -68,6 +75,9 @@ fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
         "monocle" => LayoutMode::Monocle,
         "grid" => LayoutMode::Grid,
         "columns" => LayoutMode::Columns,
+        "rows" => LayoutMode::Rows,
+        "centered-master" => LayoutMode::CenteredMaster,
+        "spiral" => LayoutMode::Spiral,
         _ => return None,
     })
 }
@@ -84,6 +94,8 @@ impl fmt::Display for DesktopAction {
             DesktopAction::CloseFocused => f.write_str("close-focused"),
             DesktopAction::CycleLayout => f.write_str("cycle-layout"),
             DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
+            DesktopAction::GrowMaster => f.write_str("grow-master"),
+            DesktopAction::ShrinkMaster => f.write_str("shrink-master"),
             // 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),
@@ -105,6 +117,8 @@ impl FromStr for DesktopAction {
             "move-backward" => Self::MoveBackward,
             "close-focused" => Self::CloseFocused,
             "cycle-layout" => Self::CycleLayout,
+            "grow-master" => Self::GrowMaster,
+            "shrink-master" => Self::ShrinkMaster,
             "quit" => Self::Quit,
             _ => {
                 if let Some(slug) = s.strip_prefix("layout:") {
@@ -162,6 +176,11 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> {
         ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
         ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
         ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
+        ("Super+r".into(), DesktopAction::SetLayout(LayoutMode::Rows)),
+        ("Super+d".into(), DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
+        ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
+        ("Super+h".into(), DesktopAction::ShrinkMaster),
+        ("Super+l".into(), DesktopAction::GrowMaster),
         ("Super+Shift+e".into(), DesktopAction::Quit),
     ];
     // Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
@@ -214,12 +233,7 @@ mod tests {
 
     #[test]
     fn every_layout_mode_round_trips() {
-        for mode in [
-            LayoutMode::MasterStack,
-            LayoutMode::Monocle,
-            LayoutMode::Grid,
-            LayoutMode::Columns,
-        ] {
+        for mode in LayoutMode::ALL {
             let a = DesktopAction::SetLayout(mode);
             assert_eq!(a, a.to_string().parse().unwrap());
         }
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index 2d91921..48a13e1 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -2,7 +2,7 @@
 
 use std::collections::HashMap;
 
-use mirada_layout::{LayoutMode, LayoutParams, Rect, WindowId, Workspace};
+use mirada_layout::{LayoutParams, Rect, WindowId, Workspace};
 use mirada_protocol::{placements, BodyEvent, BrainCommand, OutputId};
 
 use crate::action::{DesktopAction, WORKSPACE_COUNT};
@@ -180,7 +180,7 @@ impl Desktop {
                 }
             }
             DesktopAction::CycleLayout => {
-                let next = cycle_mode(self.workspaces[self.active].params().mode);
+                let next = self.workspaces[self.active].params().mode.next();
                 self.workspaces[self.active].set_mode(next);
                 self.relayout()
             }
@@ -188,6 +188,8 @@ impl Desktop {
                 self.workspaces[self.active].set_mode(mode);
                 self.relayout()
             }
+            DesktopAction::GrowMaster => self.nudge_master(0.05),
+            DesktopAction::ShrinkMaster => self.nudge_master(-0.05),
             DesktopAction::SwitchWorkspace(n) => {
                 if n < self.workspaces.len() && n != self.active {
                     self.active = n;
@@ -213,6 +215,15 @@ impl Desktop {
         }
     }
 
+    /// 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 ratio = (ws.params().master_ratio + delta).clamp(0.05, 0.95);
+        ws.set_master_ratio(ratio);
+        self.relayout()
+    }
+
     /// Recalcula la geometría del escritorio activo y la empaqueta en un
     /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que
     /// colocar.
@@ -281,19 +292,10 @@ impl Desktop {
     }
 }
 
-/// 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::*;
+    use mirada_layout::LayoutMode;
 
     /// Un escritorio con una salida 1920×1080 ya conectada.
     fn desktop_with_screen() -> Desktop {
@@ -442,19 +444,42 @@ mod tests {
     }
 
     #[test]
-    fn cycle_layout_walks_the_four_modes() {
+    fn cycle_layout_walks_every_mode_and_returns() {
         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,
-        ] {
+        let start = d.active_workspace().params().mode;
+        for _ in 0..LayoutMode::ALL.len() {
+            let before = d.active_workspace().params().mode;
             d.on_event(BodyEvent::Keybind("Super+space".into()));
-            assert_eq!(d.active_workspace().params().mode, expected);
+            assert_eq!(d.active_workspace().params().mode, before.next());
+        }
+        // Una vuelta completa devuelve al modo inicial.
+        assert_eq!(d.active_workspace().params().mode, start);
+    }
+
+    #[test]
+    fn grow_and_shrink_master_adjust_the_ratio() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        let r0 = d.active_workspace().params().master_ratio;
+        d.apply(DesktopAction::GrowMaster);
+        assert!(d.active_workspace().params().master_ratio > r0);
+        d.apply(DesktopAction::ShrinkMaster);
+        assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6);
+    }
+
+    #[test]
+    fn master_ratio_stays_within_bounds() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        for _ in 0..50 {
+            d.apply(DesktopAction::GrowMaster);
+        }
+        assert!(d.active_workspace().params().master_ratio <= 0.95);
+        for _ in 0..50 {
+            d.apply(DesktopAction::ShrinkMaster);
         }
+        assert!(d.active_workspace().params().master_ratio >= 0.05);
     }
 
     #[test]
diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs
index 3d5957f..dedd37e 100644
--- a/crates/modules/mirada/mirada-brain/src/keymap.rs
+++ b/crates/modules/mirada/mirada-brain/src/keymap.rs
@@ -238,7 +238,9 @@ const KEYMAP_HEADER: &str = "\
 //   move-forward / move-backward     reordena la ventana enfocada
 //   close-focused                    cierra la enfocada
 //   cycle-layout                     siguiente modo de teselado
-//   layout:master-stack | layout:monocle | layout:grid | layout:columns
+//   layout:<modo>                    master-stack | centered-master | spiral
+//                                    grid | columns | rows | monocle
+//   grow-master / shrink-master      redimensiona el área maestra
 //   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-layout/src/layout.rs b/crates/modules/mirada/mirada-layout/src/layout.rs
index a3b6eb7..95b5a37 100644
--- a/crates/modules/mirada/mirada-layout/src/layout.rs
+++ b/crates/modules/mirada/mirada-layout/src/layout.rs
@@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize};
 use crate::geometry::{split, Rect};
 
 /// Estrategia de teselado.
+///
+/// Las variantes nuevas se añaden **al final** para no mover los índices
+/// con que `postcard` las serializa en el API de control.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case")]
 pub enum LayoutMode {
@@ -16,6 +19,33 @@ pub enum LayoutMode {
     Grid,
     /// Columnas verticales de igual ancho.
     Columns,
+    /// Filas horizontales de igual alto.
+    Rows,
+    /// Ventana maestra centrada; el resto en columnas a ambos lados.
+    /// Pensado para monitores anchos.
+    CenteredMaster,
+    /// Espiral de Fibonacci: cada ventana parte por la mitad el espacio
+    /// que queda, alternando el sentido del corte.
+    Spiral,
+}
+
+impl LayoutMode {
+    /// Todos los modos, en el orden del ciclo de `CycleLayout`.
+    pub const ALL: [LayoutMode; 7] = [
+        LayoutMode::MasterStack,
+        LayoutMode::CenteredMaster,
+        LayoutMode::Spiral,
+        LayoutMode::Grid,
+        LayoutMode::Columns,
+        LayoutMode::Rows,
+        LayoutMode::Monocle,
+    ];
+
+    /// El siguiente modo en el ciclo (envuelve al llegar al final).
+    pub fn next(self) -> LayoutMode {
+        let i = Self::ALL.iter().position(|&m| m == self).unwrap_or(0);
+        Self::ALL[(i + 1) % Self::ALL.len()]
+    }
 }
 
 /// Parámetros del teselado.
@@ -45,8 +75,11 @@ pub fn tile(screen: Rect, count: usize, params: &LayoutParams) -> Vec<Rect> {
     let cells = match params.mode {
         LayoutMode::Monocle => vec![screen; count],
         LayoutMode::Columns => columns(screen, count),
+        LayoutMode::Rows => rows(screen, count),
         LayoutMode::Grid => grid(screen, count),
         LayoutMode::MasterStack => master_stack(screen, count, params.master_ratio),
+        LayoutMode::CenteredMaster => centered_master(screen, count, params.master_ratio),
+        LayoutMode::Spiral => spiral(screen, count),
     };
     // El margen se aplica al final, uniforme para todos los modos.
     cells.into_iter().map(|c| c.inset(params.gap)).collect()
@@ -75,6 +108,65 @@ fn grid(screen: Rect, count: usize) -> Vec<Rect> {
         .collect()
 }
 
+/// Filas horizontales de igual alto.
+fn rows(screen: Rect, count: usize) -> Vec<Rect> {
+    split(screen.h, count)
+        .into_iter()
+        .map(|(off, h)| Rect::new(screen.x, screen.y + off, screen.w, h))
+        .collect()
+}
+
+/// Espiral de Fibonacci: cada ventana se queda con la mitad del espacio
+/// libre y la siguiente recurre en la otra mitad, alternando el corte.
+/// La última ventana llena todo lo que sobra.
+fn spiral(screen: Rect, count: usize) -> Vec<Rect> {
+    let mut out = Vec::with_capacity(count);
+    let mut area = screen;
+    let mut horizontal = true;
+    for _ in 1..count {
+        if horizontal {
+            let p = split(area.w, 2);
+            out.push(Rect::new(area.x, area.y, p[0].1, area.h));
+            area = Rect::new(area.x + p[1].0, area.y, p[1].1, area.h);
+        } else {
+            let p = split(area.h, 2);
+            out.push(Rect::new(area.x, area.y, area.w, p[0].1));
+            area = Rect::new(area.x, area.y + p[1].0, area.w, p[1].1);
+        }
+        horizontal = !horizontal;
+    }
+    out.push(area);
+    out
+}
+
+/// Ventana maestra centrada + pila repartida en columnas a ambos lados.
+fn centered_master(screen: Rect, count: usize, ratio: f32) -> Vec<Rect> {
+    // Con una o dos ventanas no hay nada que centrar: cae a maestro+pila.
+    if count <= 2 {
+        return master_stack(screen, count, ratio);
+    }
+    let ratio = ratio.clamp(0.05, 0.95);
+    let master_w = (screen.w as f32 * ratio).round() as i32;
+    let sides = split(screen.w - master_w, 2);
+    let (left_w, right_w) = (sides[0].1, sides[1].1);
+
+    let stack = count - 1;
+    let left_n = stack / 2;
+    let right_n = stack - left_n;
+
+    let mut out = Vec::with_capacity(count);
+    // 0 = la maestra, centrada.
+    out.push(Rect::new(screen.x + left_w, screen.y, master_w, screen.h));
+    // Columna izquierda, luego la derecha — el orden de teselado.
+    for (off, h) in split(screen.h, left_n) {
+        out.push(Rect::new(screen.x, screen.y + off, left_w, h));
+    }
+    for (off, h) in split(screen.h, right_n) {
+        out.push(Rect::new(screen.x + left_w + master_w, screen.y + off, right_w, h));
+    }
+    out
+}
+
 /// Ventana maestra a la izquierda + pila a la derecha.
 fn master_stack(screen: Rect, count: usize, ratio: f32) -> Vec<Rect> {
     if count == 1 {
@@ -110,18 +202,59 @@ mod tests {
 
     #[test]
     fn tile_count_matches_window_count() {
-        for mode in [
-            LayoutMode::MasterStack,
-            LayoutMode::Monocle,
-            LayoutMode::Grid,
-            LayoutMode::Columns,
-        ] {
+        for mode in LayoutMode::ALL {
             for n in 1..=9 {
-                assert_eq!(tile(SCREEN, n, &params(mode)).len(), n);
+                assert_eq!(tile(SCREEN, n, &params(mode)).len(), n, "modo {mode:?}");
             }
         }
     }
 
+    #[test]
+    fn rows_partition_the_height_exactly() {
+        let rects = tile(SCREEN, 3, &params(LayoutMode::Rows));
+        assert_eq!(rects.iter().map(|r| r.h).sum::<i32>(), 1080);
+        assert!(rects.iter().all(|r| r.w == 1920));
+    }
+
+    #[test]
+    fn spiral_tiles_cover_the_screen_without_overlap() {
+        for n in 1..=9 {
+            let total: i64 = tile(SCREEN, n, &params(LayoutMode::Spiral))
+                .iter()
+                .map(|r| r.area())
+                .sum();
+            assert_eq!(total, SCREEN.area(), "espiral con {n} ventanas");
+        }
+    }
+
+    #[test]
+    fn centered_master_centers_the_master_and_covers_the_screen() {
+        let rects = tile(SCREEN, 5, &params(LayoutMode::CenteredMaster));
+        let master = rects[0];
+        // Hueco a la izquierda y a la derecha de la maestra: iguales ±1px.
+        let left = master.x - SCREEN.x;
+        let right = (SCREEN.x + SCREEN.w) - (master.x + master.w);
+        assert!((left - right).abs() <= 1, "maestra no centrada: {left} vs {right}");
+        let total: i64 = rects.iter().map(|r| r.area()).sum();
+        assert_eq!(total, SCREEN.area());
+    }
+
+    #[test]
+    fn layout_mode_next_cycles_through_every_mode() {
+        let mut visited: Vec<LayoutMode> = Vec::new();
+        let mut m = LayoutMode::MasterStack;
+        for _ in 0..LayoutMode::ALL.len() {
+            assert!(!visited.contains(&m), "modo repetido en el ciclo: {m:?}");
+            visited.push(m);
+            m = m.next();
+        }
+        // Tras una vuelta completa, de vuelta al inicio.
+        assert_eq!(m, LayoutMode::MasterStack);
+        for mode in LayoutMode::ALL {
+            assert!(visited.contains(&mode), "el ciclo no pasa por {mode:?}");
+        }
+    }
+
     #[test]
     fn monocle_gives_every_window_the_full_screen() {
         for r in tile(SCREEN, 4, &params(LayoutMode::Monocle)) {
diff --git a/vamos.txt b/vamos.txt
index 779a6c2..4fb9e2c 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1027,5 +1027,15 @@
 
 
 
+  Layouts — 7 modos de teselado, intercambiables por el API:
+  master-stack · centered-master · spiral (espiral de Fibonacci) · grid · columns · rows · monocle
+  mirada-ctl layout spiral         # fija un modo
+  mirada-ctl cycle-layout          # siguiente modo (LayoutMode::next)
+  mirada-ctl grow-master / shrink-master   # redimensiona el área maestra (Super+l / Super+h)
+  Atajos directos: Super+t/m/g/c/r/d/s. CenteredMaster y Spiral pensados para monitores anchos.
+  El motor (mirada-layout::tile) es puro y determinista; split() reparte sin perder un píxel.
+
+
+