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>
b975dc791907effa57f9167a0c2992aed075822e · ruwaq sergio · 2026-05-20T17:24:48Z (5 killa ñawpaq) · tayta 737ae5a69 · kay commitpi sach'ata qhaway
Semántico sayk'uy · 63
crates/modules/mirada/mirada-layout/src/geometry.rs
crates/modules/mirada/mirada-layout/src/layout.rs
crates/modules/mirada/mirada-layout/src/lib.rs
crates/modules/mirada/mirada-layout/src/workspace.rs
diff --git a/Cargo.lock b/Cargo.lock
index b82ea19..6cc7d6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7346,6 +7346,13 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "mirada-layout"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "moka"
version = "0.12.15"
diff --git a/Cargo.toml b/Cargo.toml
index c654f25..e9316cf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -153,6 +153,11 @@ members = [
# ============================================================
"crates/modules/charka/charka-bcd",
+ # ============================================================
+ # modules/mirada/ — Compositor Wayland
+ # ============================================================
+ "crates/modules/mirada/mirada-layout",
+
# ============================================================
# modules/nakui/ — ERP matemático (categórico)
# ============================================================
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
new file mode 100644
index 0000000..8d02352
--- /dev/null
+++ b/crates/modules/mirada/SDD.md
@@ -0,0 +1,40 @@
+# modules/mirada/ — Compositor Wayland
+
+**Propósito.** Un compositor Wayland teselante con aislamiento de
+clientes (sobre `arje-incarnate`) y delegación de regiones. El cerebro
+espacial —cómo se reparte la pantalla— se mantiene puro y aislado de
+Wayland para poder probarlo sin un servidor gráfico.
+
+## Crates
+
+| crate | tipo | rol |
+| --------------- | ---- | ------------------------------------------------------------ |
+| `mirada-layout` | lib | Motor de teselado: `Rect`, modos de layout, `Workspace` (ventanas, foco) |
+
+## mirada-layout
+
+- `Rect` + `split` — reparto exacto de píxeles (sin pérdidas).
+- `LayoutMode` — `MasterStack`, `Monocle`, `Grid`, `Columns`; `tile`
+ calcula el rectángulo de cada ventana.
+- `Workspace` — ventanas en orden de teselado, foco cíclico, reordenado
+ (`move_focused_forward/backward`) y `layout` que resuelve la geometría.
+- Determinista: misma pantalla + mismas ventanas → misma distribución.
+
+## Dependencias
+
+- `mirada-layout` ← sólo `serde`. `#![forbid(unsafe_code)]`.
+- Cero Wayland, cero `smithay` — ese acoplamiento vive en los crates de
+ integración pendientes.
+
+## Estado
+
+`mirada-layout` implementado y verde (22 tests). **Pendiente** (la capa
+que toca hardware/protocolo, no verificable en modo desatendido):
+
+| crate pendiente | rol |
+| ------------------ | ---------------------------------------------------- |
+| `mirada-compositor`| integración `smithay`: superficies, buffers, salidas |
+| `mirada-input` | teclado/ratón, atajos, asignación de foco |
+| `mirada-sandbox` | aislamiento de clientes sobre `arje-incarnate` |
+
+CRIU (congelar/restaurar ventanas) queda anotado como futuro.
diff --git a/crates/modules/mirada/mirada-layout/Cargo.toml b/crates/modules/mirada/mirada-layout/Cargo.toml
new file mode 100644
index 0000000..e8df6d8
--- /dev/null
+++ b/crates/modules/mirada/mirada-layout/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "mirada-layout"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "mirada — motor de teselado del compositor Wayland: reparte la pantalla entre ventanas según el modo de layout. Agnóstico de Wayland y de smithay."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/crates/modules/mirada/mirada-layout/src/geometry.rs b/crates/modules/mirada/mirada-layout/src/geometry.rs
new file mode 100644
index 0000000..aa3a306
--- /dev/null
+++ b/crates/modules/mirada/mirada-layout/src/geometry.rs
@@ -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));
+ }
+}
diff --git a/crates/modules/mirada/mirada-layout/src/layout.rs b/crates/modules/mirada/mirada-layout/src/layout.rs
new file mode 100644
index 0000000..a3b6eb7
--- /dev/null
+++ b/crates/modules/mirada/mirada-layout/src/layout.rs
@@ -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, ¶ms(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, ¶ms(mode)).len(), n);
+ }
+ }
+ }
+
+ #[test]
+ fn monocle_gives_every_window_the_full_screen() {
+ for r in tile(SCREEN, 4, ¶ms(LayoutMode::Monocle)) {
+ assert_eq!(r, SCREEN);
+ }
+ }
+
+ #[test]
+ fn columns_partition_the_width_exactly() {
+ let rects = tile(SCREEN, 3, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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));
+ }
+}
diff --git a/crates/modules/mirada/mirada-layout/src/lib.rs b/crates/modules/mirada/mirada-layout/src/lib.rs
new file mode 100644
index 0000000..7f2936f
--- /dev/null
+++ b/crates/modules/mirada/mirada-layout/src/lib.rs
@@ -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};
diff --git a/crates/modules/mirada/mirada-layout/src/workspace.rs b/crates/modules/mirada/mirada-layout/src/workspace.rs
new file mode 100644
index 0000000..cedfe79
--- /dev/null
+++ b/crates/modules/mirada/mirada-layout/src/workspace.rs
@@ -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());
+ }
+}
diff --git a/nohup.out b/nohup.out
index 8acf06c..3d3af54 100644
--- a/nohup.out
+++ b/nohup.out
@@ -189,3 +189,18 @@ Gdk-Message: 05:42:34.451: Error reading events from display: Broken pipe
[Child 27081, MediaDecoderStateMachine #2] WARNING: 72495d46ca60 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
[Child 27081, MediaDecoderStateMachine #2] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
[Child 27081, MediaDecoderStateMachine #2] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #9] WARNING: 72496b642700 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #9] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #9] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #9] WARNING: 72495d49e4c0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #9] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #9] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #12] WARNING: 72495eb5d3a0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #12] WARNING: 72496b6425e0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168
+[Child 27081, MediaDecoderStateMachine #12] WARNING: 72495d46cdc0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #12] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168