⎇ be61ddb6eb441193c4374a243fbb00aed47b42de
crates/modules/mirada/mirada-layout/src/layout.rs
Historia
Quién tocó qué
crudo
feat(mirada): nmaster, promover a maestra y smart gaps (estilo dwm)
1//! Modos de teselado — cómo se reparte la pantalla entre ventanas.
2
3use serde::{Deserialize, Serialize};
4
5use crate::geometry::{split, Rect};
6
7/// Estrategia de teselado.
8///
9/// Las variantes nuevas se añaden **al final** para no mover los índices
10/// con que `postcard` las serializa en el API de control.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum LayoutMode {
14 /// Una ventana maestra a la izquierda; el resto apiladas a la derecha.
15 MasterStack,
16 /// Todas a pantalla completa, superpuestas — sólo se ve la enfocada.
17 Monocle,
18 /// Rejilla uniforme.
19 Grid,
20 /// Columnas verticales de igual ancho.
21 Columns,
22 /// Filas horizontales de igual alto.
23 Rows,
24 /// Ventana maestra centrada; el resto en columnas a ambos lados.
25 /// Pensado para monitores anchos.
26 CenteredMaster,
27 /// Espiral de Fibonacci: cada ventana parte por la mitad el espacio
28 /// que queda, alternando el sentido del corte.
29 Spiral,
30}
31
32impl LayoutMode {
33 /// Todos los modos, en el orden del ciclo de `CycleLayout`.
34 pub const ALL: [LayoutMode; 7] = [
35 LayoutMode::MasterStack,
36 LayoutMode::CenteredMaster,
37 LayoutMode::Spiral,
38 LayoutMode::Grid,
39 LayoutMode::Columns,
40 LayoutMode::Rows,
41 LayoutMode::Monocle,
42 ];
43
44 /// El siguiente modo en el ciclo (envuelve al llegar al final).
45 pub fn next(self) -> LayoutMode {
46 let i = Self::ALL.iter().position(|&m| m == self).unwrap_or(0);
47 Self::ALL[(i + 1) % Self::ALL.len()]
48 }
49}
50
51/// Parámetros del teselado.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
53pub struct LayoutParams {
54 pub mode: LayoutMode,
55 /// Fracción del ancho para la ventana maestra en `MasterStack` y
56 /// `CenteredMaster` (se acota a `0.05..=0.95`).
57 pub master_ratio: f32,
58 /// Cuántas ventanas van en el área maestra (`nmaster`); al menos 1.
59 pub master_count: usize,
60 /// Margen en píxeles alrededor de cada ventana.
61 pub gap: i32,
62}
63
64impl Default for LayoutParams {
65 fn default() -> Self {
66 Self {
67 mode: LayoutMode::MasterStack,
68 master_ratio: 0.6,
69 master_count: 1,
70 gap: 8,
71 }
72 }
73}
74
75/// Calcula el rectángulo de cada una de las `count` ventanas dentro de
76/// `screen`. El vector resultante tiene exactamente `count` elementos,
77/// en el mismo orden que las ventanas.
78pub fn tile(screen: Rect, count: usize, params: &LayoutParams) -> Vec<Rect> {
79 if count == 0 {
80 return Vec::new();
81 }
82 let cells = match params.mode {
83 LayoutMode::Monocle => vec![screen; count],
84 LayoutMode::Columns => columns(screen, count),
85 LayoutMode::Rows => rows(screen, count),
86 LayoutMode::Grid => grid(screen, count),
87 LayoutMode::MasterStack => {
88 master_stack(screen, count, params.master_ratio, params.master_count)
89 }
90 LayoutMode::CenteredMaster => {
91 centered_master(screen, count, params.master_ratio, params.master_count)
92 }
93 LayoutMode::Spiral => spiral(screen, count),
94 };
95 // El margen se aplica al final, uniforme para todos los modos. *Smart
96 // gaps*: una sola ventana va a sangre, sin margen desperdiciado.
97 let gap = if count == 1 { 0 } else { params.gap };
98 cells.into_iter().map(|c| c.inset(gap)).collect()
99}
100
101/// Columnas verticales de igual ancho.
102fn columns(screen: Rect, count: usize) -> Vec<Rect> {
103 split(screen.w, count)
104 .into_iter()
105 .map(|(off, w)| Rect::new(screen.x + off, screen.y, w, screen.h))
106 .collect()
107}
108
109/// Rejilla `cols × rows` lo más cuadrada posible.
110fn grid(screen: Rect, count: usize) -> Vec<Rect> {
111 let cols = (count as f64).sqrt().ceil() as usize;
112 let rows = count.div_ceil(cols);
113 let col_parts = split(screen.w, cols);
114 let row_parts = split(screen.h, rows);
115 (0..count)
116 .map(|i| {
117 let (cx, cw) = col_parts[i % cols];
118 let (ry, rh) = row_parts[i / cols];
119 Rect::new(screen.x + cx, screen.y + ry, cw, rh)
120 })
121 .collect()
122}
123
124/// Filas horizontales de igual alto.
125fn rows(screen: Rect, count: usize) -> Vec<Rect> {
126 split(screen.h, count)
127 .into_iter()
128 .map(|(off, h)| Rect::new(screen.x, screen.y + off, screen.w, h))
129 .collect()
130}
131
132/// Espiral de Fibonacci: cada ventana se queda con la mitad del espacio
133/// libre y la siguiente recurre en la otra mitad, alternando el corte.
134/// La última ventana llena todo lo que sobra.
135fn spiral(screen: Rect, count: usize) -> Vec<Rect> {
136 let mut out = Vec::with_capacity(count);
137 let mut area = screen;
138 let mut horizontal = true;
139 for _ in 1..count {
140 if horizontal {
141 let p = split(area.w, 2);
142 out.push(Rect::new(area.x, area.y, p[0].1, area.h));
143 area = Rect::new(area.x + p[1].0, area.y, p[1].1, area.h);
144 } else {
145 let p = split(area.h, 2);
146 out.push(Rect::new(area.x, area.y, area.w, p[0].1));
147 area = Rect::new(area.x, area.y + p[1].0, area.w, p[1].1);
148 }
149 horizontal = !horizontal;
150 }
151 out.push(area);
152 out
153}
154
155/// `master_count` ventanas maestras centradas + el resto repartido en
156/// columnas a ambos lados.
157fn centered_master(screen: Rect, count: usize, ratio: f32, master_count: usize) -> Vec<Rect> {
158 let m = master_count.clamp(1, count);
159 let stack = count - m;
160 // Centrar sólo tiene sentido con al menos una ventana por lado.
161 if stack < 2 {
162 return master_stack(screen, count, ratio, master_count);
163 }
164 let ratio = ratio.clamp(0.05, 0.95);
165 let master_w = (screen.w as f32 * ratio).round() as i32;
166 let sides = split(screen.w - master_w, 2);
167 let (left_w, right_w) = (sides[0].1, sides[1].1);
168 let left_n = stack / 2;
169 let right_n = stack - left_n;
170
171 let mut out = Vec::with_capacity(count);
172 // Las maestras, apiladas en la columna central — orden de teselado.
173 for (off, h) in split(screen.h, m) {
174 out.push(Rect::new(screen.x + left_w, screen.y + off, master_w, h));
175 }
176 for (off, h) in split(screen.h, left_n) {
177 out.push(Rect::new(screen.x, screen.y + off, left_w, h));
178 }
179 for (off, h) in split(screen.h, right_n) {
180 out.push(Rect::new(screen.x + left_w + master_w, screen.y + off, right_w, h));
181 }
182 out
183}
184
185/// `master_count` ventanas maestras a la izquierda + el resto en pila a
186/// la derecha. Sin pila, las maestras llenan toda la pantalla.
187fn master_stack(screen: Rect, count: usize, ratio: f32, master_count: usize) -> Vec<Rect> {
188 let m = master_count.clamp(1, count);
189 let stack = count - m;
190 if stack == 0 {
191 return split(screen.h, m)
192 .into_iter()
193 .map(|(off, h)| Rect::new(screen.x, screen.y + off, screen.w, h))
194 .collect();
195 }
196 let ratio = ratio.clamp(0.05, 0.95);
197 let master_w = (screen.w as f32 * ratio).round() as i32;
198 let stack_x = screen.x + master_w;
199 let stack_w = screen.w - master_w;
200
201 let mut out = Vec::with_capacity(count);
202 for (off, h) in split(screen.h, m) {
203 out.push(Rect::new(screen.x, screen.y + off, master_w, h));
204 }
205 for (off, h) in split(screen.h, stack) {
206 out.push(Rect::new(stack_x, screen.y + off, stack_w, h));
207 }
208 out
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
216
217 fn params(mode: LayoutMode) -> LayoutParams {
218 LayoutParams { mode, gap: 0, ..LayoutParams::default() }
219 }
220
221 #[test]
222 fn empty_count_yields_no_rects() {
223 assert!(tile(SCREEN, 0, ¶ms(LayoutMode::Grid)).is_empty());
224 }
225
226 #[test]
227 fn tile_count_matches_window_count() {
228 for mode in LayoutMode::ALL {
229 for n in 1..=9 {
230 assert_eq!(tile(SCREEN, n, ¶ms(mode)).len(), n, "modo {mode:?}");
231 }
232 }
233 }
234
235 #[test]
236 fn rows_partition_the_height_exactly() {
237 let rects = tile(SCREEN, 3, ¶ms(LayoutMode::Rows));
238 assert_eq!(rects.iter().map(|r| r.h).sum::<i32>(), 1080);
239 assert!(rects.iter().all(|r| r.w == 1920));
240 }
241
242 #[test]
243 fn spiral_tiles_cover_the_screen_without_overlap() {
244 for n in 1..=9 {
245 let total: i64 = tile(SCREEN, n, ¶ms(LayoutMode::Spiral))
246 .iter()
247 .map(|r| r.area())
248 .sum();
249 assert_eq!(total, SCREEN.area(), "espiral con {n} ventanas");
250 }
251 }
252
253 #[test]
254 fn centered_master_centers_the_master_and_covers_the_screen() {
255 let rects = tile(SCREEN, 5, ¶ms(LayoutMode::CenteredMaster));
256 let master = rects[0];
257 // Hueco a la izquierda y a la derecha de la maestra: iguales ±1px.
258 let left = master.x - SCREEN.x;
259 let right = (SCREEN.x + SCREEN.w) - (master.x + master.w);
260 assert!((left - right).abs() <= 1, "maestra no centrada: {left} vs {right}");
261 let total: i64 = rects.iter().map(|r| r.area()).sum();
262 assert_eq!(total, SCREEN.area());
263 }
264
265 #[test]
266 fn layout_mode_next_cycles_through_every_mode() {
267 let mut visited: Vec<LayoutMode> = Vec::new();
268 let mut m = LayoutMode::MasterStack;
269 for _ in 0..LayoutMode::ALL.len() {
270 assert!(!visited.contains(&m), "modo repetido en el ciclo: {m:?}");
271 visited.push(m);
272 m = m.next();
273 }
274 // Tras una vuelta completa, de vuelta al inicio.
275 assert_eq!(m, LayoutMode::MasterStack);
276 for mode in LayoutMode::ALL {
277 assert!(visited.contains(&mode), "el ciclo no pasa por {mode:?}");
278 }
279 }
280
281 #[test]
282 fn monocle_gives_every_window_the_full_screen() {
283 for r in tile(SCREEN, 4, ¶ms(LayoutMode::Monocle)) {
284 assert_eq!(r, SCREEN);
285 }
286 }
287
288 #[test]
289 fn columns_partition_the_width_exactly() {
290 let rects = tile(SCREEN, 3, ¶ms(LayoutMode::Columns));
291 assert_eq!(rects.iter().map(|r| r.w).sum::<i32>(), 1920);
292 // Todas ocupan el alto completo.
293 assert!(rects.iter().all(|r| r.h == 1080));
294 }
295
296 #[test]
297 fn master_stack_master_takes_its_ratio() {
298 let rects = tile(SCREEN, 3, ¶ms(LayoutMode::MasterStack));
299 // 60% de 1920 = 1152.
300 assert_eq!(rects[0].w, 1152);
301 // Las dos de la pila comparten el resto del ancho y el alto.
302 assert_eq!(rects[1].w, 1920 - 1152);
303 assert_eq!(rects[1].h + rects[2].h, 1080);
304 }
305
306 #[test]
307 fn master_stack_single_window_fills_screen() {
308 let rects = tile(SCREEN, 1, ¶ms(LayoutMode::MasterStack));
309 assert_eq!(rects[0], SCREEN);
310 }
311
312 #[test]
313 fn grid_tiles_cover_the_screen_without_overlap() {
314 // 4 ventanas → rejilla 2×2, cada una un cuarto.
315 let rects = tile(SCREEN, 4, ¶ms(LayoutMode::Grid));
316 let total: i64 = rects.iter().map(|r| r.area()).sum();
317 assert_eq!(total, SCREEN.area());
318 }
319
320 #[test]
321 fn gap_shrinks_every_window() {
322 let p = LayoutParams { mode: LayoutMode::Columns, gap: 10, ..LayoutParams::default() };
323 for r in tile(SCREEN, 2, &p) {
324 // Cada celda de 960 de ancho se encoge 20 (10 por lado).
325 assert_eq!(r.w, 960 - 20);
326 assert_eq!(r.h, 1080 - 20);
327 }
328 }
329
330 #[test]
331 fn nmaster_keeps_n_windows_in_the_master_column() {
332 let p = LayoutParams {
333 mode: LayoutMode::MasterStack,
334 master_count: 2,
335 gap: 0,
336 ..LayoutParams::default()
337 };
338 let rects = tile(SCREEN, 4, &p);
339 // Dos maestras comparten el ancho maestro (60% de 1920 = 1152).
340 assert_eq!(rects[0].w, 1152);
341 assert_eq!(rects[1].w, 1152);
342 // Dos de pila comparten el resto.
343 assert_eq!(rects[2].w, 1920 - 1152);
344 assert_eq!(rects[3].w, 1920 - 1152);
345 // Las dos maestras parten la altura entre ellas.
346 assert_eq!(rects[0].h + rects[1].h, 1080);
347 }
348
349 #[test]
350 fn nmaster_above_window_count_makes_every_window_a_master() {
351 let p = LayoutParams {
352 mode: LayoutMode::MasterStack,
353 master_count: 9,
354 gap: 0,
355 ..LayoutParams::default()
356 };
357 let rects = tile(SCREEN, 3, &p);
358 // Sin pila: las tres ocupan el ancho completo.
359 assert!(rects.iter().all(|r| r.w == 1920));
360 assert_eq!(rects.iter().map(|r| r.h).sum::<i32>(), 1080);
361 }
362
363 #[test]
364 fn smart_gaps_drop_the_margin_for_a_single_window() {
365 let p = LayoutParams { mode: LayoutMode::MasterStack, gap: 20, ..LayoutParams::default() };
366 // Una sola ventana: a sangre, sin margen.
367 assert_eq!(tile(SCREEN, 1, &p)[0], SCREEN);
368 // Con dos, el margen vuelve.
369 assert!(tile(SCREEN, 2, &p)[0].w < SCREEN.w);
370 }
371
372 #[test]
373 fn layout_is_deterministic() {
374 let p = params(LayoutMode::Grid);
375 assert_eq!(tile(SCREEN, 7, &p), tile(SCREEN, 7, &p));
376 }
377}