tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

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

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · 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.has_gpu_paint
45                && (r.w, r.h) == state.surface.size()
46        });
47    if cache_hit {
48        // Este camino re-rasteriza la escena vello sobre la intermedia,
49        // limpiando a negro: después de pasar por acá, la intermedia **ya no
50        // tiene** lo que hayan puesto los painters GPU. Olvidamos la huella
51        // para que el próximo cuadro se pinte entero y no se saltee apoyándose
52        // en una intermedia que perdió el video. (Hoy `!has_gpu_paint` impide
53        // llegar acá con contenido GPU vivo; esto es el cinturón por si esa
54        // guarda se afloja.)
55        state.escena_huella = None;
56        super::perf::contar_retenido();
57        match state.surface.acquire() {
58            Ok(frame) => {
59                if state
60                    .renderer
61                    .render(&state.hal, &state.scene, &frame, palette::css::BLACK)
62                    .is_ok()
63                {
64                    super::timing_primer_present();
65                    super::aplicar_fullscreen_inicial::<A>(&state.window);
66                    super::notificar_present(&state.window);
67                    state.surface.present(frame, &state.hal);
68                    // Este camino no emite muestra, pero sí sella el fin: si no
69                    // lo hiciera, el `entre` del próximo cuadro muestreado se
70                    // comería todo el tiempo de los retenidos y diría que la
71                    // app estuvo quieta cuando estuvo presentando.
72                    super::perf::sellar_fin(super::perf::enabled());
73                    // Modo vsync: el frame retenido también tiene que pedir el
74                    // siguiente, o el enganche al compositor se corta acá
75                    // (una app en modo continuo que caiga en la retención
76                    // dejaría de repintar hasta el próximo evento).
77                    if super::modo_vsync_activo()
78                        && A::wants_vsync_frames(state.model.as_ref().expect("model"))
79                    {
80                        state.window.request_redraw();
81                    }
82                    return;
83                }
84                // render falló → cae al camino completo
85            }
86            Err(llimphi_hal::SurfaceError::DeviceLost) => {
87                // Device irrecuperable: marca y salí. El dispatcher rehace el
88                // stack GPU tras el redraw (`rebuild_gpu_after_device_loss`).
89                state.needs_device_rebuild = true;
90                return;
91            }
92            Err(_) => { /* surface efímera → camino completo */ }
93        }
94    }
95    // Título dinámico (App::window_title): si cambió respecto del
96    // último aplicado, se lo pasamos a winit. Barato: una
97    // comparación de String por frame, set_title sólo en el cambio.
98    if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
99        if state.last_title.as_deref() != Some(t.as_str()) {
100            state.window.set_title(&t);
101            state.last_title = Some(t);
102        }
103    }
104    // Posiciona la ventana de candidatos del IME junto al caret
105    // (sólo con IME activo y si la app reporta el área).
106    if A::ime_allowed() {
107        if let Some((x, y, w, h)) =
108            A::ime_cursor_area(state.model.as_ref().expect("model"))
109        {
110            state.window.set_ime_cursor_area(
111                llimphi_hal::winit::dpi::PhysicalPosition::new(x as f64, y as f64),
112                llimphi_hal::winit::dpi::PhysicalSize::new(
113                    w.max(1.0) as u32,
114                    h.max(1.0) as u32,
115                ),
116            );
117        }
118    }
119    // Perfilador (`LLIMPHI_PERF`): se consulta ACÁ y no después del acquire
120    // porque el acquire es justamente lo que faltaba medir — en `Fifo` bloquea
121    // esperando al compositor, y hasta hoy caía fuera de toda etapa.
122    let pf = super::perf::enabled();
123    let entre_us = super::perf::hueco(pf);
124    let m_acquire_0 = super::perf::mark(pf);
125    let frame = match state.surface.acquire() {
126        Ok(f) => {
127            state.acquire_fallidos = 0;
128            f
129        }
130        Err(llimphi_hal::SurfaceError::DeviceLost) => {
131            // Device irrecuperable: marca y salí; el dispatcher rehace el stack
132            // GPU tras el redraw (`rebuild_gpu_after_device_loss`).
133            state.needs_device_rebuild = true;
134            return;
135        }
136        // **Ventana tapada: no es un fallo.** Llegó con wgpu 29, que la
137        // distingue del resto de los estados. Se saltea el cuadro y NO se toca
138        // el contador: si contara, una ventana minimizada ocho cuadros —o sea
139        // una fracción de segundo— dispararía la reconstrucción del stack GPU
140        // entero sin que haya nada roto. Tampoco se pide otro redraw: no hay a
141        // quién mostrárselo, y pedirlo sería girar en vacío contra el
142        // compositor, que es justo el gasto que se viene cazando en pata.
143        Err(llimphi_hal::SurfaceError::Occluded) => {
144            // Se cuenta, y no es contabilidad de más: sin este número el tramo
145            // `ocio` del período es ilegible — un hueco largo se lee igual si el
146            // compositor dejó de pedir cuadros (bug) que si la ventana no estaba
147            // a la vista (correcto). Ver `perf::OCLUIDOS`.
148            super::perf::contar_ocluido();
149            return;
150        }
151        // Un fallo efímero (viewport en tránsito, hipo del compositor): saltamos
152        // el frame y pedimos otro. Antes esto llamaba `surface.resize(size())`
153        // —resize a las medidas que YA tenía— con la idea de reconfigurar el
154        // swapchain. Dos cosas estaban mal: `acquire` ya reconfigura y recrea
155        // por dentro ante `Outdated`/`Lost`, así que era redundante; y como el
156        // reintento es inmediato, un fallo persistente se volvía un bucle
157        // cerrado que reconstruía el swapchain miles de veces por segundo hasta
158        // dejar la app en negro (visto en Android/Mali el 2026-07-27, 41.335
159        // reconfiguraciones en 13 s). Ahora se cuenta, se dice por qué, y si no
160        // afloja se escala a rebuild del stack.
161        Err(e) => {
162            state.acquire_fallidos += 1;
163            if state.acquire_fallidos <= 3 {
164                super::aviso_error(&format!(
165                    "llimphi-ui: acquire falló ({e:?}), salteo el frame \
166                     ({}/{ACQUIRE_FALLOS_MAX})",
167                    state.acquire_fallidos
168                ));
169            }
170            if state.acquire_fallidos >= ACQUIRE_FALLOS_MAX {
171                super::aviso_error(&format!(
172                    "llimphi-ui: {ACQUIRE_FALLOS_MAX} acquire fallidos seguidos \
173                     (último: {e:?}) → rehago el stack GPU"
174                ));
175                state.acquire_fallidos = 0;
176                state.needs_device_rebuild = true;
177                return;
178            }
179            state.window.request_redraw();
180            return;
181        }
182    };
183    let acquire_us = super::perf::us(m_acquire_0, super::perf::mark(pf));
184    let (w, h) = frame.size();
185    // Bloque 21 — perfilador por frame (`LLIMPHI_PERF`). Cero costo apagado.
186    if pf {
187        // Huella del binario: sin esto una tanda de bytes no se puede fechar.
188        super::perf::set_tam_view(core::mem::size_of::<View<A::Msg>>());
189    }
190    let m_start = super::perf::mark(pf);
191    // Contador de asignaciones de heap: se muestrea en los MISMOS puntos que los
192    // µs, así el desglose de allocs es comparable etapa por etapa. Cero si nadie
193    // instaló `crate::alloc::ContadorAlloc` (ver feature `perf-alloc`).
194    let a_start = super::perf::alloc_mark(pf);
195    // LayoutBuilder: resuelve los constructores diferidos en dos
196    // pasadas (coste cero si no hay ninguno). Necesita el typesetter
197    // para medir, así que va antes de tomar `model_ref` para el overlay.
198    let mut view = resolve_layout_builders::<A>(
199        state.model.as_ref().expect("model"),
200        (w as f32, h as f32),
201        &mut state.typesetter,
202    );
203    // Animaciones implícitas de **tamaño** (`View::animated_size`):
204    // reconcila el `View` tree y parcha `style.size` ANTES del
205    // mount/layout. Así siblings/hijos reflowean suave (la
206    // animación se ve en el layout cascade, no sólo en el rect del
207    // nodo aislado). Coste cero sin nodos `animated_size`.
208    let frame_now = crate::Instant::now();
209    let size_animating = llimphi_compositor::reconcile_size_anim(
210        &mut view,
211        &mut state.size_anim_registry,
212        frame_now,
213    );
214    let model_ref = state.model.as_ref().expect("model");
215    let overlay_view = super::vigia::aplicar_view_overlay::<A>(model_ref);
216    // Bloque 24 — layout retenido. `mount_incremental` reusa los `NodeId` de
217    // taffy del frame anterior cuando la FORMA del árbol no cambió (sólo re-estila
218    // /re-mide lo que cambió), dejando limpio el resto para que taffy saltee su
219    // re-solve. Si la forma cambió, cae a `clear()`+rebuild histórico → cero
220    // regresión. Correctitud probada por el arnés de fuzz (retenido==fresco).
221    // Bloque 25 — extrae los builders de los nodos `memo` (dejándolos como
222    // marcadores) ANTES del mount. Sin nodos memo, `memo_pending` queda vacío y
223    // todo lo de abajo es no-op. Debe correr tras el size-anim (que muta el view).
224    let mut memo_pending = Vec::new();
225    llimphi_compositor::take_memo_builders(&mut view, &mut memo_pending);
226    // Censo de props raras, UNA vez por proceso (ver `perf::censo_pendiente`).
227    // Va antes de la marca de `view` a propósito: así el recorrido cae en el
228    // frame frío, que ya es el atípico del lote, y no ensucia el régimen.
229    if pf && super::perf::censo_pendiente() {
230        let c = llimphi_compositor::censo_rare(&view);
231        super::perf::set_censo(c.nodos, c.con_rare);
232    }
233    let m_view = super::perf::mark(pf);
234    let a_view = super::perf::alloc_mark(pf);
235    // Reciclado de buffers (Bloque 21): el `Mounted` del frame anterior ya no se
236    // usa —se sobrescribe entero en `last_render` al cierre de este frame— así
237    // que en vez de dejarlo morir para pedir otro `Vec` del mismo porte, se lo
238    // pasamos al mount para que lo `clear()`ee y escriba encima. A 200k nodos
239    // eso es un bloque de ~432 MB por frame que deja de ir y venir del
240    // allocator. Seguro acá: pasado el `cache_hit` de arriba, nadie vuelve a
241    // leer `last_render` hasta que se reasigna al final.
242    //
243    // Las DOS fuentes, y la segunda es la que importa: `mount_pool` (lo que
244    // rescató `invalidar_render` cuando un `update` tiró la caché) y, si no hay,
245    // `last_render` (redraw sin update de por medio). Son excluyentes por
246    // construcción. Mirar sólo `last_render` deja el reciclado muerto en
247    // cualquier app que actualice su modelo entre frames — o sea, todas.
248    //
249    // El overlay NO se recicla: vive dentro de `last_render.overlay` y es chico
250    // (un menú, un modal) — no paga la complejidad.
251    //
252    // El `ComputedLayout` viaja en el MISMO envase (`last_render`/pool), así que
253    // se rescata en la misma operación: separarlo obligaría a un segundo `take`
254    // sobre un `last_render` que este `or_else` ya consumió, y el mapa de rects
255    // se perdería justo en el camino donde más caro es (el frame con update).
256    let mut reciclado_layout = state.layout_pool.take();
257    let reciclado = state.mount_pool.take().or_else(|| {
258        state.last_render.take().map(|r| {
259            reciclado_layout = Some(r.computed);
260            r.mounted
261        })
262    });
263    let hubo_reciclado = reciclado.is_some();
264    let hubo_reciclado_layout = reciclado_layout.is_some();
265    let (mut mounted, retained, layout_reused): (Mounted<A::Msg>, _, bool) =
266        llimphi_compositor::mount_incremental_reciclando(
267            &mut state.layout,
268            view,
269            // Por valor: el mount escribe encima de los búferes del retenido en
270            // vez de devolver tres `Vec` nuevos. Se reinstala tres líneas abajo.
271            state.retained_layout.take(),
272            reciclado,
273        );
274    state.retained_layout = Some(retained);
275    let m_mount = super::perf::mark(pf);
276    let a_mount = super::perf::alloc_mark(pf);
277    let mut computed = {
278        let ts = &mut state.typesetter;
279        let tmap = &mounted.text_measures;
280        let imap = &mounted.intrinsic_measures;
281        state
282            .layout
283            .compute_with_measure_reciclando(
284                mounted.root,
285                (w as f32, h as f32),
286                |nid, known, avail| {
287                match tmap.get(&nid) {
288                    Some(tm) => measure_text_node(ts, tm, known, avail),
289                    // Contenido REEMPLAZADO (imagen): se mide por su tamaño
290                    // natural, que no es lo mismo en min-content que en
291                    // max-content. Ver `measure_intrinsic_node`.
292                    None => match imap.get(&nid) {
293                        Some(nat) => measure_intrinsic_node(nat, known, avail),
294                        None => llimphi_layout::taffy::Size::ZERO,
295                    },
296                }
297                },
298                reciclado_layout,
299            )
300            .expect("layout")
301    };
302    // Bloque 25 — splice de los subárboles memoizados en el Mounted/computed ya
303    // calculados: reusa el cacheado (sin reconstruir el View) o lo reconstruye si
304    // deps/tamaño cambiaron. No-op sin memos.
305    if !memo_pending.is_empty() {
306        let ts = &mut state.typesetter;
307        llimphi_compositor::splice_memos(
308            &mut mounted,
309            &mut computed,
310            &memo_pending,
311            &mut state.memo_registry,
312            |tm, k, a| measure_text_node(ts, tm, k, a),
313        );
314    }
315    let m_layout = super::perf::mark(pf);
316    let a_layout = super::perf::alloc_mark(pf);
317    // Animaciones implícitas (`View::animated`): reconcilia el árbol
318    // con el estado retenido DESPUÉS del layout y ANTES del paint —
319    // interpola fill/radius de los nodos con `anim`. Si alguna sigue
320    // viva pedimos otro frame al final (ticker autodetenido).
321    let now = frame_now;
322    let anim_active = state.anim_registry.reconcile(&mut mounted, now);
323    // Scene-split de fantasmas: el contenido viejo de un switch con
324    // `exit_under` se hornea como `underlay` del nodo — el paint lo apendea
325    // DEBAJO del contenido nuevo, en su posición z (no encima de todo).
326    state.anim_registry.prepare_underlays(&mut mounted, now);
327    // Heroes (`View::hero`): si la misma key cambió de rect entre
328    // frames, escribe en `transform` la afín que "vuela" del rect
329    // anterior al actual. Independiente del anim_registry — sólo
330    // toca `transform`, que el paint ya respeta. Coste cero sin
331    // nodos hero.
332    let hero_active = state.hero_registry.reconcile(&mut mounted, &computed, now);
333    // `size_animating` viene del reconcile previo al mount; lo
334    // ORrijimos al `animating` global para que se pida el
335    // próximo frame y el `retained.animating == true` invalide
336    // la cache de retención (la siguiente pasada reconstruye con
337    // el size interpolado).
338    let animating = anim_active || hero_active || size_animating;
339    // Mount + layout del overlay en un árbol aparte. Lo
340    // computamos con el mismo tamaño de viewport para que
341    // un scrim a percent(1.0) cubra toda la pantalla.
342    let overlay_built = if let Some(v) = overlay_view {
343        state.overlay_layout.clear();
344        let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
345        let ocomputed = {
346            let ts = &mut state.typesetter;
347            let tmap = &omounted.text_measures;
348            let imap = &omounted.intrinsic_measures;
349            state
350                .overlay_layout
351                .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
352                    match tmap.get(&nid) {
353                        Some(tm) => measure_text_node(ts, tm, known, avail),
354                        None => match imap.get(&nid) {
355                            Some(nat) => measure_intrinsic_node(nat, known, avail),
356                            None => llimphi_layout::taffy::Size::ZERO,
357                        },
358                    }
359                })
360                .expect("layout overlay")
361        };
362        let ohover = hit_test_hover(
363            &omounted,
364            &ocomputed,
365            state.cursor.x as f32,
366            state.cursor.y as f32,
367        );
368        Some(OverlayCache {
369            mounted: omounted,
370            computed: ocomputed,
371            hover_idx: ohover,
372        })
373    } else {
374        None
375    };
376    // Hover en el main solo si NO hay overlay — durante un
377    // menú abierto, el fondo no debe reaccionar al ratón.
378    let hover_idx = if overlay_built.is_some() {
379        None
380    } else {
381        hit_test_hover(
382            &mounted,
383            &computed,
384            state.cursor.x as f32,
385            state.cursor.y as f32,
386        )
387    };
388    // Drop hover sólo si hay drag activo con payload (un
389    // drag bloquea el overlay; rara combinación pero la
390    // resolvemos a favor del drag).
391    let drop_hover_idx = state
392        .drag
393        .as_ref()
394        .and_then(|d| d.payload.map(|_| ()))
395        .and_then(|_| {
396            hit_test_drop(
397                &mounted,
398                &computed,
399                state.cursor.x as f32,
400                state.cursor.y as f32,
401            )
402        });
403    // Z-order del overlay sobre contenido `gpu_paint`: si el
404    // árbol principal tiene painters gpu (p. ej. el video de
405    // media) Y hay un overlay activo, el overlay NO va en la
406    // escena principal (quedaría debajo del blit gpu). Se
407    // rasteriza aparte sobre fondo transparente y se compone con
408    // alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
409    // de siempre (overlay en la escena principal) — coste cero.
410    let composite_overlay =
411        overlay_built.is_some() && has_gpu_painter(&mounted);
412
413    state.scene.reset();
414    // Bloque 23 — pintura con caché de RepaintBoundary. Sin nodos boundary en el
415    // árbol, `paint_cached` delega en `paint` (coste ~0); con ellos, reusa la
416    // sub-escena rasterizada de los subárboles estáticos.
417    paint_cached(
418        &mut state.scene,
419        &mounted,
420        &computed,
421        &mut state.typesetter,
422        hover_idx,
423        drop_hover_idx,
424        &mut state.repaint_cache,
425    );
426    // Diagnóstico (`LLIMPHI_BOUNDARY_HINT`): qué subárboles vienen quietos y se
427    // están repintando de gusto. Apagado no existe — el `Option` es `None` y
428    // esto es una comparación. Va acá, con el mismo `hover_idx`/`drop_hover_idx`
429    // que acaba de usar el caché, para que la sugerencia sea una predicción de
430    // lo que el caché haría y no una opinión sobre el árbol.
431    if let Some(det) = state.detector_boundaries.as_mut() {
432        let sug = det.observar(&mounted, &computed, hover_idx, drop_hover_idx);
433        super::perf::reportar_boundaries(&sug);
434    }
435    // Animación de salida (fade-out). 1) Capturá la subescena de
436    // cada nodo `exit` presente (snapshot para cuando desaparezca).
437    // 2) Reproduce los fantasmas de los que ya se fueron, con
438    // opacidad decreciente — por encima del contenido, debajo del
439    // overlay. Coste cero si ningún nodo usa `animated_exit`.
440    for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted, now) {
441        let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
442        // Centro del rect del nodo: origen de la afín de salida del fantasma.
443        let centro = computed
444            .get(mounted.nodes[idx].id)
445            .map(|r| (r.x + r.w * 0.5, r.y + r.h * 0.5))
446            .unwrap_or((0.0, 0.0));
447        // El underlay (fantasma ANTERIOR) no debe entrar a la captura del
448        // contenido vivo — si no, el próximo fantasma arrastraría al previo.
449        let saved_underlay = mounted.nodes[idx].underlay.take();
450        let mut sub = Escena::new();
451        paint_range(
452            &mut sub,
453            &mounted,
454            &computed,
455            &mut state.typesetter,
456            None,
457            None,
458            idx,
459            end,
460            vello::kurbo::Affine::IDENTITY,
461        );
462        mounted.nodes[idx].underlay = saved_underlay;
463        state.anim_registry.store_live_exit_en(key, sub, centro, &a, now);
464    }
465    state
466        .anim_registry
467        .replay_ghosts(&mut state.scene, now, w as f32, h as f32);
468    // Resaltado de la selección de texto activa (sobre el
469    // contenido, bajo el overlay). Reconstruye el layout del nodo
470    // seleccionado y pinta los rects de `parley::Selection` con un
471    // tinte translúcido (deja leer el texto debajo).
472    if let Some(tsel) = state.selection {
473        if let Some((spec, (rx, ry, rw, _rh))) =
474            selectable_node_in(&mounted, &computed, tsel.key)
475        {
476            let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
477            use vello::kurbo::{Affine, Rect};
478            use vello::peniko::{Color, Fill};
479            let hl = Color::from_rgba8(86, 148, 246, 80);
480            let scene = &mut state.scene;
481            tsel.sel.geometry_with(&layout, |bb, _line| {
482                let r = Rect::new(
483                    rx as f64 + bb.x0,
484                    ry as f64 + bb.y0,
485                    rx as f64 + bb.x1,
486                    ry as f64 + bb.y1,
487                );
488                scene.fill(Fill::NonZero, Affine::IDENTITY, hl, None, &r);
489            });
490        }
491    }
492    // Ripples/InkWell: las salpicaduras vivas se pintan sobre el
493    // contenido (translúcidas, recortadas al nodo) y debajo del
494    // overlay. Si alguna sigue viva, pide otro frame al final.
495    let rippling =
496        state
497            .ripple_registry
498            .paint(&mut state.scene, &mounted, &computed, now);
499    // **El fantasma del arrastre**, encima de todo lo del árbol principal y
500    // debajo del overlay: si hay un menú abierto mientras arrastrás, el menú
501    // manda. Se rearma por cuadro porque va pegado al cursor — son tres nodos,
502    // y sólo mientras dura el gesto. Ver `crate::fantasma`.
503    if let Some(carga) = state.drag.as_ref().and_then(|d| d.carga.clone()) {
504        let (vw, vh) = state.surface.size();
505        let caja = crate::fantasma::caja(
506            (state.cursor.x as f32, state.cursor.y as f32),
507            crate::fantasma::ancho_contenido(&carga),
508            (vw as f32, vh as f32),
509        );
510        let vista = crate::fantasma::vista::<A::Msg>(&carga, caja);
511        let mut layout = LayoutTree::new();
512        let fmounted: Mounted<A::Msg> = mount(&mut layout, vista);
513        let ts = &mut state.typesetter;
514        let fcomputed = {
515            let tmap = &fmounted.text_measures;
516            layout
517                .compute_with_measure(fmounted.root, (vw as f32, vh as f32), |nid, known, avail| {
518                    match tmap.get(&nid) {
519                        Some(tm) => measure_text_node(ts, tm, known, avail),
520                        None => llimphi_layout::taffy::Size::ZERO,
521                    }
522                })
523                .expect("layout del fantasma")
524        };
525        paint(&mut state.scene, &fmounted, &fcomputed, &mut state.typesetter, None, None);
526    }
527    if !composite_overlay {
528        if let Some(ov) = overlay_built.as_ref() {
529            paint(
530                &mut state.scene,
531                &ov.mounted,
532                &ov.computed,
533                &mut state.typesetter,
534                ov.hover_idx,
535                None,
536            );
537        }
538    }
539    let m_paint = super::perf::mark(pf);
540    let a_paint = super::perf::alloc_mark(pf);
541    let (vw, vh) = frame.size();
542
543    // ─── ¿Este cuadro va a pintar exactamente lo mismo que el anterior? ───
544    //
545    // Una app en modo vsync recibe cuadros a 60 Hz, pero su contenido rara vez
546    // cambia tan seguido: un video de 24 fps redibuja lo mismo en 3 de cada 5.
547    // Si nada cambió, se saltea el cuadro **entero** —vello, painters GPU,
548    // composite— y se presenta la intermedia tal como quedó, que ya tiene el
549    // resultado correcto (es una textura persistente de la surface, no se
550    // recicla por cuadro). El present sí ocurre: el ritmo con el compositor no
551    // se toca.
552    //
553    // Se salta sólo con TODAS estas ciertas, y cada una tapa una forma de
554    // servir un cuadro viejo:
555    //
556    //  · la app dice que sus `gpu_painter` pintan lo mismo (`gpu_content_changed`);
557    //  · la escena vello da la misma huella que la del cuadro anterior;
558    //  · el viewport no cambió — un resize **recrea** la intermedia y la deja en
559    //    blanco, así que presentar sin pintar mostraría un cuadro vacío;
560    //  · no hay overlay (menú/modal): en el camino composite vive en otra
561    //    textura y se compone con alpha sobre la intermedia, y componerlo de
562    //    nuevo sobre lo ya compuesto lo **apilaría**;
563    //  · no hay post-pasadas de `backdrop_blur` ni `filter`, por lo mismo:
564    //    desenfocar lo ya desenfocado compone dos veces;
565    //  · no hay arrastre ni long-press en curso (conservador, igual que la
566    //    retención de cuadro entero de más arriba).
567    let hay_post_pasadas = mounted
568        .nodes
569        .iter()
570        .any(|n| n.backdrop_blur.is_some() || !n.filter.is_empty());
571    /// **Apagado por defecto, y no por prudencia genérica.** Este salteo se
572    /// mandó encendido el 2026-08-10 y rompió el reproductor: dejaba
573    /// `has_gpu_paint` en `false`, la retención de cuadro entero se creía
574    /// habilitada y re-rasterizaba la escena limpiando a negro sin volver a
575    /// blittear el video — **cuadros negros intercalados**, que el operador
576    /// comió durante horas creyendo que era una prueba en curso.
577    ///
578    /// La causa está arreglada (ver `has_gpu_paint: any_gpu || saltear`), pero
579    /// el arreglo **no se pudo verificar en metal**: la ventana abre en un
580    /// escritorio que el agente no puede hacer visible, y el compositor no
581    /// rinde una ventana tapada. Y lo que compra, medido, es ~12 % de CPU en el
582    /// mejor caso — el perfilador mostró que el 96–100 % del tiempo de media es
583    /// espera de vsync y GPU, no CPU. Eso no paga el riesgo de un reproductor
584    /// roto: se enciende con `LLIMPHI_SKIP=1`, se mira, y recién entonces se
585    /// discute volverlo default.
586    fn skip_habilitado() -> bool {
587        use std::sync::OnceLock;
588        static D: OnceLock<bool> = OnceLock::new();
589        *D.get_or_init(|| std::env::var("LLIMPHI_SKIP").is_ok())
590    }
591    let huella_escena = llimphi_raster::huella_escena(&state.scene);
592    let saltear = skip_habilitado()
593        && huella_escena.is_some()
594        && state.escena_huella == huella_escena.map(|h| (h, vw, vh))
595        && !A::gpu_content_changed(state.model.as_ref().expect("model"))
596        && overlay_built.is_none()
597        && !hay_post_pasadas
598        && state.drag.is_none()
599        && state.pending_long_press.is_none();
600    state.escena_huella = huella_escena.map(|h| (h, vw, vh));
601
602    if !saltear {
603        if let Err(e) = state.renderer.render(
604            &state.hal,
605            &state.scene,
606            &frame,
607            palette::css::BLACK,
608        ) {
609            super::aviso_error(&format!("render error: {e}"));
610            // No se pudo pintar: lo que hay en la intermedia ya no se
611            // corresponde con la huella que acabamos de guardar. Se olvida,
612            // para no saltear el cuadro que viene apoyándose en ella.
613            state.escena_huella = None;
614        }
615    }
616    let m_raster = super::perf::mark(pf);
617    // Capa de overlay aparte (camino composite): vello la
618    // rasteriza con fondo transparente en `frame.overlay_view()`.
619    // Se renderiza ANTES del pase gpu para que el blit del
620    // compositor (en `gpu_encoder`) la lea ya escrita.
621    if composite_overlay {
622        if let Some(ov) = overlay_built.as_ref() {
623            state.scene.reset();
624            paint(
625                &mut state.scene,
626                &ov.mounted,
627                &ov.computed,
628                &mut state.typesetter,
629                ov.hover_idx,
630                None,
631            );
632            if let Err(e) = state.renderer.render_to_view(
633                &state.hal,
634                &state.scene,
635                frame.overlay_view(),
636                vw,
637                vh,
638                palette::css::TRANSPARENT,
639            ) {
640                super::aviso_error(&format!("render overlay error: {e}"));
641            }
642        }
643    }
644    // Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
645    // si algún View del main o del overlay registró un
646    // `gpu_painter`, ejecutamos todos sus callbacks contra un
647    // único `CommandEncoder`, encima de lo que vello acaba de
648    // pintar sobre la intermediate. Submitimos antes del
649    // present para que el blit al swapchain incluya las
650    // primitivas GPU. Si nadie usó el hook, no se crea ni
651    // submitea nada — coste cero.
652    let mut gpu_encoder = state.hal.device.create_command_encoder(
653        &llimphi_hal::wgpu::CommandEncoderDescriptor {
654            label: Some("llimphi-ui-gpu-paint"),
655        },
656    );
657    // Desglose del tramo `gpu` (sólo con el perfilador encendido; quedan en 0
658    // si la capa over no se activa en este frame).
659    let mut over_paint_us = 0.0_f64;
660    let mut over_tex_us = 0.0_f64;
661    let mut over_raster_us = 0.0_f64;
662    // ¿Este cuadro reusó la capa over ya dibujada en vez de rasterizarla?
663    let mut over_cache_hit = false;
664    let viewport = frame.size();
665    // Backdrop blur (Bloque 11): post-pasada Gauss separable sobre
666    // la intermediate, restringida al rect de cada nodo
667    // `.backdrop_blur(sigma)`. Sucede TRAS la rasterización vello
668    // y ANTES de los `gpu_painter`/composite — los painters GPU
669    // que se solapen con el blur ven el rect ya borroneado y se
670    // dibujan encima nítidos. Coste cero sin nodos blur (loop
671    // vacío + bandera `blurred` queda false).
672    let backdrop_blurs =
673        llimphi_compositor::collect_backdrop_blurs(&mounted, &computed);
674    let blurred = !backdrop_blurs.is_empty();
675    for b in &backdrop_blurs {
676        state.blur_compositor.blur(
677            &state.hal.device,
678            &state.hal.queue,
679            &mut gpu_encoder,
680            frame.view(),
681            viewport,
682            b.rect,
683            b.sigma,
684        );
685    }
686    // `filter: …` sobre el propio subárbol (Fase 7.1232+): misma post-pasada
687    // que el backdrop, pero leyendo `MountedNode::filter` y restringida al rect
688    // del nodo. Se aplica DESPUÉS del backdrop, sobre los píxeles ya
689    // rasterizados (el contenido del nodo). Hoy sólo `Blur`; el resto de las
690    // variantes se suman por fase.
691    let filter_passes = llimphi_compositor::collect_filters(&mounted, &computed);
692    let filtered = !filter_passes.is_empty();
693    for p in &filter_passes {
694        match &p.op {
695            llimphi_compositor::FilterOp::Blur(sigma) => {
696                state.blur_compositor.blur(
697                    &state.hal.device,
698                    &state.hal.queue,
699                    &mut gpu_encoder,
700                    frame.view(),
701                    viewport,
702                    p.rect,
703                    *sigma,
704                );
705            }
706            llimphi_compositor::FilterOp::ColorMatrix(m) => {
707                state.color_filter_compositor.apply(
708                    &state.hal.device,
709                    &state.hal.queue,
710                    &mut gpu_encoder,
711                    frame.view(),
712                    viewport,
713                    p.rect,
714                    *m,
715                );
716            }
717            // drop-shadow se pinta en vello (no es post-pasada); collect_filters
718            // no la emite, pero el match debe ser exhaustivo. Fase 7.1234.
719            llimphi_compositor::FilterOp::DropShadow(_) => {}
720        }
721    }
722    // Con `saltear` no se corre ningún painter GPU: la intermedia ya tiene lo
723    // que pintaron el cuadro pasado, y volver a correrlos sería redibujar el
724    // mismo video encima de sí mismo. (`blurred`/`filtered` son false por
725    // construcción acá: `saltear` exige que no haya post-pasadas.)
726    let mut any_gpu = !saltear
727        && (blurred
728            | filtered
729            | paint_gpu(
730                &mounted,
731                &computed,
732                &state.hal.device,
733                &state.hal.queue,
734                &mut gpu_encoder,
735                frame.view(),
736                viewport,
737            ));
738    if let Some(ov) = overlay_built.as_ref() {
739        // En el camino composite, los painters gpu del overlay van
740        // sobre SU textura; si no, sobre la intermedia.
741        let target = if composite_overlay {
742            frame.overlay_view()
743        } else {
744            frame.view()
745        };
746        any_gpu |= paint_gpu(
747            &ov.mounted,
748            &ov.computed,
749            &state.hal.device,
750            &state.hal.queue,
751            &mut gpu_encoder,
752            target,
753            viewport,
754        );
755    }
756    // Capa vello "over" (Primitivo B): nodos con `paint_over` registran
757    // primitivas vello que deben quedar ENCIMA del pase GPU directo
758    // (sprites/texto AA sobre celdas instanciadas — dominium, motor
759    // voxel). Camino opt-in y coste cero si nadie la usa (loop barato +
760    // bandera false): el orden total queda [vello base] → [gpu_paint] →
761    // [vello over] → [overlay/menús].
762    //
763    // Mecánica de z-order correcta: rasterizamos la escena over en una
764    // textura scratch transparente (vello, con su propio submit — no
765    // toca la intermedia) y luego GRABAMOS el composite alpha de esa
766    // scratch sobre la intermedia DENTRO de `gpu_encoder`, después de
767    // los pases GPU directos. Como `gpu_encoder` se submitea al final
768    // (línea de abajo), el composite corre en la GPU DESPUÉS de las
769    // primitivas GPU → el over-layer queda encima de ellas. Va ANTES
770    // del composite de menús, así los menús siguen por encima del over.
771    let over_active = has_over_painter(&mounted)
772        || overlay_built
773            .as_ref()
774            .map(|ov| has_over_painter(&ov.mounted))
775            .unwrap_or(false);
776    /// ¿Rasterizar la capa over en el frame entero (comportamiento previo al
777    /// recorte por bounding box)? Sólo para medir. Cacheado: un `env::var` por
778    /// cuadro no es gratis.
779    fn over_fullframe() -> bool {
780        use std::sync::OnceLock;
781        static D: OnceLock<bool> = OnceLock::new();
782        *D.get_or_init(|| std::env::var("LLIMPHI_OVER_FULLFRAME").is_ok())
783    }
784
785    /// ¿Saltear la pasada over por completo? Sólo para medir el **techo** de lo
786    /// que se ganaría cacheándola (la UI over desaparece: no es un modo usable).
787    fn over_off() -> bool {
788        use std::sync::OnceLock;
789        static D: OnceLock<bool> = OnceLock::new();
790        *D.get_or_init(|| std::env::var("LLIMPHI_OVER_OFF").is_ok())
791    }
792
793    /// Válvula del caché de la capa over: `LLIMPHI_OVER_NOCACHE=1` rasteriza
794    /// siempre, aunque la escena no haya cambiado. Está para el A/B **alternado
795    /// en el mismo bucle** (la única medición que le saca señal a esta máquina)
796    /// y para descartar el caché de un plumazo si alguna vez se sospecha que
797    /// congela algo en pantalla.
798    fn over_nocache() -> bool {
799        use std::sync::OnceLock;
800        static D: OnceLock<bool> = OnceLock::new();
801        *D.get_or_init(|| std::env::var("LLIMPHI_OVER_NOCACHE").is_ok())
802    }
803
804    /// Traza del recorte y del caché. Cacheada como las otras: se consultaba con
805    /// un `env::var` **por cuadro**, que asigna una `String` cada vez.
806    fn over_debug() -> bool {
807        use std::sync::OnceLock;
808        static D: OnceLock<bool> = OnceLock::new();
809        *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_OVER").is_ok())
810    }
811
812    if over_active && !over_off() && !saltear {
813        let m_over_paint = super::perf::mark(pf);
814        // Bounding box de TODO lo que la pasada over va a dibujar (árbol
815        // principal + overlay). La capa se rasteriza y se compone sólo ahí:
816        // pintar una barra de progreso de 1840×5 no puede costar un frame
817        // entero de vello. Ver `over_bbox` y `02_ruway/media/RENDIMIENTO.md`.
818        let caja_over = {
819            let a = llimphi_compositor::over_bbox(&mounted, &computed);
820            let b = overlay_built
821                .as_ref()
822                .and_then(|ov| llimphi_compositor::over_bbox(&ov.mounted, &ov.computed));
823            match (a, b) {
824                (Some(a), Some(b)) => Some((
825                    a.0.min(b.0),
826                    a.1.min(b.1),
827                    a.2.max(b.2),
828                    a.3.max(b.3),
829                )),
830                (x, y) => x.or(y),
831            }
832        };
833        // Acotada al frame y **snapeada a una grilla**: el bbox de un OSC late
834        // por sub-píxeles (el texto del reloj cambia de ancho, una barra
835        // avanza), y sin snap la scratch se reasignaría casi cada cuadro —
836        // justo lo que se vino a evitar. Con grilla, en régimen es constante.
837        // Si queda vacía (la capa cae entera fuera de la ventana) no hay nada
838        // que componer.
839        const GRILLA: f32 = 32.0;
840        // Válvula para medir: `LLIMPHI_OVER_FULLFRAME=1` vuelve al
841        // comportamiento viejo (rasterizar la capa over en el frame entero).
842        // Está para poder correr el A/B **alternando en el mismo bucle** — entre
843        // dos corridas separadas, el ruido de la máquina supera al efecto.
844        let caja_over = if over_fullframe() { None } else { caja_over };
845        let recorte = caja_over.and_then(|(x0, y0, x1, y1)| {
846            let x0 = (x0 / GRILLA).floor() * GRILLA;
847            let y0 = (y0 / GRILLA).floor() * GRILLA;
848            let x1 = (x1 / GRILLA).ceil() * GRILLA;
849            let y1 = (y1 / GRILLA).ceil() * GRILLA;
850            let x0 = x0.max(0.0);
851            let y0 = y0.max(0.0);
852            let x1 = x1.min(vw as f32);
853            let y1 = y1.min(vh as f32);
854            (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
855        });
856        let (ox, oy) = recorte.map(|r| (r.0, r.1)).unwrap_or((0.0, 0.0));
857        if over_debug() {
858            if let Some((x, y, w, h)) = recorte {
859                llimphi_hal::avisar!(
860                    "[over] recorte {w}x{h} en ({x},{y}) — {:.1}% del frame {vw}x{vh}",
861                    (w * h) / (vw as f32 * vh as f32) * 100.0
862                );
863            }
864        }
865
866        // Escena vello aparte (no pisamos `state.scene`, que el caller
867        // retiene/reusa). Fondo transparente: sólo lo pintado por los
868        // `over_painter` lleva alpha. Se pinta en coordenadas LOCALES al
869        // recorte — por eso las variantes `_at`.
870        let mut over_scene = Escena::new();
871        let mut any_over = llimphi_compositor::paint_over_at(
872            &mut over_scene,
873            &mounted,
874            &computed,
875            &mut state.typesetter,
876            (ox, oy),
877        );
878        // Subárboles de Views marcados `View::over` (UI encima de contenido
879        // gpu_paint — p. ej. el OSC del reproductor): se pintan aquí, con la
880        // maquinaria completa (fills/texto/hover), en la misma escena over.
881        any_over |= llimphi_compositor::paint_over_layers_at(
882            &mut over_scene,
883            &mounted,
884            &computed,
885            &mut state.typesetter,
886            hover_idx,
887            drop_hover_idx,
888            (ox, oy),
889        );
890        if let Some(ov) = overlay_built.as_ref() {
891            any_over |= llimphi_compositor::paint_over_at(
892                &mut over_scene,
893                &ov.mounted,
894                &ov.computed,
895                &mut state.typesetter,
896                (ox, oy),
897            );
898            any_over |= llimphi_compositor::paint_over_layers_at(
899                &mut over_scene,
900                &ov.mounted,
901                &ov.computed,
902                &mut state.typesetter,
903                ov.hover_idx,
904                None,
905                (ox, oy),
906            );
907        }
908        let m_over_0 = super::perf::mark(pf);
909        over_paint_us = super::perf::us(m_over_paint, m_over_0);
910        if any_over {
911            let (rx, ry, rw, rh) = recorte.unwrap_or((0.0, 0.0, vw as f32, vh as f32));
912            let (tw, th) = (rw.max(1.0) as u32, rh.max(1.0) as u32);
913            // Scratch transparente del tamaño del RECORTE (mismo formato que la
914            // intermedia: Rgba8Unorm). Vello escribe via compute
915            // (STORAGE_BINDING) y el composite la lee como sampler
916            // (TEXTURE_BINDING). Persistente: se reasigna sólo cuando cambia el
917            // tamaño, y el recorte viene snapeado a una grilla justamente para
918            // que no cambie por un píxel.
919            // ¿Va a pintar lo mismo que la vez pasada? La huella sale del
920            // **encoding de vello** —lo que la GPU va a leer—, no de los campos
921            // de los nodos: si es la misma, los píxeles son los mismos por
922            // construcción. Ver `llimphi_raster::huella`. `None` (perfil
923            // hybrid) = no se sabe, y entonces se rasteriza como siempre.
924            let huella = (!over_nocache())
925                .then(|| llimphi_raster::huella_escena(&over_scene))
926                .flatten();
927            let reusa = matches!(state.over_scratch, Some((_, _, w, h)) if w == tw && h == th);
928            if !reusa {
929                let tex = state.hal.device.create_texture(
930                    &llimphi_hal::wgpu::TextureDescriptor {
931                        label: Some("llimphi-ui-over-scratch"),
932                        size: llimphi_hal::wgpu::Extent3d {
933                            width: tw,
934                            height: th,
935                            depth_or_array_layers: 1,
936                        },
937                        mip_level_count: 1,
938                        sample_count: 1,
939                        dimension: llimphi_hal::wgpu::TextureDimension::D2,
940                        format: llimphi_hal::wgpu::TextureFormat::Rgba8Unorm,
941                        usage: llimphi_hal::wgpu::TextureUsages::STORAGE_BINDING
942                            | llimphi_hal::wgpu::TextureUsages::TEXTURE_BINDING
943                            | llimphi_hal::wgpu::TextureUsages::RENDER_ATTACHMENT,
944                        view_formats: &[],
945                    },
946                );
947                let view = tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default());
948                state.over_scratch = Some((tex, view, tw, th));
949                // Textura nueva = lienzo en blanco: lo que hubiera dibujado ya
950                // no está. Sin esto, un cambio de tamaño dejaría la huella
951                // vieja apuntando a una scratch vacía y la capa se vería negra.
952                state.over_huella = None;
953            }
954            // Acierto: la scratch ya tiene EXACTAMENTE esta escena dibujada.
955            // Se saltea la rasterización (el tramo caro) y se compone lo que ya
956            // está. Sólo puede acertar si además la scratch es la misma (`reusa`).
957            let acierto = reusa && huella.is_some() && state.over_huella == huella;
958            if over_debug() {
959                // Qué tramo del encoding cambió respecto del cuadro anterior:
960                // sin esto, un fallo de caché sólo dice «cambió algo».
961                let tramos = llimphi_raster::huella::huella_por_tramo(&over_scene);
962                let cambio: Vec<&str> = tramos
963                    .iter()
964                    .zip(state.over_tramos.iter())
965                    .filter(|((_, a), b)| a != *b)
966                    .map(|((n, _), _)| *n)
967                    .collect();
968                state.over_tramos = tramos.map(|(_, v)| v);
969                // Las coordenadas crudas, para poder decir QUÉ se mueve: un
970                // `path_data` que cambia sin más no señala a nadie.
971                let coords: Vec<f32> = llimphi_raster::huella::coords_de_paths(&over_scene);
972                let dif: Vec<String> = coords
973                    .iter()
974                    .zip(state.over_coords.iter())
975                    .enumerate()
976                    .filter(|(_, (a, b))| *a != *b)
977                    .map(|(i, (a, b))| format!("[{i}] {b}→{a}"))
978                    .take(6)
979                    .collect();
980                state.over_coords = coords;
981                llimphi_hal::avisar!(
982                    "[over] {} scratch {tw}x{th} reusa={reusa} cambió: {} | {}",
983                    if acierto { "ACIERTO" } else { "falla  " },
984                    if cambio.is_empty() { "nada".into() } else { cambio.join(",") },
985                    dif.join(" ")
986                );
987            }
988            let over_view = &state.over_scratch.as_ref().expect("scratch recién puesta").1;
989            let m_over_1 = super::perf::mark(pf);
990            // Vello rasteriza la escena over a la scratch (su propio
991            // submit; limpia con TRANSPARENT). Independiente de la
992            // intermedia. `tw`×`th`, no el frame entero: la escena ya viene
993            // pintada en coordenadas locales al recorte.
994            if acierto {
995                over_cache_hit = true;
996            } else if let Err(e) = state.renderer.render_to_view(
997                &state.hal,
998                &over_scene,
999                over_view,
1000                tw,
1001                th,
1002                palette::css::TRANSPARENT,
1003            ) {
1004                super::aviso_error(&format!("render over-layer error: {e}"));
1005                // Rasterización fallida: la scratch quedó en un estado que no
1006                // podemos afirmar. Se olvida la huella para no servirla como
1007                // buena el cuadro que viene.
1008                state.over_huella = None;
1009            } else {
1010                state.over_huella = huella;
1011            }
1012            let m_over_2 = super::perf::mark(pf);
1013            over_tex_us = super::perf::us(m_over_0, m_over_1);
1014            over_raster_us = super::perf::us(m_over_1, m_over_2);
1015            // Composite alpha de la scratch sobre la intermedia, en su lugar del
1016            // frame, grabado en `gpu_encoder` DESPUÉS de los pases GPU directos.
1017            state.overlay_compositor.composite_rect(
1018                &state.hal.device,
1019                &state.hal.queue,
1020                &mut gpu_encoder,
1021                frame.view(),
1022                over_view,
1023                [rx, ry, rw, rh],
1024                (vw, vh),
1025            );
1026            any_gpu = true;
1027        }
1028    }
1029    // Composición alpha del overlay SOBRE la intermedia (que ya
1030    // tiene UI + video). Último pase del encoder → corre después
1031    // del blit del video. Garantiza menús por encima del video.
1032    if composite_overlay {
1033        state.overlay_compositor.composite(
1034            &state.hal.device,
1035            &state.hal.queue,
1036            &mut gpu_encoder,
1037            frame.view(),
1038            frame.overlay_view(),
1039        );
1040        any_gpu = true;
1041    }
1042    let m_submit_0 = super::perf::mark(pf);
1043    if any_gpu {
1044        state
1045            .hal
1046            .queue
1047            .submit(std::iter::once(gpu_encoder.finish()));
1048    }
1049    let m_gpu = super::perf::mark(pf);
1050    let submit_us = super::perf::us(m_submit_0, m_gpu);
1051    super::timing_primer_present();
1052    super::aplicar_fullscreen_inicial::<A>(&state.window);
1053    super::notificar_present(&state.window);
1054    state.surface.present(frame, &state.hal);
1055    let m_present = super::perf::mark(pf);
1056    // Se vacía ANTES de sellar el fin: lo que se cronometre de acá en adelante
1057    // pertenece al hueco del cuadro SIGUIENTE, no al que se está por reportar.
1058    let otros_us = super::perf::tomar_otros(pf);
1059    super::perf::sellar_fin(pf);
1060    let a_end = super::perf::alloc_mark(pf);
1061    // Bloque 21 — emití la muestra del frame (camino completo). `mounted`
1062    // sigue vivo (se mueve recién a `last_render`, abajo). El camino
1063    // `cache_hit` no llega aquí: no se muestrea a propósito.
1064    if pf {
1065        super::perf::submit(super::perf::FrameSample {
1066            view_us: super::perf::us(m_start, m_view),
1067            mount_us: super::perf::us(m_view, m_mount),
1068            layout_us: super::perf::us(m_mount, m_layout),
1069            paint_us: super::perf::us(m_layout, m_paint),
1070            raster_us: super::perf::us(m_paint, m_raster),
1071            gpu_us: super::perf::us(m_raster, m_gpu),
1072            over_paint_us,
1073            over_tex_us,
1074            over_raster_us,
1075            over_cache_hit,
1076            frame_salteado: saltear,
1077            submit_us,
1078            present_us: super::perf::us(m_gpu, m_present),
1079            acquire_us,
1080            entre_us,
1081            otros_us,
1082            total_us: super::perf::us(m_start, m_present),
1083            n_nodes: mounted.nodes.len(),
1084            boundary_hits: state.repaint_cache.hits,
1085            boundary_misses: state.repaint_cache.misses,
1086            layout_reused,
1087            mount_recycled: hubo_reciclado,
1088            layout_recycled: hubo_reciclado_layout,
1089            allocs: super::perf::allocs(a_start, a_end),
1090            allocs_view: super::perf::allocs(a_start, a_view),
1091            allocs_mount: super::perf::allocs(a_view, a_mount),
1092            allocs_layout: super::perf::allocs(a_mount, a_layout),
1093            allocs_paint: super::perf::allocs(a_layout, a_paint),
1094            alloc_bytes: super::perf::alloc_bytes(a_start, a_end),
1095            alloc_bytes_view: super::perf::alloc_bytes(a_start, a_view),
1096            alloc_bytes_mount: super::perf::alloc_bytes(a_view, a_mount),
1097            alloc_bytes_layout: super::perf::alloc_bytes(a_mount, a_layout),
1098            alloc_bytes_paint: super::perf::alloc_bytes(a_layout, a_paint),
1099        });
1100    }
1101    // Ticker de animaciones implícitas: si quedó alguna en curso,
1102    // pedí el próximo frame. Cuando todas se asientan, `animating`
1103    // queda false y el loop de redraws se detiene solo (sin render
1104    // ocioso, sin spawn_periodic por animación).
1105    //
1106    // El mismo mecanismo sirve el **modo vsync** (`App::wants_vsync_frames`):
1107    // pedir el cuadro siguiente acá, después de `notificar_present`, es lo que
1108    // engancha el bucle al frame-callback del compositor en vez de a un
1109    // temporizador propio. Ver la doc del trait para el porqué.
1110    let vsync_frames = super::modo_vsync_activo()
1111        && A::wants_vsync_frames(state.model.as_ref().expect("model"));
1112    if animating || rippling || vsync_frames {
1113        state.window.request_redraw();
1114    }
1115    state.retained = Some(RetainedScene {
1116        w,
1117        h,
1118        animating,
1119        rippling,
1120        has_overlay: overlay_built.is_some(),
1121        // `|| saltear` — y esto **no** es un detalle. Lo que este campo tiene
1122        // que decir es «la intermedia tiene contenido que NO sale de la escena
1123        // vello», porque de eso depende la retención de cuadro entero de más
1124        // arriba: si cree que no lo hay, re-rasteriza la escena limpiando a
1125        // NEGRO y nunca vuelve a blittear el video.
1126        //
1127        // Un cuadro salteado no corre los painters GPU, así que `any_gpu` queda
1128        // en `false` — pero la intermedia SÍ tiene el video del cuadro anterior.
1129        // Sin este `|| saltear`, el cuadro siguiente entraba por la retención y
1130        // salía negro con sólo la UI encima: **el parpadeo de cuadros negros
1131        // intercalados que reportó el operador el 2026-08-10**. Se salteó
1132        // *porque* hay contenido GPU vivo; decir lo contrario es exactamente la
1133        // mentira que rompe el cuadro siguiente.
1134        has_gpu_paint: any_gpu || saltear,
1135    });
1136    state.last_render = Some(RenderCache {
1137        mounted,
1138        computed,
1139        drop_hover_idx,
1140        overlay: overlay_built,
1141    });
1142    // AccessKit: tras un paint exitoso, empujamos el árbol al
1143    // adapter. `update_if_active` se salta el closure si no hay
1144    // tecnología asistiva escuchando — coste cero en ese caso.
1145    push_a11y_tree::<A>(state);
1146
1147    // Rueda **diferida**: llegaron ruedas mientras `last_render` estaba
1148    // invalidado (un update recién había corrido). Ahora la caché de hit-test
1149    // está fresca — se procesa el delta acumulado contra ella. `process_wheel`
1150    // puede volver a invalidar `last_render` y pedir otro redraw (el ciclo se
1151    // autolimita: sin ruedas nuevas, `pending_wheel` queda en None).
1152    if let Some(wd) = state.pending_wheel.take() {
1153        crate::Runtime::<A>::process_wheel(state, handle, wd);
1154    }
1155}