feat(mirada): mirada-layout — motor de teselado del compositor Wayland

Rect + split (reparto exacto de píxeles), 4 modos de layout
(MasterStack, Monocle, Grid, Columns) con tile(), y Workspace:
ventanas en orden de teselado, foco cíclico, reordenado y
resolución de geometría. Determinista, agnóstico de Wayland/smithay.

22 tests. #![forbid(unsafe_code)].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 17:24:48 +00:00
parent 737ae5a696
commit b975dc7919
9 changed files with 623 additions and 0 deletions
@@ -0,0 +1,103 @@
//! Geometría — el rectángulo en coordenadas de pantalla.
use serde::{Deserialize, Serialize};
/// Un rectángulo en píxeles de pantalla. El origen `(0,0)` es la
/// esquina superior-izquierda; `x` crece a la derecha, `y` hacia abajo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
Self { x, y, w, h }
}
/// Área en píxeles cuadrados.
pub fn area(&self) -> i64 {
self.w.max(0) as i64 * self.h.max(0) as i64
}
/// `true` si el rectángulo tiene ancho y alto positivos.
pub fn is_visible(&self) -> bool {
self.w > 0 && self.h > 0
}
/// Encoge el rectángulo `g` píxeles por cada lado. Si el margen se
/// come toda la dimensión, ésta queda en `0` (no negativa).
pub fn inset(&self, g: i32) -> Rect {
Rect {
x: self.x + g,
y: self.y + g,
w: (self.w - 2 * g).max(0),
h: (self.h - 2 * g).max(0),
}
}
/// `true` si `(px, py)` cae dentro del rectángulo.
pub fn contains(&self, px: i32, py: i32) -> bool {
px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
}
}
/// Reparte `total` píxeles en `n` tramos contiguos sin perder ni un
/// píxel: las fronteras caen en `total · k / n`, así que la suma de los
/// tamaños es exactamente `total`. Devuelve `(offset, tamaño)` por tramo.
pub fn split(total: i32, n: usize) -> Vec<(i32, i32)> {
if n == 0 {
return Vec::new();
}
let total = total.max(0) as i64;
let n64 = n as i64;
(0..n)
.map(|k| {
let start = total * k as i64 / n64;
let end = total * (k as i64 + 1) / n64;
(start as i32, (end - start) as i32)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inset_shrinks_by_gap_on_every_side() {
let r = Rect::new(0, 0, 100, 80).inset(5);
assert_eq!(r, Rect::new(5, 5, 90, 70));
}
#[test]
fn inset_clamps_to_zero() {
let r = Rect::new(0, 0, 8, 8).inset(10);
assert_eq!((r.w, r.h), (0, 0));
assert!(!r.is_visible());
}
#[test]
fn split_loses_no_pixels() {
for n in 1..=13 {
let parts = split(1000, n);
assert_eq!(parts.len(), n);
assert_eq!(parts.iter().map(|(_, s)| *s).sum::<i32>(), 1000);
// Los tramos son contiguos.
for w in parts.windows(2) {
assert_eq!(w[0].0 + w[0].1, w[1].0);
}
}
}
#[test]
fn contains_checks_bounds() {
let r = Rect::new(10, 10, 20, 20);
assert!(r.contains(10, 10));
assert!(r.contains(29, 29));
assert!(!r.contains(30, 30));
assert!(!r.contains(9, 15));
}
}
@@ -0,0 +1,179 @@
//! Modos de teselado — cómo se reparte la pantalla entre ventanas.
use serde::{Deserialize, Serialize};
use crate::geometry::{split, Rect};
/// Estrategia de teselado.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutMode {
/// Una ventana maestra a la izquierda; el resto apiladas a la derecha.
MasterStack,
/// Todas a pantalla completa, superpuestas — sólo se ve la enfocada.
Monocle,
/// Rejilla uniforme.
Grid,
/// Columnas verticales de igual ancho.
Columns,
}
/// Parámetros del teselado.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct LayoutParams {
pub mode: LayoutMode,
/// Fracción del ancho para la ventana maestra en `MasterStack`
/// (se acota a `0.05..=0.95`).
pub master_ratio: f32,
/// Margen en píxeles alrededor de cada ventana.
pub gap: i32,
}
impl Default for LayoutParams {
fn default() -> Self {
Self { mode: LayoutMode::MasterStack, master_ratio: 0.6, gap: 8 }
}
}
/// Calcula el rectángulo de cada una de las `count` ventanas dentro de
/// `screen`. El vector resultante tiene exactamente `count` elementos,
/// en el mismo orden que las ventanas.
pub fn tile(screen: Rect, count: usize, params: &LayoutParams) -> Vec<Rect> {
if count == 0 {
return Vec::new();
}
let cells = match params.mode {
LayoutMode::Monocle => vec![screen; count],
LayoutMode::Columns => columns(screen, count),
LayoutMode::Grid => grid(screen, count),
LayoutMode::MasterStack => master_stack(screen, count, params.master_ratio),
};
// El margen se aplica al final, uniforme para todos los modos.
cells.into_iter().map(|c| c.inset(params.gap)).collect()
}
/// Columnas verticales de igual ancho.
fn columns(screen: Rect, count: usize) -> Vec<Rect> {
split(screen.w, count)
.into_iter()
.map(|(off, w)| Rect::new(screen.x + off, screen.y, w, screen.h))
.collect()
}
/// Rejilla `cols × rows` lo más cuadrada posible.
fn grid(screen: Rect, count: usize) -> Vec<Rect> {
let cols = (count as f64).sqrt().ceil() as usize;
let rows = count.div_ceil(cols);
let col_parts = split(screen.w, cols);
let row_parts = split(screen.h, rows);
(0..count)
.map(|i| {
let (cx, cw) = col_parts[i % cols];
let (ry, rh) = row_parts[i / cols];
Rect::new(screen.x + cx, screen.y + ry, cw, rh)
})
.collect()
}
/// Ventana maestra a la izquierda + pila a la derecha.
fn master_stack(screen: Rect, count: usize, ratio: f32) -> Vec<Rect> {
if count == 1 {
return vec![screen];
}
let ratio = ratio.clamp(0.05, 0.95);
let master_w = (screen.w as f32 * ratio).round() as i32;
let master = Rect::new(screen.x, screen.y, master_w, screen.h);
let stack_x = screen.x + master_w;
let stack_w = screen.w - master_w;
let mut out = vec![master];
for (off, h) in split(screen.h, count - 1) {
out.push(Rect::new(stack_x, screen.y + off, stack_w, h));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
fn params(mode: LayoutMode) -> LayoutParams {
LayoutParams { mode, master_ratio: 0.6, gap: 0 }
}
#[test]
fn empty_count_yields_no_rects() {
assert!(tile(SCREEN, 0, &params(LayoutMode::Grid)).is_empty());
}
#[test]
fn tile_count_matches_window_count() {
for mode in [
LayoutMode::MasterStack,
LayoutMode::Monocle,
LayoutMode::Grid,
LayoutMode::Columns,
] {
for n in 1..=9 {
assert_eq!(tile(SCREEN, n, &params(mode)).len(), n);
}
}
}
#[test]
fn monocle_gives_every_window_the_full_screen() {
for r in tile(SCREEN, 4, &params(LayoutMode::Monocle)) {
assert_eq!(r, SCREEN);
}
}
#[test]
fn columns_partition_the_width_exactly() {
let rects = tile(SCREEN, 3, &params(LayoutMode::Columns));
assert_eq!(rects.iter().map(|r| r.w).sum::<i32>(), 1920);
// Todas ocupan el alto completo.
assert!(rects.iter().all(|r| r.h == 1080));
}
#[test]
fn master_stack_master_takes_its_ratio() {
let rects = tile(SCREEN, 3, &params(LayoutMode::MasterStack));
// 60% de 1920 = 1152.
assert_eq!(rects[0].w, 1152);
// Las dos de la pila comparten el resto del ancho y el alto.
assert_eq!(rects[1].w, 1920 - 1152);
assert_eq!(rects[1].h + rects[2].h, 1080);
}
#[test]
fn master_stack_single_window_fills_screen() {
let rects = tile(SCREEN, 1, &params(LayoutMode::MasterStack));
assert_eq!(rects[0], SCREEN);
}
#[test]
fn grid_tiles_cover_the_screen_without_overlap() {
// 4 ventanas → rejilla 2×2, cada una un cuarto.
let rects = tile(SCREEN, 4, &params(LayoutMode::Grid));
let total: i64 = rects.iter().map(|r| r.area()).sum();
assert_eq!(total, SCREEN.area());
}
#[test]
fn gap_shrinks_every_window() {
let p = LayoutParams { mode: LayoutMode::Columns, master_ratio: 0.6, gap: 10 };
for r in tile(SCREEN, 2, &p) {
// Cada celda de 960 de ancho se encoge 20 (10 por lado).
assert_eq!(r.w, 960 - 20);
assert_eq!(r.h, 1080 - 20);
}
}
#[test]
fn layout_is_deterministic() {
let p = params(LayoutMode::Grid);
assert_eq!(tile(SCREEN, 7, &p), tile(SCREEN, 7, &p));
}
}
@@ -0,0 +1,23 @@
//! `mirada-layout` — el motor de teselado del compositor Wayland.
//!
//! mirada es un compositor Wayland; este crate es su cerebro espacial,
//! aislado de Wayland y de `smithay`. Decide *dónde* va cada ventana —
//! un cálculo puro sobre rectángulos— para que el compositor sólo tenga
//! que aplicar la geometría a las superficies reales.
//!
//! - [`geometry`] — el [`Rect`] y el reparto exacto de píxeles.
//! - [`layout`] — los modos de teselado y la función [`tile`].
//! - [`workspace`] — el [`Workspace`]: ventanas, foco y modo.
//!
//! Todo es determinista y testeable sin un servidor gráfico: la misma
//! pantalla y las mismas ventanas dan siempre la misma distribución.
#![forbid(unsafe_code)]
pub mod geometry;
pub mod layout;
pub mod workspace;
pub use geometry::Rect;
pub use layout::{tile, LayoutMode, LayoutParams};
pub use workspace::{Workspace, WindowId};
@@ -0,0 +1,240 @@
//! `Workspace` — un conjunto de ventanas, su foco y su modo de teselado.
use serde::{Deserialize, Serialize};
use crate::geometry::Rect;
use crate::layout::{tile, LayoutMode, LayoutParams};
/// Identificador de una ventana (una superficie Wayland).
pub type WindowId = u64;
/// Un escritorio: ventanas en orden de teselado + la enfocada + el modo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
/// Ventanas en orden de teselado (la 0 es la maestra en `MasterStack`).
windows: Vec<WindowId>,
/// Índice de la ventana enfocada en `windows`.
focus: usize,
params: LayoutParams,
}
impl Workspace {
/// Escritorio vacío con los parámetros dados.
pub fn new(params: LayoutParams) -> Self {
Self { windows: Vec::new(), focus: 0, params }
}
pub fn len(&self) -> usize {
self.windows.len()
}
pub fn is_empty(&self) -> bool {
self.windows.is_empty()
}
/// Ventanas en orden de teselado.
pub fn windows(&self) -> &[WindowId] {
&self.windows
}
pub fn params(&self) -> &LayoutParams {
&self.params
}
/// Cambia el modo de teselado.
pub fn set_mode(&mut self, mode: LayoutMode) {
self.params.mode = mode;
}
/// Ajusta la fracción de la ventana maestra.
pub fn set_master_ratio(&mut self, ratio: f32) {
self.params.master_ratio = ratio;
}
/// Añade una ventana y la enfoca. Si ya estaba, sólo la enfoca.
pub fn add(&mut self, window: WindowId) {
if let Some(i) = self.windows.iter().position(|&w| w == window) {
self.focus = i;
} else {
self.windows.push(window);
self.focus = self.windows.len() - 1;
}
}
/// Quita una ventana. `false` si no estaba. El foco se reajusta para
/// seguir apuntando a una ventana válida.
pub fn remove(&mut self, window: WindowId) -> bool {
let Some(i) = self.windows.iter().position(|&w| w == window) else {
return false;
};
self.windows.remove(i);
if i < self.focus {
self.focus -= 1;
}
if self.focus >= self.windows.len() {
self.focus = self.windows.len().saturating_sub(1);
}
true
}
/// Ventana enfocada, o `None` si el escritorio está vacío.
pub fn focused(&self) -> Option<WindowId> {
self.windows.get(self.focus).copied()
}
/// Mueve el foco a la ventana siguiente (cíclico).
pub fn focus_next(&mut self) {
if !self.windows.is_empty() {
self.focus = (self.focus + 1) % self.windows.len();
}
}
/// Mueve el foco a la ventana anterior (cíclico).
pub fn focus_prev(&mut self) {
if !self.windows.is_empty() {
self.focus = (self.focus + self.windows.len() - 1) % self.windows.len();
}
}
/// Enfoca una ventana por id. `false` si no está en el escritorio.
pub fn focus_window(&mut self, window: WindowId) -> bool {
match self.windows.iter().position(|&w| w == window) {
Some(i) => {
self.focus = i;
true
}
None => false,
}
}
/// Intercambia la ventana enfocada con la siguiente en el orden de
/// teselado; el foco la acompaña. No hace nada si ya es la última.
pub fn move_focused_forward(&mut self) {
if self.focus + 1 < self.windows.len() {
self.windows.swap(self.focus, self.focus + 1);
self.focus += 1;
}
}
/// Intercambia la ventana enfocada con la anterior. No hace nada si
/// ya es la primera.
pub fn move_focused_backward(&mut self) {
if self.focus > 0 && !self.windows.is_empty() {
self.windows.swap(self.focus, self.focus - 1);
self.focus -= 1;
}
}
/// Resuelve la geometría: el rectángulo de cada ventana dentro de
/// `screen`, en orden de teselado.
pub fn layout(&self, screen: Rect) -> Vec<(WindowId, Rect)> {
let rects = tile(screen, self.windows.len(), &self.params);
self.windows.iter().copied().zip(rects).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ws() -> Workspace {
Workspace::new(LayoutParams::default())
}
#[test]
fn add_focuses_the_new_window() {
let mut w = ws();
w.add(10);
w.add(20);
assert_eq!(w.focused(), Some(20));
assert_eq!(w.len(), 2);
}
#[test]
fn adding_an_existing_window_just_focuses_it() {
let mut w = ws();
w.add(10);
w.add(20);
w.add(10);
assert_eq!(w.focused(), Some(10));
assert_eq!(w.len(), 2);
}
#[test]
fn focus_cycles_both_ways() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
assert_eq!(w.focused(), Some(3));
w.focus_next();
assert_eq!(w.focused(), Some(1)); // dio la vuelta
w.focus_prev();
assert_eq!(w.focused(), Some(3));
}
#[test]
fn remove_keeps_focus_valid() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(2);
w.remove(2);
// El foco se mantiene dentro de rango.
assert!(w.focused().is_some());
assert_eq!(w.len(), 2);
}
#[test]
fn remove_before_focus_shifts_it() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(3); // focus = 2
w.remove(1); // quita una anterior
assert_eq!(w.focused(), Some(3)); // sigue enfocada la 3
}
#[test]
fn remove_last_window_empties_workspace() {
let mut w = ws();
w.add(7);
assert!(w.remove(7));
assert!(w.is_empty());
assert_eq!(w.focused(), None);
}
#[test]
fn move_focused_reorders_tiling() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(1); // primera
w.move_focused_forward();
assert_eq!(w.windows(), &[2, 1, 3]);
assert_eq!(w.focused(), Some(1)); // el foco la acompañó
w.move_focused_backward();
assert_eq!(w.windows(), &[1, 2, 3]);
}
#[test]
fn layout_pairs_each_window_with_a_rect() {
let mut w = ws();
for id in [100, 200, 300] {
w.add(id);
}
let screen = Rect::new(0, 0, 1920, 1080);
let placed = w.layout(screen);
assert_eq!(placed.len(), 3);
let ids: Vec<_> = placed.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![100, 200, 300]);
}
#[test]
fn empty_workspace_lays_out_nothing() {
assert!(ws().layout(Rect::new(0, 0, 800, 600)).is_empty());
}
}