From 8821d34bd59227892ae42b95d09409d0467ae125 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 00:37:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(mirada):=203=20layouts=20nuevos=20+=20redi?= =?UTF-8?q?mensionar=20el=20=C3=A1rea=20maestra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mirada-layout pasa de 4 a 7 modos de teselado, todos intercambiables por el API (SetLayout / CycleLayout / mirada-ctl layout ): - 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 --- crates/apps/mirada-compositor/README.md | 5 +- crates/apps/mirada-ctl/src/main.rs | 33 ++-- crates/apps/mirada/src/main.rs | 15 +- crates/modules/mirada/SDD.md | 14 +- .../mirada-brain/examples/headless-ctl.rs | 40 +++-- .../modules/mirada/mirada-brain/src/action.rs | 26 +++- .../mirada/mirada-brain/src/desktop.rs | 67 +++++--- .../modules/mirada/mirada-brain/src/keymap.rs | 4 +- .../mirada/mirada-layout/src/layout.rs | 147 +++++++++++++++++- vamos.txt | 10 ++ 10 files changed, 293 insertions(+), 68 deletions(-) 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 enfoca la ventana (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 master-stack | monocle | grid | columns\n \ - workspace activa el escritorio n (1..9)\n \ - send-to-workspace 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 enfoca la ventana (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 master-stack · centered-master · spiral + grid · columns · rows · monocle + grow-master agranda el área de la ventana maestra + shrink-master la encoge + workspace activa el escritorio n (1..9) + send-to-workspace 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 { "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 { + 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: 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 { 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 { .collect() } +/// Filas horizontales de igual alto. +fn rows(screen: Rect, count: usize) -> Vec { + 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 { + 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 { + // 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 { 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, ¶ms(mode)).len(), n); + assert_eq!(tile(SCREEN, n, ¶ms(mode)).len(), n, "modo {mode:?}"); } } } + #[test] + fn rows_partition_the_height_exactly() { + let rects = tile(SCREEN, 3, ¶ms(LayoutMode::Rows)); + assert_eq!(rects.iter().map(|r| r.h).sum::(), 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, ¶ms(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, ¶ms(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 = 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, ¶ms(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. + + +