gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ e2102d6057046fade7d44f45092cc01cfd05b5e7 llimphi-ui/src/eventloop/redraw.rs Rust · 574 líneas · 22.5 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

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}