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//! **Bloque 23 (`PLAN-NUCLEO-RETENIDO.md`) — `RepaintBoundary`: cacheado contra
2//! fresco, píxel a píxel.**
3//!
4//! El bloque quedó «HECHO (headless), falta PNG-diff en metal» desde julio, y
5//! la Iniciativa #2 del `PLAN-MOTOR-PROFUNDO` decía que ese follow-up se cierra
6//! **acá**, sin GPU física: el arnés `llimphi-test` rasteriza sobre el adapter
7//! de software y compara por número, que es la evidencia por texto que pide la
8//! Regla 8. Lo único que faltaba era una forma de pintar el mismo árbol de dos
9//! maneras — `Harness::render_con`.
10//!
11//! Lo que se prueba es una **igualdad**, y ésas se pasan solas si el arnés no
12//! está haciendo nada. Por eso cada test asevera además los contadores de
13//! `hits`/`misses`: sin eso, un `paint_cached` que nunca cacheara daría 0 px de
14//! diferencia y el test lo firmaría como correcto.
15
16use llimphi_compositor::llimphi_layout::taffy::{
17 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
18 Rect,
19};
20use llimphi_compositor::peniko::Color;
21use llimphi_compositor::{paint, paint_cached, RepaintCache, Shadow, View};
22use llimphi_test::{default_bg, diff, with_harness, Harness, Rendered};
23
24const W: u32 = 320;
25const H: u32 = 240;
26
27#[derive(Clone)]
28enum Msg {}
29
30/// Un subárbol con lo que de verdad rompe un caché de rasterización: gradiente,
31/// sombra, radio, borde y texto. Un rectángulo liso pasaría cualquier cosa.
32fn tarjeta(titulo: &str, color: Color) -> View<Msg> {
33 View::<Msg>::new(Style {
34 flex_direction: FlexDirection::Column,
35 size: Size { width: percent(1.0_f32), height: length(70.0_f32) },
36 padding: Rect {
37 left: length(10.0_f32),
38 right: length(10.0_f32),
39 top: length(8.0_f32),
40 bottom: length(8.0_f32),
41 },
42 ..Default::default()
43 })
44 .fill(color)
45 .hover_fill(Color::from_rgba8(240, 240, 90, 255))
46 .radius(9.0)
47 .border(1.5, Color::from_rgba8(210, 210, 230, 255))
48 .shadow(Shadow { color: Color::from_rgba8(0, 0, 0, 120), blur: 8.0, dx: 2.0, dy: 3.0, spread: 0.0 })
49 .children(vec![
50 View::<Msg>::new(Style::default()).text(titulo, 15.0, Color::WHITE),
51 View::<Msg>::new(Style::default()).text(
52 "subtítulo con descendentes: jgpqy",
53 11.0,
54 Color::from_rgba8(230, 230, 245, 255),
55 ),
56 ])
57}
58
59/// El árbol de la prueba. `marca` cambia el texto de la tarjeta **de abajo**,
60/// que NO está dentro de un boundary — así se puede mover el árbol sin tocar el
61/// subárbol cacheado.
62fn arbol(titulo_panel: &str, marca: u32) -> View<Msg> {
63 View::<Msg>::new(Style {
64 flex_direction: FlexDirection::Column,
65 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
66 gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
67 padding: Rect {
68 left: length(14.0_f32),
69 right: length(14.0_f32),
70 top: length(14.0_f32),
71 bottom: length(14.0_f32),
72 },
73 ..Default::default()
74 })
75 .fill(Color::from_rgba8(24, 24, 32, 255))
76 .children(vec![
77 // El chrome estático: justo el caso que el Bloque 23 quiere cachear.
78 tarjeta(titulo_panel, Color::from_rgba8(60, 90, 170, 255)).repaint_boundary(0xC0FFEE),
79 View::<Msg>::new(Style {
80 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
81 ..Default::default()
82 })
83 .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)),
84 tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)),
85 ])
86}
87
88fn fresco_con(h: &mut Harness, titulo: &str, marca: u32) -> Rendered {
89 h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| {
90 paint(s, m, c, ts, None, None);
91 })
92}
93
94fn cacheado_con(h: &mut Harness, titulo: &str, marca: u32, cache: &mut RepaintCache) -> Rendered {
95 h.render_con(arbol(titulo, marca), W, H, default_bg(), |s, m, c, ts| {
96 paint_cached(s, m, c, ts, None, None, cache);
97 })
98}
99
100fn fresco(h: &mut Harness, marca: u32) -> Rendered {
101 fresco_con(h, "panel quieto", marca)
102}
103
104fn cacheado(h: &mut Harness, marca: u32, cache: &mut RepaintCache) -> Rendered {
105 cacheado_con(h, "panel quieto", marca, cache)
106}
107
108/// Píxeles distintos entre dos rasterizaciones, sin tolerancia de canal.
109fn distintos(a: &Rendered, b: &Rendered) -> u64 {
110 diff(a, b, 0).expect("mismo tamaño").1.differing_pixels
111}
112
113#[test]
114fn el_subarbol_servido_del_cache_da_los_mismos_pixeles_que_el_fresco() {
115 with_harness(|h| {
116 let referencia = fresco(h, 1);
117
118 let mut cache = RepaintCache::new();
119 // Primer cuadro: el boundary no está en el caché, se rasteriza y se
120 // guarda. Tiene que ser un miss, y tiene que dar lo mismo que el fresco
121 // (acá el caché no participa del resultado, sólo lo memoriza).
122 let primero = cacheado(h, 1, &mut cache);
123 assert_eq!((cache.hits, cache.misses), (0, 1), "el primer cuadro es un miss");
124 assert_eq!(distintos(&referencia, &primero), 0, "el cuadro que puebla el caché ya difiere");
125
126 // Segundo cuadro: **ahora sí** el subárbol sale del caché, trasladado.
127 // Ésta es la comparación que el Bloque 23 debía y arrastró seis meses.
128 let segundo = cacheado(h, 1, &mut cache);
129 assert_eq!((cache.hits, cache.misses), (1, 1), "el segundo cuadro tenía que acertar");
130 assert_eq!(
131 distintos(&referencia, &segundo),
132 0,
133 "el subárbol servido del caché no es idéntico al pintado fresco"
134 );
135 });
136}
137
138#[test]
139fn el_cache_sigue_acertando_aunque_el_resto_del_arbol_cambie() {
140 // El punto entero del boundary: el chrome quieto se sirve del caché
141 // mientras lo que lo rodea se repinta. Si un cambio en cualquier parte del
142 // árbol invalidara el boundary, la optimización no ahorraría nada.
143 with_harness(|h| {
144 let mut cache = RepaintCache::new();
145 cacheado(h, 1, &mut cache);
146 for marca in 2..=5 {
147 let esperado = fresco(h, marca);
148 let obtenido = cacheado(h, marca, &mut cache);
149 assert_eq!(
150 distintos(&esperado, &obtenido),
151 0,
152 "con el contador en {marca} el cuadro cacheado no coincide con el fresco"
153 );
154 }
155 assert_eq!(
156 (cache.hits, cache.misses),
157 (4, 1),
158 "el boundary tenía que acertar en los cuatro cuadros siguientes"
159 );
160 });
161}
162
163#[test]
164fn si_el_subarbol_cacheado_cambia_el_cache_falla_y_repinta_bien() {
165 // El control que le da sentido a los otros dos: un caché que devolviera
166 // siempre lo viejo los pasaría igual, porque ahí el subárbol nunca cambia.
167 // Acá cambia, y lo que se exige es que el caché **falle** y que el
168 // resultado sea el nuevo, no el de antes.
169 with_harness(|h| {
170 let mut cache = RepaintCache::new();
171 let _ = cacheado_con(h, "panel quieto", 1, &mut cache);
172 assert_eq!(cache.misses, 1);
173
174 let esperado = fresco_con(h, "panel MOVIDO", 1);
175 let anterior = fresco_con(h, "panel quieto", 1);
176 let obtenido = cacheado_con(h, "panel MOVIDO", 1, &mut cache);
177
178 assert_eq!(cache.misses, 2, "cambió el contenido del boundary: tenía que fallar");
179 assert_eq!(cache.hits, 0, "y no puede haber acertado nada");
180 assert_eq!(distintos(&esperado, &obtenido), 0, "no repintó el contenido nuevo");
181 assert!(
182 distintos(&anterior, &obtenido) > 0,
183 "devolvió el subárbol viejo: el caché está sirviendo contenido rancio"
184 );
185 });
186}
187
188// ── El puntero adentro del subárbol cacheado ─────────────────────────────
189
190/// Índice del nodo montado que abre el boundary `key`.
191fn nodo_del_boundary<Msg>(m: &llimphi_compositor::Mounted<Msg>, key: u64) -> usize {
192 m.nodes
193 .iter()
194 .position(|n| n.repaint_boundary == Some(key))
195 .expect("el árbol tiene que traer el boundary")
196}
197
198#[test]
199fn con_el_puntero_encima_el_boundary_no_se_sirve_del_cache() {
200 // `boundary_cacheable` ya se niega a cachear un subárbol con el hover
201 // adentro, y hay un test unitario que lo dice. Lo que no había es la
202 // comprobación de que eso **se vea bien**: un caché que igual sirviera lo
203 // guardado pintaría la tarjeta sin resaltar, y el usuario vería un panel
204 // que no reacciona al puntero.
205 //
206 // El oráculo no necesita ninguna imagen bendecida: es `paint` con el mismo
207 // hover.
208 with_harness(|h| {
209 let mut cache = RepaintCache::new();
210 // Un cuadro sin hover, para que el caché tenga algo guardado que servir.
211 cacheado(h, 1, &mut cache);
212 assert_eq!((cache.hits, cache.misses), (0, 1));
213
214 let sin_hover = fresco(h, 1);
215 let esperado = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| {
216 let i = nodo_del_boundary(m, 0xC0FFEE);
217 paint(s, m, c, ts, Some(i), None);
218 });
219 let obtenido = h.render_con(arbol("panel quieto", 1), W, H, default_bg(), |s, m, c, ts| {
220 let i = nodo_del_boundary(m, 0xC0FFEE);
221 paint_cached(s, m, c, ts, Some(i), None, &mut cache);
222 });
223
224 // Guarda del arnés: si el hover no cambiara nada visible, comparar dos
225 // imágenes idénticas no probaría nada.
226 assert!(
227 distintos(&sin_hover, &esperado) > 100,
228 "el hover tiene que cambiar la tarjeta para que este test signifique algo"
229 );
230 assert_eq!(
231 distintos(&esperado, &obtenido),
232 0,
233 "con el puntero encima, el cuadro cacheado no coincide con el fresco"
234 );
235 assert_eq!(
236 cache.hits, 0,
237 "sirvió el subárbol guardado en vez de repintarlo resaltado"
238 );
239 });
240}
241
242// ── El detector que dice DÓNDE poner el boundary ─────────────────────────
243
244/// El mismo árbol pero **sin** el `repaint_boundary`: lo que escribiría alguien
245/// que todavía no sabe dónde ponerlo.
246fn arbol_pelado(marca: u32) -> View<Msg> {
247 View::<Msg>::new(Style {
248 flex_direction: FlexDirection::Column,
249 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
250 gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
251 padding: Rect {
252 left: length(14.0_f32),
253 right: length(14.0_f32),
254 top: length(14.0_f32),
255 bottom: length(14.0_f32),
256 },
257 ..Default::default()
258 })
259 .fill(Color::from_rgba8(24, 24, 32, 255))
260 .children(vec![
261 tarjeta("panel quieto", Color::from_rgba8(60, 90, 170, 255)),
262 View::<Msg>::new(Style {
263 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
264 ..Default::default()
265 })
266 .text(format!("contador: {marca}"), 13.0, Color::from_rgba8(255, 210, 120, 255)),
267 tarjeta("panel que cambia", Color::from_rgba8(150, 60, 90, 255)),
268 ])
269}
270
271#[test]
272fn lo_que_el_detector_senala_da_los_mismos_pixeles_al_cablearlo() {
273 // El detector (`DetectorBoundaries`) mira el árbol cuadro a cuadro y dice
274 // qué subárboles vienen quietos. Su promesa es doble: que puesto ahí el
275 // boundary el caché **acierta**, y que lo pintado **no cambia**. Los hits
276 // los cuenta un unit test del compositor; los píxeles sólo se pueden
277 // comparar acá, que es donde hay rasterizador.
278 //
279 // Sin esto, el detector podría señalar un subárbol que el caché no sabe
280 // reproducir y nadie se enteraría hasta ver el cuadro mal en pantalla.
281 use llimphi_compositor::{mount, DetectorBoundaries};
282 use llimphi_compositor::llimphi_layout::LayoutTree;
283
284 let mut det = DetectorBoundaries::con(3, 3);
285 let mut sugerido = None;
286 for marca in 0..4u32 {
287 let mut lt = LayoutTree::new();
288 let m = mount(&mut lt, arbol_pelado(marca));
289 let c = lt.compute(m.root, (W as f32, H as f32)).expect("layout");
290 sugerido = det.observar(&m, &c, None, None).into_iter().next();
291 }
292 let s = sugerido.expect("el detector tenía que señalar la tarjeta quieta");
293 assert!(s.nodos >= 3, "señaló un subárbol trivial: {} nodos", s.nodos);
294 assert_eq!(
295 s.texto.as_deref(),
296 Some("panel quieto"),
297 "señaló otra cosa que la tarjeta que no cambia: {s:?}"
298 );
299
300 // Se cablea donde dijo —la tarjeta de arriba, que es `arbol()`— y se compara
301 // contra el pintado fresco del árbol pelado, que es el oráculo: lo que el
302 // usuario veía ANTES de cablear nada.
303 with_harness(|h| {
304 let mut cache = RepaintCache::new();
305 for marca in 0..4u32 {
306 let esperado =
307 h.render_con(arbol_pelado(marca), W, H, default_bg(), |s, m, c, ts| {
308 paint(s, m, c, ts, None, None);
309 });
310 let obtenido = cacheado(h, marca, &mut cache);
311 assert_eq!(
312 distintos(&esperado, &obtenido),
313 0,
314 "con el contador en {marca}, cablear la sugerencia cambió lo pintado"
315 );
316 }
317 assert_eq!(
318 (cache.hits, cache.misses),
319 (3, 1),
320 "el detector prometió cache-hits y el caché no acertó"
321 );
322 });
323}