tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! llimphi-layout — Física del Espacio.
2//!
3//! Wrapper sobre `taffy` que resuelve árboles flex/grid y devuelve
4//! coordenadas absolutas (no relativas al padre). El consumidor pasa el
5//! árbol a `compute(root, viewport)` y obtiene un [`ComputedLayout`] con
6//! un rect absoluto por nodo, listo para `llimphi-raster`.
7
8// `no_std` salvo bajo test. El layout es aritmética sobre un árbol: nunca tuvo
9// motivo para exigir sistema operativo, sólo la inercia de `std::collections`.
10// Es lo que permite que el mismo cálculo de cajas corra en Linux y en el kernel
11// de wawa (W1 de PLAN-LLIMPHI.md).
12#![cfg_attr(not(test), no_std)]
13
14extern crate alloc;
15
16use alloc::string::{String, ToString};
17use hashbrown::HashMap;
18
19pub use taffy;
20pub use taffy::prelude::*;
21
22/// Errores del motor de layout.
23#[derive(Debug)]
24pub enum LayoutError {
25 Taffy(String),
26}
27
28impl core::fmt::Display for LayoutError {
29 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
30 match self {
31 Self::Taffy(s) => write!(f, "taffy: {s}"),
32 }
33 }
34}
35
36// `core::error::Error` es estable desde Rust 1.81 y es LA MISMA trait que
37// `std::error::Error` (std la reexporta), así que ningún consumidor nota el
38// cambio: quien haga `Box<dyn std::error::Error>` con esto sigue compilando.
39impl core::error::Error for LayoutError {}
40
41/// Caja absoluta de un nodo (origen en la esquina superior izquierda del viewport).
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Rect {
44 pub x: f32,
45 pub y: f32,
46 pub w: f32,
47 pub h: f32,
48}
49
50/// Resultado de [`LayoutTree::compute`]: rect absoluto por nodo del árbol.
51#[derive(Debug, Default)]
52pub struct ComputedLayout {
53 pub rects: HashMap<NodeId, Rect>,
54}
55
56impl ComputedLayout {
57 pub fn get(&self, node: NodeId) -> Option<Rect> {
58 self.rects.get(&node).copied()
59 }
60
61 /// Vacía el mapa **conservando la capacidad**, para escribir el layout del
62 /// próximo frame encima del bloque que ya está tomado.
63 ///
64 /// Es la contraparte de lo que el Bloque 21 hizo con el `Mounted`: sin
65 /// esto, cada frame construye un `HashMap` de N entradas desde cero, y un
66 /// `HashMap` que crece se rehashea **a saltos** (duplica la tabla, re-inserta
67 /// todo). A 200k nodos eso medía 14 MB por frame — y era la mitad del «hilo
68 /// abierto» del plan, donde un frame suelto aparecía con 1.807 asignaciones
69 /// y 25 MB en la etapa de layout mientras el resto del lote iba en 465.
70 ///
71 /// Contrapeso, igual que en el mount: si el árbol se desploma (una tabla de
72 /// 200k filas → una pantalla vacía), conservar la capacidad vieja sería
73 /// quedarse con esos megas tomados para siempre. Se encoge sólo cuando sobra
74 /// más de 4×, dejando 2× de margen, para que una UI que oscila alrededor de
75 /// un tamaño no rebote entre encoger y volver a crecer.
76 pub fn limpiar(&mut self) {
77 let n = self.rects.len().max(1);
78 if self.rects.capacity() > 4 * n {
79 self.rects.shrink_to(2 * n);
80 }
81 self.rects.clear();
82 }
83}
84
85/// Árbol de layout. Encapsula la `TaffyTree` y la lógica de absolutización.
86pub struct LayoutTree {
87 inner: TaffyTree<()>,
88}
89
90impl Default for LayoutTree {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl LayoutTree {
97 pub fn new() -> Self {
98 Self {
99 inner: TaffyTree::new(),
100 }
101 }
102
103 /// Vacía el árbol conservando la capacidad ya asignada. Permite
104 /// reusar la misma `LayoutTree` entre frames sin re-allocar el
105 /// slotmap interno de taffy: `clear()` + `mount` en vez de
106 /// `LayoutTree::new()` por frame. Los `NodeId` emitidos antes de
107 /// `clear()` quedan inválidos (el caller ya volcó lo que necesitaba
108 /// a un `ComputedLayout`, que es dueño de sus rects).
109 pub fn clear(&mut self) {
110 self.inner.clear();
111 }
112
113 /// Crea una hoja (nodo sin hijos).
114 pub fn leaf(&mut self, style: Style) -> Result<NodeId, LayoutError> {
115 self.inner
116 .new_leaf(style)
117 .map_err(|e| LayoutError::Taffy(e.to_string()))
118 }
119
120 /// Crea un nodo contenedor con hijos.
121 pub fn node(&mut self, style: Style, children: &[NodeId]) -> Result<NodeId, LayoutError> {
122 self.inner
123 .new_with_children(style, children)
124 .map_err(|e| LayoutError::Taffy(e.to_string()))
125 }
126
127 /// Calcula el layout para `root` con viewport `(w, h)` y devuelve rects absolutos.
128 pub fn compute(
129 &mut self,
130 root: NodeId,
131 viewport: (f32, f32),
132 ) -> Result<ComputedLayout, LayoutError> {
133 self.inner
134 .compute_layout(
135 root,
136 taffy::Size {
137 width: AvailableSpace::Definite(viewport.0),
138 height: AvailableSpace::Definite(viewport.1),
139 },
140 )
141 .map_err(|e| LayoutError::Taffy(e.to_string()))?;
142 let mut out = ComputedLayout::default();
143 flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
144 Ok(out)
145 }
146
147 /// Como [`Self::compute`] pero pasando una función de medición por
148 /// nodo. Taffy la invoca sobre las **hojas** que necesita dimensionar
149 /// (texto que envuelve, contenido intrínseco) con el `NodeId`, las
150 /// dimensiones ya conocidas y el espacio disponible; el caller devuelve
151 /// el tamaño en px. Devolver `Size::ZERO` deja que el estilo decida (el
152 /// comportamiento de [`Self::compute`] para hojas sin contenido). El
153 /// `NodeId` permite al caller mantener su propio mapa nodo→contenido
154 /// (p. ej. texto a shapear con parley) sin acoplar este crate a la capa
155 /// de tipografía.
156 pub fn compute_with_measure<F>(
157 &mut self,
158 root: NodeId,
159 viewport: (f32, f32),
160 measure: F,
161 ) -> Result<ComputedLayout, LayoutError>
162 where
163 F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
164 {
165 self.compute_with_measure_reciclando(root, viewport, measure, None)
166 }
167
168 /// Igual que [`LayoutTree::compute_with_measure`], pero escribiendo el
169 /// resultado **encima del `ComputedLayout` del frame anterior** en vez de
170 /// pedir uno nuevo.
171 ///
172 /// El caller le entrega el mapa que ya no va a leer; acá se limpia
173 /// conservando capacidad (ver [`ComputedLayout::limpiar`]) y se rellena. La
174 /// fachada de arriba le pasa `None` — comportamiento histórico exacto, cero
175 /// regresión para el resto de los callers.
176 pub fn compute_with_measure_reciclando<F>(
177 &mut self,
178 root: NodeId,
179 viewport: (f32, f32),
180 mut measure: F,
181 reciclado: Option<ComputedLayout>,
182 ) -> Result<ComputedLayout, LayoutError>
183 where
184 F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
185 {
186 self.inner
187 .compute_layout_with_measure(
188 root,
189 taffy::Size {
190 width: AvailableSpace::Definite(viewport.0),
191 height: AvailableSpace::Definite(viewport.1),
192 },
193 |known, available, node_id, _ctx, _style| {
194 measure(node_id, known, available)
195 },
196 )
197 .map_err(|e| LayoutError::Taffy(e.to_string()))?;
198 let mut out = reciclado.unwrap_or_default();
199 out.limpiar();
200 flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
201 Ok(out)
202 }
203
204 pub fn inner(&self) -> &TaffyTree<()> {
205 &self.inner
206 }
207
208 pub fn inner_mut(&mut self) -> &mut TaffyTree<()> {
209 &mut self.inner
210 }
211}
212
213#[cfg(test)]
214mod cache_probe {
215 //! Bloque 24 (probe): ¿taffy saltea subárboles limpios al recomputar sobre
216 //! un árbol persistente (sin `clear()`)? De la respuesta depende todo el
217 //! enfoque de layout retenido. Se mide contando llamadas a `measure`.
218 use super::*;
219 use std::cell::Cell;
220
221 #[test]
222 fn taffy_cachea_entre_computes() {
223 let mut lt = LayoutTree::new();
224 let mut leaves = Vec::new();
225 for _ in 0..100 {
226 leaves.push(
227 lt.leaf(Style {
228 size: Size { width: auto(), height: auto() },
229 ..Default::default()
230 })
231 .unwrap(),
232 );
233 }
234 let root = lt
235 .node(
236 Style {
237 flex_direction: FlexDirection::Column,
238 size: Size { width: length(200.0), height: length(4000.0) },
239 ..Default::default()
240 },
241 &leaves,
242 )
243 .unwrap();
244
245 let count = Cell::new(0u32);
246 let mut measure = |_nid: NodeId,
247 _k: taffy::Size<Option<f32>>,
248 _a: taffy::Size<AvailableSpace>| {
249 count.set(count.get() + 1);
250 taffy::Size { width: 10.0, height: 10.0 }
251 };
252
253 lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
254 let first = count.get();
255
256 // 2ª pasada SIN mutar ni clear.
257 count.set(0);
258 lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
259 let second = count.get();
260
261 // 3ª: una sola hoja sucia.
262 lt.inner_mut().mark_dirty(leaves[0]).unwrap();
263 count.set(0);
264 lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
265 let third = count.get();
266
267 println!("[probe] measures: 1ª={first} 2ª(sin mutar)={second} 3ª(1 hoja sucia)={third}");
268 assert!(first >= 100, "la 1ª pasada mide las hojas");
269 // Premisa del Bloque 24: la 2ª pasada cachea (mide << first) y una hoja
270 // sucia sólo re-mide su cadena (third << first).
271 assert!(second < first, "2ª pasada debe cachear (second={second} < first={first})");
272 assert!(third < first, "1 hoja sucia re-mide poco (third={third} < first={first})");
273 }
274}
275
276fn flatten(
277 tree: &TaffyTree<()>,
278 node: NodeId,
279 ox: f32,
280 oy: f32,
281 out: &mut HashMap<NodeId, Rect>,
282) -> Result<(), LayoutError> {
283 let layout = tree
284 .layout(node)
285 .map_err(|e| LayoutError::Taffy(e.to_string()))?;
286 let x = ox + layout.location.x;
287 let y = oy + layout.location.y;
288 out.insert(
289 node,
290 Rect {
291 x,
292 y,
293 w: layout.size.width,
294 h: layout.size.height,
295 },
296 );
297 // `tree.children(node)` hace `self.children[node].clone()`: **una asignación
298 // de heap por cada nodo con hijos, en cada frame**, sólo para recorrerlos y
299 // tirarla. A 200k nodos en 448 columnas eso medía 448 de las 465
300 // asignaciones de la etapa de layout. Se recorre por índice, que lee el
301 // mismo `Vec` de taffy sin copiarlo.
302 let n = tree.child_count(node);
303 for i in 0..n {
304 let child = tree
305 .child_at_index(node, i)
306 .map_err(|e| LayoutError::Taffy(e.to_string()))?;
307 flatten(tree, child, x, y, out)?;
308 }
309 Ok(())
310}