tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1use super::*;
2
3// ─────────────────────────────────────────────────────────────────────────
4// RepaintBoundary — caché de rasterización de subárboles (Bloque 23 de
5// PLAN-NUCLEO-RETENIDO.md). Ver `View::repaint_boundary`.
6//
7// Diseño *fail-safe*: `paint_range` queda INTACTO (cero regresión para todo el
8// que no use boundaries). Toda la lógica vive en `paint_cached`, un wrapper que
9// pinta los tramos no-boundary con el mismo `paint_range` de siempre e intercala
10// el append (cacheado o fresco) de cada boundary. Ante cualquier duda sobre si
11// un subárbol es cacheable, se pinta fresco → nunca produce un frame incorrecto.
12// ─────────────────────────────────────────────────────────────────────────
13
14/// Caché de rasterización de los [`View::repaint_boundary`]. Vive en el runtime
15/// entre frames. Cada entrada guarda la sub-escena vello del subárbol en coords
16/// **locales** al origen del boundary + un hash de su contenido; mientras el
17/// hash no cambie se reusa (`scene.append` trasladado al origen actual), así
18/// sobrevive a un scroll/translación del subárbol sin re-pintar.
19#[derive(Default)]
20pub struct RepaintCache {
21 entries: HashMap<u64, CachedBoundary>,
22 seen: hashbrown::HashSet<u64>,
23 /// Aciertos de caché acumulados (telemetría / tests).
24 pub hits: u64,
25 /// Fallos de caché acumulados (telemetría / tests).
26 pub misses: u64,
27}
28
29pub(crate) struct CachedBoundary {
30 scene: Lienzo,
31 hash: u64,
32}
33
34impl RepaintCache {
35 pub fn new() -> Self {
36 Self::default()
37 }
38 /// Entradas vivas.
39 pub fn len(&self) -> usize {
40 self.entries.len()
41 }
42 pub fn is_empty(&self) -> bool {
43 self.entries.is_empty()
44 }
45 /// Vacía el caché (p. ej. al cambiar el viewport o el tema).
46 pub fn clear(&mut self) {
47 self.entries.clear();
48 self.seen.clear();
49 }
50 fn begin_frame(&mut self) {
51 self.seen.clear();
52 }
53 /// Descarta las entradas cuyo boundary no apareció este frame.
54 fn gc(&mut self) {
55 let seen = &self.seen;
56 self.entries.retain(|k, _| seen.contains(k));
57 }
58}
59
60/// `true` si algún nodo del árbol declara `repaint_boundary` (el runtime lo usa
61/// para decidir si vale la pena `paint_cached` o alcanza con `paint`).
62pub fn has_repaint_boundary<Msg>(mounted: &Mounted<Msg>) -> bool {
63 mounted.nodes.iter().any(|n| n.repaint_boundary.is_some())
64}
65
66/// ¿El nodo abre una capa vello / transform / scrollport que un `scene.append`
67/// trasladado NO podría reproducir si fuera ancestro de un boundary? Si es
68/// ancestro de un boundary, ese boundary NO está en posición "limpia".
69pub(crate) fn opens_layer<Msg>(n: &MountedNode<Msg>) -> bool {
70 n.clip
71 || n.alpha.is_some()
72 || n.blend.is_some()
73 || n.transform.is_some()
74 || n.transform_rel.is_some()
75 || n.mask_image.is_some()
76 || n.sticky.is_some()
77 || n.over_layer
78 || n.clip_inset.is_some()
79 || n.clip_ellipse.is_some()
80 || n.clip_polygon.is_some()
81 || n.clip_path_svg.is_some()
82 || n.clip_ref_inset.is_some()
83}
84
85/// Recolecta los `repaint_boundary` en posición **limpia** (sin capa ancestral
86/// abierta), en pre-orden. Un boundary bajo un transform/clip/alpha NO se
87/// recolecta → cae a un tramo normal y se pinta fresco.
88pub(crate) fn collect_clean_boundaries<Msg>(mounted: &Mounted<Msg>) -> Vec<(usize, usize, u64)> {
89 let mut out = Vec::new();
90 let mut layer_ends: Vec<usize> = Vec::new();
91 for idx in 0..mounted.nodes.len() {
92 while layer_ends.last().is_some_and(|&e| idx >= e) {
93 layer_ends.pop();
94 }
95 let node = &mounted.nodes[idx];
96 if let Some(key) = node.repaint_boundary {
97 if layer_ends.is_empty() {
98 out.push((idx, node.subtree_end, key));
99 }
100 }
101 // El propio boundary puede abrir su capa (clip/alpha propios): se registra
102 // DESPUÉS de recolectarlo (su capa es interna a su sub-escena).
103 if opens_layer(node) {
104 layer_ends.push(node.subtree_end);
105 }
106 }
107 out
108}
109
110/// ¿El subárbol `[idx, end)` es cacheable? Paint-puro (sin closures de pintado
111/// ni contenido dinámico) y sin el nodo hovereado/drop adentro. Conservador:
112/// ante la duda `false` (se pinta fresco).
113///
114/// **Gradiente e imagen entraron en 2026-08-05.** Quedaban fuera «porque no se
115/// hashean de forma barata», y eso dejaba al caché sin el caso que lo motivaba:
116/// un sidebar real tiene un degradé de fondo o un ícono, y **un solo nodo con
117/// imagen en el subárbol lo descalificaba entero**. La imagen se hashea por el
118/// **id del `Blob`** —un contador global de peniko, O(1)— y no por sus píxeles;
119/// dos blobs distintos con los mismos bytes dan ids distintos y eso es un miss
120/// de más, nunca un hit indebido. El gradiente usa el `bit_hash` **de peniko**
121/// para los stops, que es la definición upstream de «mismo gradiente».
122///
123/// La **máscara** sigue afuera, y a propósito: su `MaskPlacement` es un nido de
124/// enums (size/pos/repeat/mode/clip-inset/origin-inset) cuya superficie de hash
125/// es más grande que lo que compra —una `mask-image` en chrome estático es rara—
126/// y cada campo que se olvidara sería un hit indebido, o sea un cuadro mal.
127pub(crate) fn boundary_cacheable<Msg>(
128 mounted: &Mounted<Msg>,
129 idx: usize,
130 end: usize,
131 hover_idx: Option<usize>,
132 drop_hover_idx: Option<usize>,
133) -> bool {
134 if hover_idx.is_some_and(|h| h >= idx && h < end) {
135 return false;
136 }
137 if drop_hover_idx.is_some_and(|d| d >= idx && d < end) {
138 return false;
139 }
140 mounted.nodes[idx..end].iter().all(|n| {
141 n.painter.is_none()
142 && n.gpu_painter.is_none()
143 && n.over_painter.is_none()
144 && n.mask_image.is_none()
145 && n.anim.is_none()
146 && n.ripple.is_none()
147 && n.hero.is_none()
148 })
149}
150
151pub(crate) fn hash_color(c: &Color, h: &mut impl Hasher) {
152 for comp in c.components {
153 comp.to_bits().hash(h);
154 }
155}
156
157/// Hash de un gradiente. Los stops van por el `bit_hash` **de peniko**: es la
158/// definición upstream de «mismo gradiente», así que no hay una segunda idea
159/// nuestra que pueda quedar corta cuando peniko sume un campo.
160pub(crate) fn hash_gradient(g: &crate::peniko::Gradient, h: &mut impl Hasher) {
161 use crate::peniko::color::cache_key::BitHash;
162 use crate::peniko::GradientKind;
163 core::mem::discriminant(&g.kind).hash(h);
164 let mut punto = |x: f64, y: f64| {
165 x.to_bits().hash(h);
166 y.to_bits().hash(h);
167 };
168 match &g.kind {
169 GradientKind::Linear(l) => {
170 punto(l.start.x, l.start.y);
171 punto(l.end.x, l.end.y);
172 }
173 GradientKind::Radial(r) => {
174 punto(r.start_center.x, r.start_center.y);
175 punto(r.end_center.x, r.end_center.y);
176 punto(r.start_radius as f64, r.end_radius as f64);
177 }
178 GradientKind::Sweep(sw) => {
179 punto(sw.center.x, sw.center.y);
180 punto(sw.start_angle as f64, sw.end_angle as f64);
181 }
182 }
183 (g.extend as u8).hash(h);
184 core::mem::discriminant(&g.interpolation_cs).hash(h);
185 core::mem::discriminant(&g.hue_direction).hash(h);
186 core::mem::discriminant(&g.interpolation_alpha_space).hash(h);
187 g.stops.bit_hash(h);
188}
189
190/// Hash de una imagen: **el id del `Blob`**, no sus píxeles.
191///
192/// El id es un contador global de peniko que se asigna al construir el blob, así
193/// que es O(1) y sobrevive a los clones —que es como una app pasa la misma
194/// imagen cuadro a cuadro—. El error posible es en la dirección segura: dos
195/// blobs distintos con los mismos bytes dan ids distintos y eso es un **miss de
196/// más**, un repintado gratis, nunca un hit indebido.
197pub(crate) fn hash_image(img: &Image, h: &mut impl Hasher) {
198 img.image.data.id().hash(h);
199 (img.image.format as u8).hash(h);
200 (img.image.alpha_type as u8).hash(h);
201 img.image.width.hash(h);
202 img.image.height.hash(h);
203 // El sampler cambia lo pintado sin cambiar un byte del blob.
204 (img.sampler.x_extend as u8).hash(h);
205 (img.sampler.y_extend as u8).hash(h);
206 (img.sampler.quality as u8).hash(h);
207 img.sampler.alpha.to_bits().hash(h);
208}
209
210/// Hash del contenido pintable del subárbol `[idx, end)`, con los rects
211/// **relativos al origen** — así una traslación pura del boundary (scroll) deja
212/// el hash igual y es cache-hit. Cubre las variantes que `boundary_cacheable`
213/// deja pasar (fill/text/borde/sombra/radio/alpha/clip/transform propios).
214pub(crate) fn boundary_hash<Msg>(
215 mounted: &Mounted<Msg>,
216 computed: &ComputedLayout,
217 idx: usize,
218 end: usize,
219 origin: (f32, f32),
220) -> u64 {
221 let mut h = hasher_memo();
222 (end - idx).hash(&mut h);
223 for i in idx..end {
224 let n = &mounted.nodes[i];
225 match computed.get(n.id) {
226 Some(r) => {
227 (r.x - origin.0).to_bits().hash(&mut h);
228 (r.y - origin.1).to_bits().hash(&mut h);
229 r.w.to_bits().hash(&mut h);
230 r.h.to_bits().hash(&mut h);
231 }
232 None => u32::MAX.hash(&mut h),
233 }
234 n.fill.is_some().hash(&mut h);
235 if let Some(c) = &n.fill {
236 hash_color(c, &mut h);
237 }
238 n.radius.to_bits().hash(&mut h);
239 if let Some(cr) = &n.corner_radii {
240 for v in [cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left] {
241 v.to_bits().hash(&mut h);
242 }
243 }
244 if let Some(b) = &n.border {
245 b.width.to_bits().hash(&mut h);
246 hash_color(&b.color, &mut h);
247 }
248 if let Some(s) = &n.shadow {
249 hash_color(&s.color, &mut h);
250 s.blur.to_bits().hash(&mut h);
251 s.spread.to_bits().hash(&mut h);
252 s.dx.to_bits().hash(&mut h);
253 s.dy.to_bits().hash(&mut h);
254 }
255 if let Some(a) = n.alpha {
256 a.to_bits().hash(&mut h);
257 }
258 n.clip.hash(&mut h);
259 if let Some(t) = n.transform {
260 for c in t.as_coeffs() {
261 c.to_bits().hash(&mut h);
262 }
263 }
264 if let Some((x, y)) = n.transform_rel {
265 x.to_bits().hash(&mut h);
266 y.to_bits().hash(&mut h);
267 }
268 if let Some(g) = &n.fill_gradient {
269 hash_gradient(g, &mut h);
270 n.fill_gradient_superficie.hash(&mut h);
271 }
272 if let Some(img) = &n.image {
273 hash_image(img, &mut h);
274 // El encaje decide la transformación con la que se pinta: dos
275 // `Contain`/`Cover` sobre la misma imagen dan cuadros distintos.
276 n.image_fit.map(|f| f as u8).hash(&mut h);
277 }
278 if let Some(t) = &n.text {
279 t.content.hash(&mut h);
280 t.size_px.to_bits().hash(&mut h);
281 hash_color(&t.color, &mut h);
282 t.weight.to_bits().hash(&mut h);
283 t.line_height.to_bits().hash(&mut h);
284 t.italic.hash(&mut h);
285 (t.alignment as u8).hash(&mut h);
286 }
287 }
288 h.finish()
289}
290
291/// Como [`paint`], pero **cachea la rasterización** de los subárboles marcados
292/// con [`View::repaint_boundary`] que estén en posición limpia y sean
293/// paint-puros. El resto del árbol (y los boundaries no cacheables) se pinta
294/// idéntico a `paint`. Si el árbol no tiene boundaries, delega en `paint` —
295/// coste ~0.
296pub fn paint_cached<Msg>(
297 scene: &mut Lienzo,
298 mounted: &Mounted<Msg>,
299 computed: &ComputedLayout,
300 typesetter: &mut llimphi_text::Typesetter,
301 hover_idx: Option<usize>,
302 drop_hover_idx: Option<usize>,
303 cache: &mut RepaintCache,
304) {
305 cache.begin_frame();
306 let boundaries = collect_clean_boundaries(mounted);
307 if boundaries.is_empty() {
308 cache.gc();
309 paint(scene, mounted, computed, typesetter, hover_idx, drop_hover_idx);
310 return;
311 }
312 let n = mounted.nodes.len();
313 let mut cursor = 0usize;
314 for (bidx, bend, key) in boundaries {
315 // Subsumido por un boundary externo ya procesado.
316 if bidx < cursor {
317 continue;
318 }
319 // Tramo previo al boundary: pintura normal.
320 if cursor < bidx {
321 paint_range(
322 scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, bidx,
323 Affine::IDENTITY,
324 );
325 }
326 let origin = computed
327 .get(mounted.nodes[bidx].id)
328 .map(|r| (r.x, r.y))
329 .unwrap_or((0.0, 0.0));
330 if boundary_cacheable(mounted, bidx, bend, hover_idx, drop_hover_idx) {
331 let hash = boundary_hash(mounted, computed, bidx, bend, origin);
332 let hit = cache
333 .entries
334 .get(&key)
335 .is_some_and(|c| c.hash == hash);
336 if hit {
337 cache.hits += 1;
338 } else {
339 cache.misses += 1;
340 // Rasterizá el subárbol a una sub-escena en coords LOCALES.
341 let mut sub = crate::Lienzo::new();
342 paint_range(
343 &mut sub, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend,
344 Affine::translate((-origin.0 as f64, -origin.1 as f64)),
345 );
346 cache.entries.insert(key, CachedBoundary { scene: sub, hash });
347 }
348 cache.seen.insert(key);
349 // Append trasladado al origen actual (sobrevive al scroll).
350 if let Some(c) = cache.entries.get(&key) {
351 scene.append(
352 &c.scene,
353 Some(Affine::translate((origin.0 as f64, origin.1 as f64))),
354 );
355 }
356 } else {
357 // No cacheable → fresco inline (idéntico a `paint`).
358 paint_range(
359 scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend,
360 Affine::IDENTITY,
361 );
362 }
363 cursor = bend;
364 }
365 // Cola tras el último boundary.
366 if cursor < n {
367 paint_range(
368 scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, n,
369 Affine::IDENTITY,
370 );
371 }
372 cache.gc();
373}
374