tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1use super::*;
2
3// ─────────────────────────────────────────────────────────────────────────
4// Bloque 24 — Layout retenido (mount incremental). El único motivo por el que
5// llimphi no aprovecha el caché de taffy es el `clear()` por frame (ids nuevos =
6// caché fría). `mount_incremental` reusa los `NodeId` del frame anterior EN EL
7// MISMO orden pre-orden cuando la **forma** del árbol no cambió, y sólo dirtea
8// lo que cambió (estilo → set_style dentro de mount_recursive; medida de texto →
9// mark_dirty aquí), dejando limpio el resto para que taffy saltee su re-solve.
10//
11// Correctitud demostrada por el arnés de equivalencia (fuzz retenido==fresco).
12// Fail-safe: si la forma cambió, cae a `clear()`+rebuild = camino histórico.
13// ─────────────────────────────────────────────────────────────────────────
14
15/// Estado retenido entre frames para el layout incremental. Opaco para el
16/// runtime: lo guarda y lo pasa de vuelta. Ver [`mount_incremental`].
17#[derive(Clone)]
18pub struct RetainedLayout {
19 root: NodeId,
20 /// `NodeId` en pre-orden (índice = orden en `Mounted::nodes`).
21 ids: Vec<NodeId>,
22 /// Cantidad de hijos por nodo, en pre-orden — la **firma de forma**.
23 child_counts: Vec<usize>,
24 /// Firma de medida por nodo: `Some(h)` compara por hash; `None` = "siempre
25 /// re-medir" (nodos con `spans`, conservador). Ver [`measure_sig_of`].
26 measure_sig: Vec<Option<u64>>,
27 /// Los dos búferes de trabajo del frame en curso, guardados acá **para no
28 /// pedirlos de nuevo cada cuadro**.
29 ///
30 /// La firma de forma y la de medida se calculan enteras en cada frame para
31 /// compararlas contra las del anterior, y hasta 2026-08-04 eran dos `Vec`
32 /// frescos por frame — a 200k nodos, 1,6 MB y 3,2 MB que iban y venían del
33 /// allocator sin que su contenido sobreviviera al cuadro. Ahora se calculan
34 /// acá dentro y se intercambian con las buenas (`mem::swap`), así el bloque
35 /// que queda libre es el que se reusa la próxima vez. Es el mismo lever del
36 /// `mount_pool` y del `layout_pool`, aplicado donde faltaba.
37 scratch_counts: Vec<usize>,
38 scratch_sig: Vec<Option<u64>>,
39 /// La pila de ids que comparte todo el recorrido del mount — ver
40 /// [`mount_recursive`]. Vive acá por lo mismo que las otras dos: para que su
41 /// bloque sobreviva al frame.
42 scratch_ids: Vec<NodeId>,
43}
44
45impl RetainedLayout {
46 /// `NodeId` raíz del árbol retenido (para `compute`).
47 pub fn root(&self) -> NodeId {
48 self.root
49 }
50
51 /// El envase vacío del primer frame. No es `Default` a propósito: un
52 /// `RetainedLayout` sin árbol detrás no es un estado válido para reusar, y
53 /// el único que puede fabricarlo es el mount, que sabe distinguirlo con su
54 /// `tenia_prev`.
55 fn vacio() -> Self {
56 Self {
57 root: NodeId::new(0),
58 ids: Vec::new(),
59 child_counts: Vec::new(),
60 measure_sig: Vec::new(),
61 scratch_counts: Vec::new(),
62 scratch_sig: Vec::new(),
63 scratch_ids: Vec::new(),
64 }
65 }
66}
67
68/// Firma de medida de un nodo: `Some(0)` = no lo mide taffy (sizing por Style o
69/// texto con `runs`, dimensionado por el caller) → nunca necesita re-medida.
70/// `Some(hash)` = texto uniforme, compara por hash de los campos que afectan el
71/// shaping/line-break (el `color` NO entra). `None` = texto con `spans` →
72
73/// El hasher de las memoizaciones: **determinista y sin sistema operativo**.
74///
75/// `std::collections::hash_map::DefaultHasher` no existe sin `std`, y el
76/// `DefaultHashBuilder` de hashbrown va sembrado al azar por proceso. Ninguno de
77/// los dos sirve acá: estos hashes comparan el MISMO subárbol entre fotogramas,
78/// así que tienen que dar lo mismo siempre. `FixedState` lo garantiza.
79pub(crate) fn hasher_memo() -> impl core::hash::Hasher {
80 use core::hash::BuildHasher;
81 foldhash::fast::FixedState::default().build_hasher()
82}
83
84/// conservador: se re-mide siempre (spans son raros; evita un sig frágil).
85pub(crate) fn measure_sig_of<Msg>(node: &MountedNode<Msg>) -> Option<u64> {
86 let Some(t) = node.text.as_ref() else {
87 return Some(0);
88 };
89 if t.runs.is_some() {
90 return Some(0);
91 }
92 if t.spans.is_some() {
93 return None;
94 }
95 let mut h = hasher_memo();
96 t.content.hash(&mut h);
97 t.size_px.to_bits().hash(&mut h);
98 (t.alignment as u8).hash(&mut h);
99 t.italic.hash(&mut h);
100 t.weight.to_bits().hash(&mut h);
101 t.line_height.to_bits().hash(&mut h);
102 t.max_lines.hash(&mut h);
103 t.ellipsis.hash(&mut h);
104 t.font_family.hash(&mut h);
105 t.letter_spacing.to_bits().hash(&mut h);
106 t.word_spacing.to_bits().hash(&mut h);
107 t.no_wrap.hash(&mut h);
108 t.overflow_wrap.hash(&mut h);
109 t.font_features.hash(&mut h);
110 t.font_variations.hash(&mut h);
111 t.font_width.to_bits().hash(&mut h);
112 Some(h.finish())
113}
114
115/// Firma de forma del `View` (cantidad de hijos por nodo, en pre-orden). Es una
116/// pasada read-only que NO consume el árbol; se compara contra la retenida para
117/// decidir reuso vs rebuild.
118/// Contrapeso del reciclado de buffers: si el árbol se achicó **mucho** (una app
119/// que pasa de una tabla de 200k filas a una pantalla vacía), conservar la
120/// capacidad vieja sería quedarse con cientos de MB tomados para siempre. Se
121/// encoge sólo cuando sobra más de 4× y se deja 2× de margen, así una UI que
122/// oscila alrededor de un tamaño no rebota entre encoger y volver a crecer.
123pub(crate) fn encoger_si_sobra<Msg>(nodes: &mut Vec<MountedNode<Msg>>) {
124 let n = nodes.len().max(1);
125 if nodes.capacity() > 4 * n {
126 nodes.shrink_to(2 * n);
127 }
128}
129
130pub(crate) fn view_child_counts<Msg>(v: &View<Msg>, out: &mut Vec<usize>) {
131 out.push(v.children.len());
132 for c in &v.children {
133 view_child_counts(c, out);
134 }
135}
136
137/// Monta `view` reusando el layout retenido cuando la **forma** no cambió. En
138/// ese caso NO se limpia el árbol taffy: se reusan los `NodeId`, se re-estila
139/// sólo lo cambiado y se marcan dirty los textos cuya medida cambió — así taffy
140/// cachea el resto. Si la forma cambió (o no hay estado previo), cae a
141/// `clear()`+rebuild (idéntico al camino histórico). Devuelve el `Mounted`, el
142/// nuevo `RetainedLayout` (para el próximo frame) y si hubo reuso.
143pub fn mount_incremental<Msg: Clone>(
144 layout: &mut LayoutTree,
145 view: View<Msg>,
146 prev: Option<&RetainedLayout>,
147) -> (Mounted<Msg>, RetainedLayout, bool) {
148 // Clona el retenido porque la variante que recicla lo consume. No es una
149 // regresión: esta fachada ya pagaba tres `Vec` nuevos por llamada (la firma
150 // de forma, la de medida y un `ids.clone()`), que es exactamente lo que
151 // cuesta el clon. Quien quiera el ahorro usa la variante `_reciclando`, que
152 // es lo que hace el runtime.
153 mount_incremental_reciclando(layout, view, prev.cloned(), None)
154}
155
156/// Igual que [`mount_incremental`], pero **reusando los buffers** del `Mounted`
157/// del frame anterior en vez de pedir otros nuevos.
158///
159/// Por qué existe (Bloque 21, baseline de allocs 2026-07-27): el heap por frame
160/// es `nodos × sizeof(MountedNode)` — 432 MB por frame con 200k nodos, en **una
161/// sola** asignación gigante. `clear()` conserva la capacidad, así que el frame
162/// siguiente escribe encima en vez de pedirle al allocator otro bloque de ese
163/// tamaño. No ahorra las escrituras (hay que llenar los 2.160 B por nodo igual),
164/// ahorra el `malloc`/`free`: a ese porte lo sirve `mmap`, y cada frame se
165/// pagaba en fallos de página y zeroing del kernel. De paso baja el **pico** de
166/// memoria, porque antes el buffer viejo seguía vivo mientras se llenaba el
167/// nuevo — dos árboles enteros en RAM a la vez.
168///
169/// El `Mounted` que se pasa queda consumido: sus nodos se dropean acá (el mismo
170/// trabajo que antes hacía el drop del frame viejo, sólo que adelantado). Pasarle
171/// `None` da el comportamiento histórico exacto.
172pub fn mount_incremental_reciclando<Msg: Clone>(
173 layout: &mut LayoutTree,
174 view: View<Msg>,
175 prev: Option<RetainedLayout>,
176 reciclado: Option<Mounted<Msg>>,
177) -> (Mounted<Msg>, RetainedLayout, bool) {
178 // El retenido entra **por valor** para poder escribir encima de sus búferes
179 // en vez de devolver tres `Vec` nuevos por frame. `tenia_prev` conserva la
180 // distinción que antes daba el `Option`: sin estado previo no hay reuso
181 // posible, aunque el default traiga los vectores vacíos.
182 let tenia_prev = prev.is_some();
183 let mut ret = prev.unwrap_or_else(RetainedLayout::vacio);
184 let mut child_counts = core::mem::take(&mut ret.scratch_counts);
185 child_counts.clear();
186 view_child_counts(&view, &mut child_counts);
187 let can_reuse = tenia_prev && ret.child_counts == child_counts;
188
189 let (mut nodes, mut text_measures, mut intrinsic_measures) = match reciclado {
190 Some(m) => {
191 let Mounted { mut nodes, mut text_measures, mut intrinsic_measures, .. } = m;
192 nodes.clear();
193 text_measures.clear();
194 intrinsic_measures.clear();
195 (nodes, text_measures, intrinsic_measures)
196 }
197 None => (Vec::new(), hashbrown::HashMap::new(), hashbrown::HashMap::new()),
198 };
199
200 // La firma de medida del frame en curso, sobre el búfer reusado. Se llena
201 // en los dos caminos y al final se intercambia con la buena, así el bloque
202 // que queda suelto es el que se reusa el frame que viene.
203 let mut measure_sig = core::mem::take(&mut ret.scratch_sig);
204 measure_sig.clear();
205 let mut ids_scratch = core::mem::take(&mut ret.scratch_ids);
206 ids_scratch.clear();
207 if can_reuse {
208 // Reuso: sin clear. `set_style` se hace dentro de mount_recursive donde
209 // el estilo cambió (deja limpio el resto).
210 //
211 // `ret.ids` se presta inmutable acá y NO se reconstruye después: antes
212 // el retenido nuevo lo clonaba entero (`p.ids.clone()`, 1,6 MB por frame
213 // a 200k nodos) para guardar exactamente lo mismo que ya tenía.
214 let root = mount_recursive(
215 layout,
216 view,
217 &mut nodes,
218 &mut text_measures,
219 &mut intrinsic_measures,
220 &MountPlan::Reuse(&ret.ids),
221 &mut ids_scratch,
222 );
223 // Post-pasada de medida: un texto puede cambiar de tamaño sin cambiar su
224 // `Style` (el contenido no está en el Style). Marcamos dirty esos nodos
225 // para que taffy los re-mida; el resto queda cacheado.
226 measure_sig.reserve(nodes.len());
227 for (i, n) in nodes.iter().enumerate() {
228 let sig = measure_sig_of(n);
229 let prev_sig = ret.measure_sig.get(i).copied().flatten();
230 // `None` (spans) ⇒ siempre dirty. `Some(h)` ⇒ dirty si cambió.
231 let dirty = match sig {
232 None => true,
233 Some(h) => prev_sig != Some(h),
234 };
235 if dirty {
236 let _ = layout.inner_mut().mark_dirty(n.id);
237 }
238 measure_sig.push(sig);
239 }
240 ret.root = root;
241 ret.scratch_ids = ids_scratch;
242 intercambiar_retenido(&mut ret, child_counts, measure_sig);
243 encoger_si_sobra(&mut nodes);
244 (Mounted { root, nodes, text_measures, intrinsic_measures }, ret, true)
245 } else {
246 // Rebuild fresco (camino histórico). NodeIds nuevos.
247 layout.clear();
248 let root = mount_recursive(
249 layout,
250 view,
251 &mut nodes,
252 &mut text_measures,
253 &mut intrinsic_measures,
254 &MountPlan::Fresh,
255 &mut ids_scratch,
256 );
257 ret.ids.clear();
258 ret.ids.extend(nodes.iter().map(|n| n.id));
259 measure_sig.extend(nodes.iter().map(measure_sig_of));
260 ret.root = root;
261 ret.scratch_ids = ids_scratch;
262 intercambiar_retenido(&mut ret, child_counts, measure_sig);
263 encoger_si_sobra(&mut nodes);
264 (Mounted { root, nodes, text_measures, intrinsic_measures }, ret, false)
265 }
266}
267
268/// Deja las firmas nuevas como las buenas y las viejas como búferes de trabajo.
269/// Los cuatro `Vec` involucrados son los mismos dos bloques de memoria dando
270/// vueltas frame tras frame — que es todo el punto.
271pub(crate) fn intercambiar_retenido(
272 ret: &mut RetainedLayout,
273 mut counts: Vec<usize>,
274 mut sig: Vec<Option<u64>>,
275) {
276 core::mem::swap(&mut ret.child_counts, &mut counts);
277 core::mem::swap(&mut ret.measure_sig, &mut sig);
278 ret.scratch_counts = counts;
279 ret.scratch_sig = sig;
280}
281