tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ e2102d6057046fade7d44f45092cc01cfd05b5e7
llimphi-ui/src/eventloop/redraw.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1// redraw.rs — Ciclo completo de repintado: layout → paint → GPU → present.
2// Esta función es la ruta caliente: mount + compute + animaciones + vello +
3// pasadas GPU + present + retención de frame.
4
5use super::super::*;
6use super::helpers::{
7 build_selectable_layout, resolve_layout_builders, selectable_node_in,
8};
9use super::push_a11y_tree;
10
11/// Ejecuta la pasada completa de redraw para la ventana primaria.
12/// Se llama desde `handle_primary_window_event` cuando el evento es
13/// `WindowEvent::RedrawRequested`. Recibe `state` y `handle` separados para
14/// facilitar el borrow-checker (no necesita `&mut Runtime<A>` completo).
15pub(super) fn handle_redraw<A: App>(
16 state: &mut RuntimeState<A>,
17 handle: &Handle<A::Msg>,
18) {
19 // **Retención de frame entero**. Si:
20 // (a) hay scene retenida del frame anterior (`retained`),
21 // (b) `last_render` SIGUE siendo `Some` — la invariante del
22 // runtime es que cualquier handler que muta visualmente
23 // pone `last_render = None`, así que `Some` ⇒ nadie tocó
24 // nada que afecte la pintura,
25 // (c) el frame retenido NO estaba animando ni ripplando
26 // (si lo estaba, el ticker NECESITA avanzarlo),
27 // (d) no hay overlay, drag, ni long-press en curso (camino
28 // conservador: esos casos suelen estar acoplados a
29 // cambios visuales que no atraviesan `last_render`),
30 // (e) el viewport sigue del mismo tamaño,
31 // entonces `state.scene` ya tiene EXACTAMENTE lo que hay que
32 // mostrar. Saltamos mount + layout + paint y solo hacemos un
33 // render+present de la scene retenida. Cubre redraws espurios
34 // (expose del compositor, refocus, el último frame de una anim
35 // ya asentada). Si algo falla en el acquire, caemos al camino
36 // completo (no es un error, sólo un viewport efímero).
37 let cache_hit = state.last_render.is_some()
38 && state.drag.is_none()
39 && state.pending_long_press.is_none()
40 && state.retained.as_ref().is_some_and(|r| {
41 !r.animating
42 && !r.rippling
43 && !r.has_overlay
44 && (r.w, r.h) == state.surface.size()
45 });
46 if cache_hit {
47 match state.surface.acquire() {
48 Ok(frame) => {
49 if state
50 .renderer
51 .render(&state.hal, &state.scene, &frame, palette::css::BLACK)
52 .is_ok()
53 {
54 state.surface.present(frame, &state.hal);
55 return;
56 }
57 // render falló → cae al camino completo
58 }
59 Err(_) => { /* surface efímera → camino completo */ }
60 }
61 }
62 // Título dinámico (App::window_title): si cambió respecto del
63 // último aplicado, se lo pasamos a winit. Barato: una
64 // comparación de String por frame, set_title sólo en el cambio.
65 if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
66 if state.last_title.as_deref() != Some(t.as_str()) {
67 state.window.set_title(&t);
68 state.last_title = Some(t);
69 }
70 }
71 // Posicioná la ventana de candidatos del IME junto al caret
72 // (sólo con IME activo y si la app reporta el área).
73 if A::ime_allowed() {
74 if let Some((x, y, w, h)) =
75 A::ime_cursor_area(state.model.as_ref().expect("model"))
76 {
77 state.window.set_ime_cursor_area(
78 llimphi_hal::winit::dpi::PhysicalPosition::new(x as f64, y as f64),
79 llimphi_hal::winit::dpi::PhysicalSize::new(
80 w.max(1.0) as u32,
81 h.max(1.0) as u32,
82 ),
83 );
84 }
85 }
86 let frame = match state.surface.acquire() {
87 Ok(f) => f,
88 Err(_) => {
89 let (w, h) = state.surface.size();
90 state.surface.resize(w, h);
91 state.window.request_redraw();
92 return;
93 }
94 };
95 let (w, h) = frame.size();
96 // LayoutBuilder: resuelve los constructores diferidos en dos
97 // pasadas (coste cero si no hay ninguno). Necesita el typesetter
98 // para medir, así que va antes de tomar `model_ref` para el overlay.
99 let mut view = resolve_layout_builders::<A>(
100 state.model.as_ref().expect("model"),
101 (w as f32, h as f32),
102 &mut state.typesetter,
103 );
104 // Animaciones implícitas de **tamaño** (`View::animated_size`):
105 // reconcila el `View` tree y parcha `style.size` ANTES del
106 // mount/layout. Así siblings/hijos reflowean suave (la
107 // animación se ve en el layout cascade, no sólo en el rect del
108 // nodo aislado). Coste cero sin nodos `animated_size`.
109 let frame_now = std::time::Instant::now();
110 let size_animating = llimphi_compositor::reconcile_size_anim(
111 &mut view,
112 &mut state.size_anim_registry,
113 frame_now,
114 );
115 let model_ref = state.model.as_ref().expect("model");
116 let overlay_view = A::view_overlay(model_ref);
117 // Reusamos los árboles de layout del runtime: `clear()` +
118 // `mount` evita re-allocar el slotmap de taffy por frame.
119 state.layout.clear();
120 let mut mounted: Mounted<A::Msg> = mount(&mut state.layout, view);
121 let computed = {
122 let ts = &mut state.typesetter;
123 let tmap = &mounted.text_measures;
124 state
125 .layout
126 .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
127 match tmap.get(&nid) {
128 Some(tm) => measure_text_node(ts, tm, known, avail),
129 None => llimphi_layout::taffy::Size::ZERO,
130 }
131 })
132 .expect("layout")
133 };
134 // Animaciones implícitas (`View::animated`): reconcilia el árbol
135 // con el estado retenido DESPUÉS del layout y ANTES del paint —
136 // interpola fill/radius de los nodos con `anim`. Si alguna sigue
137 // viva pedimos otro frame al final (ticker autodetenido).
138 let now = frame_now;
139 let anim_active = state.anim_registry.reconcile(&mut mounted, now);
140 // Heroes (`View::hero`): si la misma key cambió de rect entre
141 // frames, escribe en `transform` la afín que "vuela" del rect
142 // anterior al actual. Independiente del anim_registry — sólo
143 // toca `transform`, que el paint ya respeta. Coste cero sin
144 // nodos hero.
145 let hero_active = state.hero_registry.reconcile(&mut mounted, &computed, now);
146 // `size_animating` viene del reconcile previo al mount; lo
147 // ORrijimos al `animating` global para que se pida el
148 // próximo frame y el `retained.animating == true` invalide
149 // la cache de retención (la siguiente pasada reconstruye con
150 // el size interpolado).
151 let animating = anim_active || hero_active || size_animating;
152 // Mount + layout del overlay en un árbol aparte. Lo
153 // computamos con el mismo tamaño de viewport para que
154 // un scrim a percent(1.0) cubra toda la pantalla.
155 let overlay_built = if let Some(v) = overlay_view {
156 state.overlay_layout.clear();
157 let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
158 let ocomputed = {
159 let ts = &mut state.typesetter;
160 let tmap = &omounted.text_measures;
161 state
162 .overlay_layout
163 .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
164 match tmap.get(&nid) {
165 Some(tm) => measure_text_node(ts, tm, known, avail),
166 None => llimphi_layout::taffy::Size::ZERO,
167 }
168 })
169 .expect("layout overlay")
170 };
171 let ohover = hit_test_hover(
172 &omounted,
173 &ocomputed,
174 state.cursor.x as f32,
175 state.cursor.y as f32,
176 );
177 Some(OverlayCache {
178 mounted: omounted,
179 computed: ocomputed,
180 hover_idx: ohover,
181 })
182 } else {
183 None
184 };
185 // Hover en el main solo si NO hay overlay — durante un
186 // menú abierto, el fondo no debe reaccionar al ratón.
187 let hover_idx = if overlay_built.is_some() {
188 None
189 } else {
190 hit_test_hover(
191 &mounted,
192 &computed,
193 state.cursor.x as f32,
194 state.cursor.y as f32,
195 )
196 };
197 // Drop hover sólo si hay drag activo con payload (un
198 // drag bloquea el overlay; rara combinación pero la
199 // resolvemos a favor del drag).
200 let drop_hover_idx = state
201 .drag
202 .as_ref()
203 .and_then(|d| d.payload.map(|_| ()))
204 .and_then(|_| {
205 hit_test_drop(
206 &mounted,
207 &computed,
208 state.cursor.x as f32,
209 state.cursor.y as f32,
210 )
211 });
212 // Z-order del overlay sobre contenido `gpu_paint`: si el
213 // árbol principal tiene painters gpu (p. ej. el video de
214 // media) Y hay un overlay activo, el overlay NO va en la
215 // escena principal (quedaría debajo del blit gpu). Se
216 // rasteriza aparte sobre fondo transparente y se compone con
217 // alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
218 // de siempre (overlay en la escena principal) — coste cero.
219 let composite_overlay =
220 overlay_built.is_some() && has_gpu_painter(&mounted);
221
222 state.scene.reset();
223 paint(
224 &mut state.scene,
225 &mounted,
226 &computed,
227 &mut state.typesetter,
228 hover_idx,
229 drop_hover_idx,
230 );
231 // Animación de salida (fade-out). 1) Capturá la subescena de
232 // cada nodo `exit` presente (snapshot para cuando desaparezca).
233 // 2) Reproducí los fantasmas de los que ya se fueron, con
234 // opacidad decreciente — por encima del contenido, debajo del
235 // overlay. Coste cero si ningún nodo usa `animated_exit`.
236 for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted) {
237 let (dur, easing) = {
238 let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
239 (a.duration, a.easing)
240 };
241 let mut sub = vello::Scene::new();
242 paint_range(
243 &mut sub,
244 &mounted,
245 &computed,
246 &mut state.typesetter,
247 None,
248 None,
249 idx,
250 end,
251 vello::kurbo::Affine::IDENTITY,
252 );
253 state.anim_registry.store_live_exit(key, sub, dur, easing);
254 }
255 state
256 .anim_registry
257 .replay_ghosts(&mut state.scene, now, w as f32, h as f32);
258 // Resaltado de la selección de texto activa (sobre el
259 // contenido, bajo el overlay). Reconstruye el layout del nodo
260 // seleccionado y pinta los rects de `parley::Selection` con un
261 // tinte translúcido (deja leer el texto debajo).
262 if let Some(tsel) = state.selection {
263 if let Some((spec, (rx, ry, rw, _rh))) =
264 selectable_node_in(&mounted, &computed, tsel.key)
265 {
266 let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
267 use vello::kurbo::{Affine, Rect};
268 use vello::peniko::{Color, Fill};
269 let hl = Color::from_rgba8(86, 148, 246, 80);
270 let scene = &mut state.scene;
271 tsel.sel.geometry_with(&layout, |bb, _line| {
272 let r = Rect::new(
273 rx as f64 + bb.x0,
274 ry as f64 + bb.y0,
275 rx as f64 + bb.x1,
276 ry as f64 + bb.y1,
277 );
278 scene.fill(Fill::NonZero, Affine::IDENTITY, hl, None, &r);
279 });
280 }
281 }
282 // Ripples/InkWell: las salpicaduras vivas se pintan sobre el
283 // contenido (translúcidas, recortadas al nodo) y debajo del
284 // overlay. Si alguna sigue viva, pide otro frame al final.
285 let rippling =
286 state
287 .ripple_registry
288 .paint(&mut state.scene, &mounted, &computed, now);
289 if !composite_overlay {
290 if let Some(ov) = overlay_built.as_ref() {
291 paint(
292 &mut state.scene,
293 &ov.mounted,
294 &ov.computed,
295 &mut state.typesetter,
296 ov.hover_idx,
297 None,
298 );
299 }
300 }
301 if let Err(e) = state.renderer.render(
302 &state.hal,
303 &state.scene,
304 &frame,
305 palette::css::BLACK,
306 ) {
307 eprintln!("render error: {e}");
308 }
309 let (vw, vh) = frame.size();
310 // Capa de overlay aparte (camino composite): vello la
311 // rasteriza con fondo transparente en `frame.overlay_view()`.
312 // Se renderiza ANTES del pase gpu para que el blit del
313 // compositor (en `gpu_encoder`) la lea ya escrita.
314 if composite_overlay {
315 if let Some(ov) = overlay_built.as_ref() {
316 state.scene.reset();
317 paint(
318 &mut state.scene,
319 &ov.mounted,
320 &ov.computed,
321 &mut state.typesetter,
322 ov.hover_idx,
323 None,
324 );
325 if let Err(e) = state.renderer.render_to_view(
326 &state.hal,
327 &state.scene,
328 frame.overlay_view(),
329 vw,
330 vh,
331 palette::css::TRANSPARENT,
332 ) {
333 eprintln!("render overlay error: {e}");
334 }
335 }
336 }
337 // Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
338 // si algún View del main o del overlay registró un
339 // `gpu_painter`, ejecutamos todos sus callbacks contra un
340 // único `CommandEncoder`, encima de lo que vello acaba de
341 // pintar sobre la intermediate. Submitimos antes del
342 // present para que el blit al swapchain incluya las
343 // primitivas GPU. Si nadie usó el hook, no se crea ni
344 // submitea nada — coste cero.
345 let mut gpu_encoder = state.hal.device.create_command_encoder(
346 &llimphi_hal::wgpu::CommandEncoderDescriptor {
347 label: Some("llimphi-ui-gpu-paint"),
348 },
349 );
350 let viewport = frame.size();
351 // Backdrop blur (Bloque 11): post-pasada Gauss separable sobre
352 // la intermediate, restringida al rect de cada nodo
353 // `.backdrop_blur(sigma)`. Sucede TRAS la rasterización vello
354 // y ANTES de los `gpu_painter`/composite — los painters GPU
355 // que se solapen con el blur ven el rect ya borroneado y se
356 // dibujan encima nítidos. Coste cero sin nodos blur (loop
357 // vacío + bandera `blurred` queda false).
358 let backdrop_blurs =
359 llimphi_compositor::collect_backdrop_blurs(&mounted, &computed);
360 let blurred = !backdrop_blurs.is_empty();
361 for b in &backdrop_blurs {
362 state.blur_compositor.blur(
363 &state.hal.device,
364 &state.hal.queue,
365 &mut gpu_encoder,
366 frame.view(),
367 viewport,
368 b.rect,
369 b.sigma,
370 );
371 }
372 // `filter: …` sobre el propio subárbol (Fase 7.1232+): misma post-pasada
373 // que el backdrop, pero leyendo `MountedNode::filter` y restringida al rect
374 // del nodo. Se aplica DESPUÉS del backdrop, sobre los píxeles ya
375 // rasterizados (el contenido del nodo). Hoy sólo `Blur`; el resto de las
376 // variantes se suman por fase.
377 let filter_passes = llimphi_compositor::collect_filters(&mounted, &computed);
378 let filtered = !filter_passes.is_empty();
379 for p in &filter_passes {
380 match &p.op {
381 llimphi_compositor::FilterOp::Blur(sigma) => {
382 state.blur_compositor.blur(
383 &state.hal.device,
384 &state.hal.queue,
385 &mut gpu_encoder,
386 frame.view(),
387 viewport,
388 p.rect,
389 *sigma,
390 );
391 }
392 llimphi_compositor::FilterOp::ColorMatrix(m) => {
393 state.color_filter_compositor.apply(
394 &state.hal.device,
395 &state.hal.queue,
396 &mut gpu_encoder,
397 frame.view(),
398 viewport,
399 p.rect,
400 *m,
401 );
402 }
403 // drop-shadow se pinta en vello (no es post-pasada); collect_filters
404 // no la emite, pero el match debe ser exhaustivo. Fase 7.1234.
405 llimphi_compositor::FilterOp::DropShadow(_) => {}
406 }
407 }
408 let mut any_gpu = blurred
409 | filtered
410 | paint_gpu(
411 &mounted,
412 &computed,
413 &state.hal.device,
414 &state.hal.queue,
415 &mut gpu_encoder,
416 frame.view(),
417 viewport,
418 );
419 if let Some(ov) = overlay_built.as_ref() {
420 // En el camino composite, los painters gpu del overlay van
421 // sobre SU textura; si no, sobre la intermedia.
422 let target = if composite_overlay {
423 frame.overlay_view()
424 } else {
425 frame.view()
426 };
427 any_gpu |= paint_gpu(
428 &ov.mounted,
429 &ov.computed,
430 &state.hal.device,
431 &state.hal.queue,
432 &mut gpu_encoder,
433 target,
434 viewport,
435 );
436 }
437 // Capa vello "over" (Primitivo B): nodos con `paint_over` registran
438 // primitivas vello que deben quedar ENCIMA del pase GPU directo
439 // (sprites/texto AA sobre celdas instanciadas — dominium, motor
440 // voxel). Camino opt-in y coste cero si nadie la usa (loop barato +
441 // bandera false): el orden total queda [vello base] → [gpu_paint] →
442 // [vello over] → [overlay/menús].
443 //
444 // Mecánica de z-order correcta: rasterizamos la escena over en una
445 // textura scratch transparente (vello, con su propio submit — no
446 // toca la intermedia) y luego GRABAMOS el composite alpha de esa
447 // scratch sobre la intermedia DENTRO de `gpu_encoder`, después de
448 // los pases GPU directos. Como `gpu_encoder` se submitea al final
449 // (línea de abajo), el composite corre en la GPU DESPUÉS de las
450 // primitivas GPU → el over-layer queda encima de ellas. Va ANTES
451 // del composite de menús, así los menús siguen por encima del over.
452 let over_active = has_over_painter(&mounted)
453 || overlay_built
454 .as_ref()
455 .map(|ov| has_over_painter(&ov.mounted))
456 .unwrap_or(false);
457 if over_active {
458 // Escena vello aparte (no pisamos `state.scene`, que el caller
459 // retiene/reusa). Fondo transparente: sólo lo pintado por los
460 // `over_painter` lleva alpha.
461 let mut over_scene = vello::Scene::new();
462 let mut any_over = paint_over(
463 &mut over_scene,
464 &mounted,
465 &computed,
466 &mut state.typesetter,
467 );
468 if let Some(ov) = overlay_built.as_ref() {
469 any_over |= paint_over(
470 &mut over_scene,
471 &ov.mounted,
472 &ov.computed,
473 &mut state.typesetter,
474 );
475 }
476 if any_over {
477 // Scratch transparente del tamaño del frame (mismo formato
478 // que la intermedia: Rgba8Unorm). Vello escribe via compute
479 // (STORAGE_BINDING) y el composite la lee como sampler
480 // (TEXTURE_BINDING). Por-frame: sólo se crea cuando hay
481 // over-layer activo, igual que los buffers de `GpuBatch`.
482 let over_tex = state.hal.device.create_texture(
483 &llimphi_hal::wgpu::TextureDescriptor {
484 label: Some("llimphi-ui-over-scratch"),
485 size: llimphi_hal::wgpu::Extent3d {
486 width: vw,
487 height: vh,
488 depth_or_array_layers: 1,
489 },
490 mip_level_count: 1,
491 sample_count: 1,
492 dimension: llimphi_hal::wgpu::TextureDimension::D2,
493 format: llimphi_hal::wgpu::TextureFormat::Rgba8Unorm,
494 usage: llimphi_hal::wgpu::TextureUsages::STORAGE_BINDING
495 | llimphi_hal::wgpu::TextureUsages::TEXTURE_BINDING
496 | llimphi_hal::wgpu::TextureUsages::RENDER_ATTACHMENT,
497 view_formats: &[],
498 },
499 );
500 let over_view =
501 over_tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default());
502 // Vello rasteriza la escena over a la scratch (su propio
503 // submit; limpia con TRANSPARENT). Independiente de la
504 // intermedia.
505 if let Err(e) = state.renderer.render_to_view(
506 &state.hal,
507 &over_scene,
508 &over_view,
509 vw,
510 vh,
511 palette::css::TRANSPARENT,
512 ) {
513 eprintln!("render over-layer error: {e}");
514 }
515 // Composite alpha de la scratch sobre la intermedia, grabado
516 // en `gpu_encoder` DESPUÉS de los pases GPU directos.
517 state.overlay_compositor.composite(
518 &state.hal.device,
519 &mut gpu_encoder,
520 frame.view(),
521 &over_view,
522 );
523 any_gpu = true;
524 }
525 }
526 // Composición alpha del overlay SOBRE la intermedia (que ya
527 // tiene UI + video). Último pase del encoder → corre después
528 // del blit del video. Garantiza menús por encima del video.
529 if composite_overlay {
530 state.overlay_compositor.composite(
531 &state.hal.device,
532 &mut gpu_encoder,
533 frame.view(),
534 frame.overlay_view(),
535 );
536 any_gpu = true;
537 }
538 if any_gpu {
539 state
540 .hal
541 .queue
542 .submit(std::iter::once(gpu_encoder.finish()));
543 }
544 state.surface.present(frame, &state.hal);
545 // Ticker de animaciones implícitas: si quedó alguna en curso,
546 // pedí el próximo frame. Cuando todas se asientan, `animating`
547 // queda false y el loop de redraws se detiene solo (sin render
548 // ocioso, sin spawn_periodic por animación).
549 if animating || rippling {
550 state.window.request_redraw();
551 }
552 state.retained = Some(RetainedScene {
553 w,
554 h,
555 animating,
556 rippling,
557 has_overlay: overlay_built.is_some(),
558 });
559 state.last_render = Some(RenderCache {
560 mounted,
561 computed,
562 hover_idx,
563 drop_hover_idx,
564 overlay: overlay_built,
565 });
566 // AccessKit: tras un paint exitoso, empujamos el árbol al
567 // adapter. `update_if_active` se salta el closure si no hay
568 // tecnología asistiva escuchando — coste cero en ese caso.
569 push_a11y_tree::<A>(state);
570
571 // `handle` se recibe pero el redraw no lo necesita directamente;
572 // se pasa para mantener la firma consistente con el caller.
573 let _ = handle;
574}