tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-layout/src/lib.rs Rust · 310 lines · 10.9 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}