tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-compositor/src/anim.rs Rust · 1664 siq'ikuna · 72.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! Animaciones **implícitas** (estilo Flutter `AnimatedContainer`): un nodo
2//! del `View` declara una `key` estable y, cuando sus props visuales de paint
3//! cambian entre frames, el runtime **interpola** en vez de saltar — sin que
4//! la app cablee un `Tween` en su `Model` ni un loop de ticks.
5//!
6//! El modelo de Llimphi reconstruye el árbol `View` cada frame desde el
7//! `Model`, así que no hay estado retenido por nodo. Este registro lo aporta:
8//! mapea `key → AnimEntry` (valor actual + objetivo + reloj) y vive en el
9//! runtime entre frames. En cada redraw, DESPUÉS de `compute` y ANTES de
10//! `paint`, el runtime llama [`AnimRegistry::reconcile`], que:
11//!
12//! 1. Para cada nodo con [`Anim`], toma su valor objetivo (lo que la `view`
13//!    pintó este frame).
14//! 2. Si el objetivo cambió respecto del guardado, arranca un tween desde el
15//!    valor interpolado actual hacia el nuevo.
16//! 3. Escribe el valor interpolado de vuelta en el nodo (fill/radius) para
17//!    que `paint` lo use.
18//! 4. Devuelve `true` si alguna animación sigue viva → el runtime pide otro
19//!    frame (`request_redraw`). Cuando todas se asientan, deja de pedir frames
20//!    (el ticker se autodetiene; no hay render loop ocioso).
21//!
22//! La **primera** aparición de una key no anima (igual que Flutter): sólo los
23//! **cambios** posteriores se interpolan. Props soportadas hoy: `fill` (color),
24//! `radius`, `alpha` (opacidad) y `transform` (afín 2D — scale/rotate/translate
25//! alrededor del centro del rect). Es ampliable agregando campos a
26//! [`AnimSnapshot`].
27//!
28//! **Animación de contenido (entrada y salida).** Aparte de los cambios de
29//! props, una key puede animar su **entrada** ([`crate::View::animated_enter`]:
30//! la primera aparición sube la opacidad de 0 a su valor) y su **salida**
31//! ([`crate::View::animated_exit`]: al desaparecer del árbol). El exit no se
32//! puede hacer sólo modificando nodos vivos — el nodo ya no está. La solución:
33//! el runtime captura la **subescena vello** que el nodo `exit` pinta cada
34//! frame mientras vive (vía [`AnimRegistry::live_exit_nodes`] +
35//! [`AnimRegistry::store_live_exit`]); cuando la key desaparece, esa subescena
36//! retenida se promueve a **fantasma** y [`AnimRegistry::replay_ghosts`] la
37//! reproduce con opacidad decreciente hasta que el reloj se agota.
38//!
39//! **Cross-fade real (`AnimatedSwitcher`).** Un nodo puede declarar
40//! ([`crate::View::animated_switch`]) una `key` estable + una **variante** de
41//! contenido. Cuando la variante cambia entre frames, el runtime promueve el
42//! contenido anterior (retenido en `live` el frame previo) a fantasma
43//! (fade-out) y arranca el subárbol nuevo desde alpha 0 (fade-in), en el mismo
44//! rect — una transición entre dos identidades distintas reusando la misma
45//! infra de ghosts del `exit`, sin tener que combinar enter+exit de dos keys.
46
47// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
48#[allow(unused_imports)]
49use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
50
51use hashbrown::HashMap;
52use core::time::Duration;
53use crate::Instant;
54
55use llimphi_text::peniko::kurbo::{Affine, Rect};
56use llimphi_text::peniko::{Color, Fill, Mix};
57use crate::Lienzo as Scene;
58
59use crate::Mounted;
60
61/// Declara que las props visuales de paint de este nodo se animan de forma
62/// implícita. `key` debe ser estable entre rebuilds del `View` (índice de
63/// item, hash de id, etc.) — es lo que enlaza "el mismo nodo" entre frames.
64#[derive(Clone, Copy, Debug)]
65pub struct Anim {
66    pub key: u64,
67    pub duration: Duration,
68    /// Easing aplicado a `t ∈ [0,1]`. Las canónicas viven en
69    /// `llimphi_theme::motion`; por defecto el builder usa un ease-out cúbico.
70    pub easing: fn(f32) -> f32,
71    /// `true` si la **primera aparición** de la key debe animar la opacidad de
72    /// 0 hacia su valor (fade-in de entrada, estilo `AnimatedSwitcher`). Las
73    /// animaciones de props (fill/radius/alpha) no entran por aquí: sólo cambian
74    /// el arranque del primer frame. Sin él, la primera aparición se asienta
75    /// instantánea (default histórico de `View::animated`).
76    pub enter: bool,
77    /// `true` si la **salida** de la key debe animar (fade-out): cuando el nodo
78    /// desaparece del árbol, el runtime retiene la última subescena que pintó y
79    /// la reproduce con opacidad decreciente durante `duration`, en vez de que
80    /// el nodo se esfume de golpe. Tiene coste por frame (captura el subárbol
81    /// mientras vive) — usar en pocos nodos (toasts, modales, paneles), no en
82    /// cada fila de una lista grande.
83    pub exit: bool,
84    /// Transformación afín desde la que arrancar la **entrada** (`enter`). Por
85    /// ej. `Some(Affine::scale(0.6))` da el "pop" del FAB; `Some(Affine::
86    /// translate((0.0, 60.0)))` da slide-in vertical. Llega al target del nodo
87    /// (`node.transform` o identidad) en `duration`. Sin efecto si `enter` es
88    /// `false`. Combinable con el fade de entrada por defecto.
89    pub enter_from_xf: Option<Affine>,
90    /// Discriminador de **variante de contenido** para cross-fade real
91    /// (Flutter `AnimatedSwitcher`). Cuando es `Some(v)` y `v` **cambia**
92    /// entre frames bajo la misma `key`, el runtime promueve la subescena del
93    /// contenido anterior a fantasma (fade-out) y hace fade-in del nuevo, en
94    /// el mismo rect — una transición real entre dos identidades distintas, no
95    /// la combinación enter+exit de dos keys. Implica captura `live` por frame
96    /// (como `exit`). La primera aparición no cruza (sólo asienta la variante).
97    pub switch: Option<u64>,
98    /// Transformación afín hacia la que **sale el contenido saliente** mientras
99    /// se desvanece (fantasma): se interpola de identidad a esta afín, aplicada
100    /// alrededor del centro del rect capturado (misma convención que
101    /// `View::transform`). `Some(Affine::translate((-w, 0)))` = slide-out a la
102    /// izquierda; `Some(Affine::scale(1.05))` = zoom-out. Con `exit`, describe
103    /// cómo sale **este nodo** al desaparecer (capturada mientras vive). Con
104    /// `switch`, describe cómo sale el **contenido viejo** al cambiar la
105    /// variante — se lee del frame NUEVO (quien conmuta conoce la dirección:
106    /// push vs pop). `None` = fade en el lugar (comportamiento histórico).
107    pub exit_to_xf: Option<Affine>,
108    /// `false` = el contenido nuevo de un `switch` NO arranca transparente
109    /// (entra opaco — necesario para un push estilo UIKit donde la página
110    /// entrante tapa a la saliente). `true` = fade-in histórico.
111    pub enter_fade: bool,
112    /// `true` = el fantasma del contenido saliente se pinta **DEBAJO** del
113    /// contenido nuevo, en la posición z del nodo (vía
114    /// [`AnimRegistry::prepare_underlays`] + `MountedNode::underlay`), en vez
115    /// de encima de toda la escena. Es el orden natural de una transición de
116    /// páginas: lo nuevo cubre a lo viejo. Como `exit_to_xf`, en un `switch`
117    /// lo declara el frame NUEVO.
118    pub exit_under: bool,
119    /// `true` = el fantasma NO se desvanece: mantiene alpha 1 hasta que su
120    /// reloj expira (y ahí desaparece de golpe — para entonces el contenido
121    /// nuevo ya lo cubre). Para salidas opacas (Cover/push UIKit).
122    pub exit_hold: bool,
123}
124
125/// Estilo completo de una transición de `switch` direccional — agrupa los
126/// cinco knobs que [`crate::View::animated_switch_styled`] pasa al [`Anim`].
127/// `Default` = cross-fade puro (idéntico a `animated_switch`).
128#[derive(Clone, Copy, Debug)]
129pub struct SwitchStyle {
130    /// Afín desde la que entra el contenido nuevo (`None` = en el lugar).
131    pub enter_from_xf: Option<Affine>,
132    /// `false` = el contenido nuevo entra opaco (sin fade-in).
133    pub enter_fade: bool,
134    /// Afín hacia la que sale el contenido viejo (`None` = en el lugar).
135    pub exit_to_xf: Option<Affine>,
136    /// `true` = el fantasma va DEBAJO del contenido nuevo (posición z real).
137    pub exit_under: bool,
138    /// `true` = el fantasma no se desvanece (salida opaca).
139    pub exit_hold: bool,
140}
141
142impl Default for SwitchStyle {
143    fn default() -> Self {
144        Self {
145            enter_from_xf: None,
146            enter_fade: true,
147            exit_to_xf: None,
148            exit_under: false,
149            exit_hold: false,
150        }
151    }
152}
153
154/// Ease-out cúbico, el default razonable para transiciones implícitas
155/// (arranca rápido, frena suave). Copia local para no acoplar el compositor a
156/// `llimphi-theme`; el caller puede pasar cualquier `fn(f32)->f32`.
157pub fn ease_out_cubic(t: f32) -> f32 {
158    let u = 1.0 - t.clamp(0.0, 1.0);
159    1.0 - u * u * u
160}
161
162/// Declara que el **tamaño** de este nodo (CSS `width`/`height` /
163/// Flutter `AnimatedSize`/Compose `animateContentSize()`) se anima de
164/// forma implícita cuando cambia entre frames. Bloque 15 de
165/// PARIDAD-FLUTTER (extensión faltante del Bloque 4).
166///
167/// A diferencia de [`Anim`] (que interpola props de **paint** después
168/// del layout: fill/radius/alpha/transform), el tamaño tiene que estar
169/// fijo **antes** del layout — siblings y hijos dependen del rect del
170/// nodo. Por eso este registro vive aparte y el reconciler camina el
171/// `View` tree **antes** de `mount`, parchando `style.size` con el
172/// valor interpolado.
173///
174/// **Límite v1**: sólo anima cuando `style.size.width` y
175/// `style.size.height` son ambas `Dimension::Length(_)`. Si una es
176/// `Percent`/`Auto`, el nodo se monta tal cual sin animación (no hay
177/// "tamaño en píxeles" estable para interpolar). El caller que quiera
178/// animar un nodo flex debe declarar `length(...)` explícito.
179#[derive(Clone, Copy, Debug)]
180pub struct SizeAnim {
181    pub key: u64,
182    pub duration: Duration,
183    pub easing: fn(f32) -> f32,
184}
185
186#[derive(Clone, Copy)]
187struct SizeAnimEntry {
188    from: (f32, f32),
189    to: (f32, f32),
190    start: Instant,
191    duration: Duration,
192    easing: fn(f32) -> f32,
193}
194
195impl SizeAnimEntry {
196    /// Entrada "asentada" (from == to): no anima. Igual que
197    /// `AnimEntry::settled`, usamos `duration: ZERO` para que `done(now)`
198    /// devuelva `true` desde el frame 0 — así la primera aparición no
199    /// pide más frames. Cuando llegue un target nuevo el reconciler
200    /// sobreescribe `duration` con el de `SizeAnim`.
201    fn settled(target: (f32, f32), now: Instant, _dur: Duration, easing: fn(f32) -> f32) -> Self {
202        Self {
203            from: target,
204            to: target,
205            start: now,
206            duration: Duration::ZERO,
207            easing,
208        }
209    }
210
211    fn t(&self, now: Instant) -> f32 {
212        if self.duration.is_zero() {
213            return 1.0;
214        }
215        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
216        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
217        (self.easing)(raw)
218    }
219
220    fn value(&self, now: Instant) -> (f32, f32) {
221        let t = self.t(now);
222        let (fw, fh) = self.from;
223        let (tw, th) = self.to;
224        (fw + (tw - fw) * t, fh + (th - fh) * t)
225    }
226
227    fn done(&self, now: Instant) -> bool {
228        now.saturating_duration_since(self.start) >= self.duration
229    }
230}
231
232/// Registro de animaciones implícitas de **tamaño**, vivo entre
233/// frames. El runtime mantiene una instancia y llama
234/// [`reconcile_size_anim`] en cada redraw **antes** del mount/layout.
235#[derive(Default)]
236pub struct SizeAnimRegistry {
237    entries: HashMap<u64, SizeAnimEntry>,
238}
239
240impl SizeAnimRegistry {
241    pub fn new() -> Self {
242        Self::default()
243    }
244
245    pub fn clear(&mut self) {
246        self.entries.clear();
247    }
248
249    /// Para tests: hay animación viva para esa key.
250    pub fn is_animating(&self, key: u64, now: Instant) -> bool {
251        self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false)
252    }
253}
254
255/// Lee `(width, height)` en píxeles si **ambos** son
256/// `Dimension::Length(_)`. Devuelve `None` si alguno es `Auto`,
257/// `Percent`, etc. — esos nodos no se animan en v1. (taffy 0.9 esconde
258/// las variantes detrás de un `CompactLength`; chequeamos por tag.)
259fn try_extract_length_size(
260    style: &llimphi_layout::Style,
261) -> Option<(f32, f32)> {
262    use llimphi_layout::taffy::CompactLength;
263    let w = style.size.width;
264    let h = style.size.height;
265    if w.tag() == CompactLength::LENGTH_TAG && h.tag() == CompactLength::LENGTH_TAG {
266        Some((w.value(), h.value()))
267    } else {
268        None
269    }
270}
271
272fn patch_length_size(style: &mut llimphi_layout::Style, size: (f32, f32)) {
273    use llimphi_layout::taffy::Dimension;
274    style.size.width = Dimension::length(size.0);
275    style.size.height = Dimension::length(size.1);
276}
277
278/// Recorre el `View` tree y, para cada nodo con [`SizeAnim`], reconcila
279/// su `style.size` con el registry: si cambió el objetivo, arranca un
280/// tween; si está animando, parcha `style.size` con el valor
281/// interpolado. Devuelve `true` si alguna animación de tamaño sigue
282/// viva → el runtime debe pedir otro redraw.
283///
284/// **Cuándo llamarlo**: el runtime lo invoca tras `A::view(model)` y
285/// **antes** de `mount`/`compute`, así el layout cascade ve el tamaño
286/// interpolado en vez del objetivo crudo (siblings y hijos reflowean
287/// suave).
288///
289/// Las keys no vistas este frame se descartan al final — un nodo que se
290/// va deja de animar (mismo comportamiento que [`AnimRegistry::reconcile`]).
291pub fn reconcile_size_anim<Msg>(
292    view: &mut crate::View<Msg>,
293    reg: &mut SizeAnimRegistry,
294    now: Instant,
295) -> bool {
296    let mut seen: Vec<u64> = Vec::new();
297    let animating = reconcile_size_anim_inner(view, reg, now, &mut seen);
298    if reg.entries.len() != seen.len() {
299        reg.entries.retain(|k, _| seen.contains(k));
300    }
301    animating
302}
303
304fn reconcile_size_anim_inner<Msg>(
305    view: &mut crate::View<Msg>,
306    reg: &mut SizeAnimRegistry,
307    now: Instant,
308    seen: &mut Vec<u64>,
309) -> bool {
310    let mut animating = false;
311    if let Some(sa) = view.rare.as_ref().and_then(|r| r.animated_size) {
312        if let Some(target) = try_extract_length_size(&view.style) {
313            seen.push(sa.key);
314            let entry = reg
315                .entries
316                .entry(sa.key)
317                .or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing));
318            if entry.to != target {
319                // Cambió el objetivo: congela el valor actual como nuevo
320                // origen y rearranca el reloj — mismo patrón que el
321                // `AnimRegistry` de props.
322                entry.from = entry.value(now);
323                entry.to = target;
324                entry.start = now;
325                entry.duration = sa.duration;
326                entry.easing = sa.easing;
327            }
328            let interp = if entry.done(now) { entry.to } else { entry.value(now) };
329            patch_length_size(&mut view.style, interp);
330            if !entry.done(now) {
331                animating = true;
332            }
333        }
334    }
335    for child in view.children.iter_mut() {
336        if reconcile_size_anim_inner(child, reg, now, seen) {
337            animating = true;
338        }
339    }
340    animating
341}
342
343/// Foto de las props animables de un nodo en un frame. `alpha == None` ≡ nodo
344/// opaco (1.0): es la convención de `View::alpha` y la usa el lerp para mezclar
345/// hacia/desde "sin alpha explícito" sin tratarlo como un salto. Lo mismo para
346/// `transform == None` ≡ identidad, así "sin transform" → "con transform" anima
347/// desde la identidad (estilo CSS `transform: none` → `transform: scale(1.5)`).
348#[derive(Clone, Copy, PartialEq)]
349struct AnimSnapshot {
350    fill: Option<Color>,
351    radius: f64,
352    alpha: Option<f32>,
353    transform: Option<Affine>,
354}
355
356#[inline]
357fn lerp_f64(a: f64, b: f64, t: f32) -> f64 {
358    a + (b - a) * t as f64
359}
360
361#[inline]
362fn lerp_color(a: Color, b: Color, t: f32) -> Color {
363    let p = a.components;
364    let q = b.components;
365    Color {
366        components: [
367            p[0] + (q[0] - p[0]) * t,
368            p[1] + (q[1] - p[1]) * t,
369            p[2] + (q[2] - p[2]) * t,
370            p[3] + (q[3] - p[3]) * t,
371        ],
372        ..a
373    }
374}
375
376/// Lerp componente-a-componente de las 6 coefs del afín (m00, m10, m01, m11,
377/// m02, m12). Es lo mismo que Flutter `MatrixTween`: no preserva una rotación
378/// pura entre matrices muy distintas, pero alcanza para las animaciones UI
379/// típicas (scale/translate/rotaciones chicas, slide-in, pop, hero).
380#[inline]
381fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
382    let p = a.as_coeffs();
383    let q = b.as_coeffs();
384    let ft = t as f64;
385    Affine::new([
386        p[0] + (q[0] - p[0]) * ft,
387        p[1] + (q[1] - p[1]) * ft,
388        p[2] + (q[2] - p[2]) * ft,
389        p[3] + (q[3] - p[3]) * ft,
390        p[4] + (q[4] - p[4]) * ft,
391        p[5] + (q[5] - p[5]) * ft,
392    ])
393}
394
395impl AnimSnapshot {
396    /// Interpola entre `self` (origen) y `to` (objetivo). El color sólo se
397    /// mezcla si ambos lados tienen fill sólido; si uno es `None` (gradiente o
398    /// sin fill) se salta al objetivo sin crossfade.
399    fn lerp(self, to: AnimSnapshot, t: f32) -> AnimSnapshot {
400        let fill = match (self.fill, to.fill) {
401            (Some(a), Some(b)) => Some(lerp_color(a, b, t)),
402            _ => to.fill,
403        };
404        // `None` ≡ opaco (1.0): un lado sin alpha se mezcla contra 1.0 en vez
405        // de saltar, así fade-in (0→opaco) y fade de un alpha explícito a/desde
406        // "sin alpha" interpolan suave. None↔None se mantiene None (sin capa).
407        let alpha = match (self.alpha, to.alpha) {
408            (None, None) => None,
409            (a, b) => {
410                let from = a.unwrap_or(1.0);
411                let dst = b.unwrap_or(1.0);
412                Some(from + (dst - from) * t)
413            }
414        };
415        // `None` ≡ identidad: idem. Un lado sin transform se mezcla contra
416        // `Affine::IDENTITY` en vez de saltar, así "sin xf" → `scale(1.5)`
417        // arranca desde scale(1) (Flutter/CSS hacen lo mismo). None↔None se
418        // mantiene None (sin push_layer afín en paint).
419        let transform = match (self.transform, to.transform) {
420            (None, None) => None,
421            (a, b) => {
422                let from = a.unwrap_or(Affine::IDENTITY);
423                let dst = b.unwrap_or(Affine::IDENTITY);
424                Some(lerp_affine(from, dst, t))
425            }
426        };
427        AnimSnapshot {
428            fill,
429            radius: lerp_f64(self.radius, to.radius, t),
430            alpha,
431            transform,
432        }
433    }
434}
435
436/// Estado retenido de una animación: tween entre `from` y `to`.
437struct AnimEntry {
438    from: AnimSnapshot,
439    to: AnimSnapshot,
440    start: Instant,
441    duration: Duration,
442    easing: fn(f32) -> f32,
443}
444
445impl AnimEntry {
446    /// Entrada ya asentada en `snap` (from == to): no anima.
447    fn settled(snap: AnimSnapshot, now: Instant) -> Self {
448        Self {
449            from: snap,
450            to: snap,
451            start: now,
452            duration: Duration::ZERO,
453            easing: |t| t,
454        }
455    }
456
457    /// Progreso `[0,1]` con easing aplicado.
458    fn t(&self, now: Instant) -> f32 {
459        if self.duration.is_zero() {
460            return 1.0;
461        }
462        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
463        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
464        (self.easing)(raw)
465    }
466
467    fn value(&self, now: Instant) -> AnimSnapshot {
468        self.from.lerp(self.to, self.t(now))
469    }
470
471    fn done(&self, now: Instant) -> bool {
472        now.saturating_duration_since(self.start) >= self.duration
473    }
474}
475
476/// Cada cuánto se **refresca** la foto de un nodo `exit`/`switch` que sigue
477/// vivo.
478///
479/// Refrescarla en CADA frame es lo que hacía antes, y es carísimo donde más se
480/// usa: la captura repinta el subárbol entero con [`crate::paint_range`], por
481/// fuera del caché de repintado, así que marcar el lienzo de una app con
482/// `animated_switch` le duplicaba el costo de pintado **para siempre**, no
483/// durante la transición. Con el cuentagotas la foto queda como mucho
484/// `REFRESCO_LIVE` atrasada, y eso no se ve: lo único que se hace con ella es
485/// el fantasma que se desvanece en ~200 ms.
486///
487/// Es el mismo reparo que ya tiene el compositor para la miniatura del cajón
488/// (`DrmState::recordar_cajon` se autolimita a una copia cada 500 ms).
489///
490/// Un nodo **sin** foto se captura siempre, sin esperar: si no, uno que aparece
491/// y se va dentro de la misma ventana no dejaría fantasma ninguno.
492const REFRESCO_LIVE: Duration = Duration::from_millis(120);
493
494/// Subescena retenida de un nodo marcado para animar su salida, capturada por
495/// el runtime el último frame que el nodo vivió. Mientras la key sigue presente
496/// se refresca con cuentagotas (`REFRESCO_LIVE`); cuando desaparece, se
497/// promueve a [`Ghost`].
498struct LiveExit {
499    scene: Scene,
500    /// Cuándo se tomó esta foto. La captura se **refresca con cuentagotas**
501    /// (ver [`REFRESCO_LIVE`]), así que hace falta saber cuánto tiene.
502    sellado: Instant,
503    duration: Duration,
504    easing: fn(f32) -> f32,
505    /// Centro del rect del nodo al capturarse — origen de la afín de salida.
506    centro: (f32, f32),
507    /// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`).
508    to_xf: Option<Affine>,
509    /// El fantasma va debajo del contenido nuevo (`Anim::exit_under`).
510    under: bool,
511    /// El fantasma no se desvanece (`Anim::exit_hold`).
512    hold: bool,
513}
514
515/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
516/// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza).
517struct Ghost {
518    scene: Scene,
519    start: Instant,
520    duration: Duration,
521    easing: fn(f32) -> f32,
522    centro: (f32, f32),
523    to_xf: Option<Affine>,
524    under: bool,
525    hold: bool,
526}
527
528impl Ghost {
529    /// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con
530    /// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca).
531    fn alpha(&self, now: Instant) -> f32 {
532        if self.hold {
533            return if self.done(now) { 0.0 } else { 1.0 };
534        }
535        1.0 - self.t(now)
536    }
537
538    /// Progreso eased `0 → 1` del fantasma.
539    fn t(&self, now: Instant) -> f32 {
540        if self.duration.is_zero() {
541            return 1.0;
542        }
543        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
544        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
545        (self.easing)(raw)
546    }
547
548    /// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del
549    /// rect capturado (misma convención que `View::transform`, así un scale
550    /// "respira" desde su centro y no desde el origen de la ventana). `None`
551    /// si el fantasma sólo se desvanece en el lugar.
552    fn xf(&self, now: Instant) -> Option<Affine> {
553        let to = self.to_xf?;
554        let t = self.t(now);
555        let local = lerp_affine(Affine::IDENTITY, to, t);
556        let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64);
557        Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)))
558    }
559
560    fn done(&self, now: Instant) -> bool {
561        now.saturating_duration_since(self.start) >= self.duration
562    }
563}
564
565/// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene
566/// una instancia y llama [`Self::reconcile`] en cada redraw.
567#[derive(Default)]
568pub struct AnimRegistry {
569    entries: HashMap<u64, AnimEntry>,
570    /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el
571    /// runtime tras el paint de cada frame). Membresía = "presente el frame
572    /// anterior".
573    live: HashMap<u64, LiveExit>,
574    /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`)
575    /// que se están desvaneciendo.
576    ghosts: HashMap<u64, Ghost>,
577    /// Última variante vista por cada key con `switch` — para detectar el
578    /// cambio de contenido que dispara el cross-fade.
579    variants: HashMap<u64, u64>,
580    /// Keys colocadas como underlay ESTE frame (por
581    /// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no
582    /// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`.
583    under_placed: Vec<u64>,
584    /// **Instrumento.** Cuántos nodos `switch` conmutaron de variante en el
585    /// último [`Self::reconcile`], y de ésos cuántos tenían foto retenida para
586    /// dejar fantasma. Dos contadores de un `u16` cada uno, sin asignaciones:
587    /// existen porque un `animated_switch` que no se ve tiene **cuatro** causas
588    /// que en el píxel son la misma (el nodo no se marcó, la variante no cambió,
589    /// no había foto, o la afín es la identidad) y ninguna se deduce mirando.
590    /// Los lee el host por [`Self::ultimo_reconcile`].
591    switches_ultimo: u16,
592    switches_con_foto_ultimo: u16,
593    /// **La quinta causa, y la única muda** (2026-09-15). Cuántos nodos `switch`
594    /// aparecieron este frame con la clave **estrenada** — o sea que no estaba en
595    /// `variants` la vuelta anterior, porque el nodo no existía en el árbol.
596    ///
597    /// Esa primera aparición **re-asienta sin animar** a propósito (una key que
598    /// vuelve no debe conmutar contra la variante que tenía hace media hora),
599    /// pero desde afuera se ve idéntica a «la variante no cambió»: los dos
600    /// contadores dan cero y el píxel no se mueve. Si el nodo sale y vuelve a
601    /// entrar al árbol en el MISMO gesto que cambia la variante, no anima nunca
602    /// y ningún contador lo dice. Éste lo dice.
603    switches_estreno_ultimo: u16,
604}
605
606impl AnimRegistry {
607    pub fn new() -> Self {
608        Self::default()
609    }
610
611    /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con
612    /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y
613    /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las
614    /// keys que no aparecieron este frame se descartan (un nodo que se va deja
615    /// de animar). Devuelve `true` si alguna animación sigue en curso.
616    ///
617    /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del
618    /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes
619    /// controlados).
620    /// **Instrumento**: `(nodos marcados con switch que conmutaron, de ésos
621    /// cuántos tenían foto, fantasmas vivos, claves estrenadas)` en el último
622    /// [`Self::reconcile`]. Lo consume el diag del host — ver el `diag!` de
623    /// `pata-llimphi::dibujar_panel`. El cuarto es el que habla cuando los dos
624    /// primeros callan: ver [`Self::switches_estreno_ultimo`].
625    pub fn ultimo_reconcile(&self) -> (u16, u16, usize, u16) {
626        (
627            self.switches_ultimo,
628            self.switches_con_foto_ultimo,
629            self.ghosts.len(),
630            self.switches_estreno_ultimo,
631        )
632    }
633
634    pub fn reconcile<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) -> bool {
635        let mut animating = false;
636        self.switches_ultimo = 0;
637        self.switches_con_foto_ultimo = 0;
638        self.switches_estreno_ultimo = 0;
639        let mut seen: Vec<u64> = Vec::new();
640        // Keys presentes que requieren captura `live` y tracking de vanish
641        // (exit O switch). Membresía = "vive este frame".
642        let mut present_live: Vec<u64> = Vec::new();
643        // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de
644        // `switch` están presentes todos los frames y su ghost (contenido
645        // viejo) NO debe cancelarse por presencia.
646        let mut present_exit_only: Vec<u64> = Vec::new();
647        for node in &mut mounted.nodes {
648            let Some(anim) = node.anim else { continue };
649            seen.push(anim.key);
650            let target = AnimSnapshot {
651                fill: node.fill,
652                radius: node.radius,
653                alpha: node.alpha,
654                transform: node.transform,
655            };
656            // Detección de cross-fade (switch) ANTES de tomar prestado
657            // `entries`: si la variante cambió, el contenido viejo retenido en
658            // `live` (del frame anterior) se promueve a fantasma (fade-out) y
659            // el nodo nuevo arranca su fade-in desde alpha 0.
660            let mut switched = false;
661            if anim.exit {
662                present_live.push(anim.key);
663                present_exit_only.push(anim.key);
664            } else if let Some(variant) = anim.switch {
665                present_live.push(anim.key);
666                let anterior = self.variants.insert(anim.key, variant);
667                if anterior.is_none() {
668                    // Clave estrenada: el nodo no estaba en el árbol la vuelta
669                    // pasada. Se re-asienta sin animar, y eso no se distingue de
670                    // «la variante no cambió» mirando los otros dos contadores.
671                    self.switches_estreno_ultimo =
672                        self.switches_estreno_ultimo.saturating_add(1);
673                }
674                if let Some(prev) = anterior {
675                    if prev != variant {
676                        switched = true;
677                        self.switches_ultimo = self.switches_ultimo.saturating_add(1);
678                        if let Some(le) = self.live.remove(&anim.key) {
679                            self.switches_con_foto_ultimo =
680                                self.switches_con_foto_ultimo.saturating_add(1);
681                            // La afín/placement/hold de salida del contenido
682                            // VIEJO los declara el frame nuevo (`anim.exit_*`):
683                            // sólo quien conmuta sabe hacia dónde debe irse lo
684                            // anterior (push ≠ pop). El centro sí es el capturado.
685                            self.ghosts.insert(
686                                anim.key,
687                                Ghost {
688                                    scene: le.scene,
689                                    start: now,
690                                    duration: le.duration,
691                                    easing: le.easing,
692                                    centro: le.centro,
693                                    to_xf: anim.exit_to_xf,
694                                    under: anim.exit_under,
695                                    hold: anim.exit_hold,
696                                },
697                            );
698                        }
699                    }
700                }
701            }
702            let entry = self.entries.entry(anim.key).or_insert_with(|| {
703                // Primera aparición. Con `enter`, arranca un tween de opacidad
704                // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también
705                // arranca de esa transform → target.transform (scale-in/slide-in).
706                if anim.enter {
707                    let from = AnimSnapshot {
708                        alpha: Some(0.0),
709                        transform: anim.enter_from_xf.or(target.transform),
710                        ..target
711                    };
712                    AnimEntry {
713                        from,
714                        to: target,
715                        start: now,
716                        duration: anim.duration,
717                        easing: anim.easing,
718                    }
719                } else {
720                    AnimEntry::settled(target, now)
721                }
722            });
723            if switched {
724                // Cross-fade: el contenido nuevo entra desde transparente
725                // (el viejo ya quedó como fantasma desvaneciéndose). Con
726                // `enter_fade: false` entra OPACO (push estilo UIKit: la
727                // página nueva tapa, no se funde). Si además declara
728                // `enter_from_xf`, entra desde esa afín (slide/zoom).
729                entry.from = AnimSnapshot {
730                    alpha: if anim.enter_fade { Some(0.0) } else { target.alpha },
731                    transform: anim.enter_from_xf.or(target.transform),
732                    ..target
733                };
734                entry.to = target;
735                entry.start = now;
736                entry.duration = anim.duration;
737                entry.easing = anim.easing;
738            } else if entry.to != target {
739                // Cambió el objetivo: congela el valor actual como nuevo origen
740                // y rearranca el reloj hacia el objetivo nuevo.
741                entry.from = entry.value(now);
742                entry.to = target;
743                entry.start = now;
744                entry.duration = anim.duration;
745                entry.easing = anim.easing;
746            }
747            // Al terminar aterriza EXACTO en el objetivo (incluido `alpha:
748            // None` / `transform: None`, que evita capa de opacidad residual o
749            // un push_layer afín espurio frame a frame).
750            let v = if entry.done(now) { entry.to } else { entry.value(now) };
751            node.fill = v.fill;
752            node.radius = v.radius;
753            node.alpha = v.alpha;
754            node.transform = v.transform;
755            if !entry.done(now) {
756                animating = true;
757            }
758        }
759        if self.entries.len() != seen.len() {
760            self.entries.retain(|k, _| seen.contains(k));
761        }
762        // Las variantes de keys que ya no aparecen se descartan (si la key
763        // vuelve, su primera aparición re-asienta sin cross-fade).
764        if self.variants.len() != seen.len() {
765            self.variants.retain(|k, _| seen.contains(k));
766        }
767
768        // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior
769        // (vive en `live`) que ya no aparece → se promueve a fantasma con su
770        // última subescena retenida. Si una key `exit` con fantasma reaparece,
771        // se cancela el fade (no las de `switch`: su fantasma es contenido viejo
772        // que debe seguir desvaneciéndose aunque la key siga presente). Por
773        // último, descartamos los fantasmas cuyo reloj se agotó.
774        let vanished: Vec<u64> = self
775            .live
776            .keys()
777            .filter(|k| !present_live.contains(k))
778            .copied()
779            .collect();
780        for key in vanished {
781            if let Some(le) = self.live.remove(&key) {
782                self.ghosts.insert(
783                    key,
784                    Ghost {
785                        scene: le.scene,
786                        start: now,
787                        duration: le.duration,
788                        easing: le.easing,
789                        centro: le.centro,
790                        to_xf: le.to_xf,
791                        under: le.under,
792                        hold: le.hold,
793                    },
794                );
795            }
796        }
797        for key in &present_exit_only {
798            self.ghosts.remove(key);
799        }
800        self.ghosts.retain(|_, g| !g.done(now));
801        animating || !self.ghosts.is_empty()
802    }
803
804    /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por
805    /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en
806    /// una subescena con [`crate::paint_range`] y entregarla a
807    /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y
808    /// la geometría ya están firmes).
809    ///
810    /// **No devuelve todos los nodos marcados: sólo los que toca refrescar.**
811    /// Ver `REFRESCO_LIVE` — repintar el subárbol de cada uno en cada frame
812    /// era el precio oculto de `animated_switch`, y lo paga quien lo pone sobre
813    /// el nodo más caro de su app, que es justo para lo que sirve.
814    pub fn live_exit_nodes<Msg>(
815        &self,
816        mounted: &Mounted<Msg>,
817        now: Instant,
818    ) -> Vec<(usize, usize, u64)> {
819        mounted
820            .nodes
821            .iter()
822            .enumerate()
823            .filter_map(|(idx, n)| {
824                n.anim
825                    .filter(|a| a.exit || a.switch.is_some())
826                    .filter(|a| self.toca_refrescar(a.key, now))
827                    .map(|a| (idx, n.subtree_end, a.key))
828            })
829            .collect()
830    }
831
832    /// ¿La foto de `key` está vencida (o no existe)?
833    fn toca_refrescar(&self, key: u64, now: Instant) -> bool {
834        match self.live.get(&key) {
835            None => true,
836            Some(l) => now.saturating_duration_since(l.sellado) >= REFRESCO_LIVE,
837        }
838    }
839
840    /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El
841    /// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim`
842    /// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/
843    /// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en
844    /// un `switch` la salida la re-declara el frame nuevo). `centro` es el
845    /// centro del rect del nodo (origen de la afín de salida).
846    ///
847    /// **Sólo existe con `std`**, porque lee el reloj. Sin sistema operativo no
848    /// hay reloj global al que preguntarle la hora —lo dice el encabezado del
849    /// crate— y el runtime ya trae el instante del fotograma: ahí va
850    /// [`Self::store_live_exit_en`]. La ausencia de `Instant::now()` está puesta
851    /// para que un uso equivocado sea un error de compilación en vez de una
852    /// animación congelada; **este atajo se le había escapado**, y el error salía
853    /// sólo en un perfil que ningún check del árbol compilaba.
854    #[cfg(feature = "std")]
855    pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) {
856        self.store_live_exit_en(key, scene, centro, anim, Instant::now())
857    }
858
859    /// Como [`Self::store_live_exit`] con el reloj explícito — lo usa el runtime
860    /// (que ya tiene el `now` del frame) y los tests.
861    pub fn store_live_exit_en(
862        &mut self,
863        key: u64,
864        scene: Scene,
865        centro: (f32, f32),
866        anim: &Anim,
867        now: Instant,
868    ) {
869        self.live.insert(
870            key,
871            LiveExit {
872                scene,
873                sellado: now,
874                duration: anim.duration,
875                easing: anim.easing,
876                centro,
877                to_xf: anim.exit_to_xf,
878                under: anim.exit_under,
879                hold: anim.exit_hold,
880            },
881        );
882    }
883
884    /// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la
885    /// subescena (ya envuelta en su capa de alpha + afín de salida) de cada
886    /// fantasma `under` cuyo `switch` sigue montado — el paint la apendea
887    /// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro
888    /// del clip/transform de sus ancestros). Las keys colocadas se saltean en
889    /// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`]
890    /// y ANTES del paint.
891    pub fn prepare_underlays<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) {
892        self.under_placed.clear();
893        if self.ghosts.is_empty() {
894            return;
895        }
896        for node in &mut mounted.nodes {
897            let Some(anim) = node.anim else { continue };
898            if anim.switch.is_none() {
899                continue;
900            }
901            let Some(g) = self.ghosts.get(&anim.key) else { continue };
902            if !g.under {
903                continue;
904            }
905            let a = g.alpha(now);
906            if a <= 0.0 {
907                continue;
908            }
909            // Envoltorio: capa de alpha (rect generoso — el clip real lo
910            // ponen los ancestros del nodo en el paint) + la afín de salida.
911            let mut wrap = Scene::new();
912            let big = Rect::new(-1e6, -1e6, 1e6, 1e6);
913            wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big);
914            wrap.append(&g.scene, g.xf(now));
915            wrap.pop_layer();
916            node.underlay = Some(wrap);
917            self.under_placed.push(anim.key);
918        }
919    }
920
921    /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
922    /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint
923    /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo
924    /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo).
925    /// **Olvidá lo que estaba saliendo**: tira los fantasmas vivos y las fotos
926    /// retenidas de los nodos marcados para animar su salida.
927    ///
928    /// Lo pide el host cuando lo que se estaba desvaneciendo **dejó de existir**
929    /// de un modo que la animación no puede saber. El caso que lo trajo: el
930    /// cajón de shuma se guarda con un enrollado propio (un clip que lo deja
931    /// invisible en 115 ms) y su lienzo, que está marcado con `animated_switch`
932    /// para paginar entre sesiones, desaparece del árbol. Acá eso se lee como
933    /// una salida y se pinta la foto retenida —**el cajón abierto y entero**,
934    /// hasta 120 ms vieja— desvaneciéndose 160 ms más, sin el clip que lo estaba
935    /// enrollando y sobre una surface que todavía mide la pantalla completa. En
936    /// el píxel eso es el cajón volviendo un instante después de guardarlo, y el
937    /// operador lo reportó como *«guarda y luego parpadea para guardarse otra
938    /// vez»* (medido cuadro a cuadro el 2026-09-17).
939    ///
940    /// No es un caso que la animación pueda detectar sola: desde acá, un nodo
941    /// que se va es un nodo que se va. Quien sabe que el cajón se guardó —y que
942    /// por lo tanto no hay nada que despedir— es el host.
943    pub fn clear_exits(&mut self) {
944        self.ghosts.clear();
945        self.live.clear();
946    }
947
948    pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool {
949        if self.ghosts.is_empty() {
950            return false;
951        }
952        let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
953        for (key, g) in &self.ghosts {
954            // Los colocados como underlay este frame ya se pintaron en su
955            // posición z (dentro del árbol) — no duplicarlos encima.
956            if self.under_placed.contains(key) {
957                continue;
958            }
959            let a = g.alpha(now);
960            if a <= 0.0 {
961                continue;
962            }
963            scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip);
964            scene.append(&g.scene, g.xf(now));
965            scene.pop_layer();
966        }
967        true
968    }
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::{mount, View};
975    use llimphi_layout::{LayoutTree, Style};
976
977    fn rgba(r: u8, g: u8, b: u8) -> Color {
978        Color::from_rgba8(r, g, b, 255)
979    }
980
981    /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`.
982    fn one(fill: Color) -> Mounted<()> {
983        let v = View::<()>::new(Style::default())
984            .fill(fill)
985            .animated(1, Duration::from_millis(200));
986        let mut layout = LayoutTree::new();
987        mount(&mut layout, v)
988    }
989
990    #[test]
991    fn primera_aparicion_no_anima() {
992        let mut reg = AnimRegistry::new();
993        let mut m = one(rgba(255, 0, 0));
994        let now = Instant::now();
995        let animating = reg.reconcile(&mut m, now);
996        assert!(!animating, "la primera vez no debe animar");
997        assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0)));
998    }
999
1000    #[test]
1001    fn cambio_de_color_interpola_y_pide_frames() {
1002        let mut reg = AnimRegistry::new();
1003        let t0 = Instant::now();
1004        // Frame 1: rojo, se asienta.
1005        let mut m = one(rgba(255, 0, 0));
1006        reg.reconcile(&mut m, t0);
1007        // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que
1008        // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo)
1009        // pero ya pide frames.
1010        let mut m = one(rgba(0, 0, 255));
1011        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1012        assert!(animating, "al detectar el cambio debe pedir frames");
1013        // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado:
1014        // ni rojo puro ni azul puro.
1015        let mut m = one(rgba(0, 0, 255));
1016        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200));
1017        assert!(animating, "a mitad del tween debe seguir animando");
1018        let c = m.nodes[0].fill.expect("fill").components;
1019        assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]);
1020        assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]);
1021    }
1022
1023    #[test]
1024    fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() {
1025        let mut reg = AnimRegistry::new();
1026        let t0 = Instant::now();
1027        let mut m = one(rgba(255, 0, 0));
1028        reg.reconcile(&mut m, t0);
1029        let mut m = one(rgba(0, 0, 255));
1030        reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca
1031        // Pasada la duración, llega exacto al objetivo y no pide más frames.
1032        let mut m = one(rgba(0, 0, 255));
1033        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1034        assert!(!animating);
1035        assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255)));
1036    }
1037
1038    /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`.
1039    fn one_alpha(alpha: f32) -> Mounted<()> {
1040        let v = View::<()>::new(Style::default())
1041            .alpha(alpha)
1042            .animated(1, Duration::from_millis(200));
1043        let mut layout = LayoutTree::new();
1044        mount(&mut layout, v)
1045    }
1046
1047    /// Monta un nodo opaco (sin alpha) con animación de ENTRADA.
1048    fn one_enter() -> Mounted<()> {
1049        let v = View::<()>::new(Style::default())
1050            .fill(rgba(10, 20, 30))
1051            .animated_enter(1, Duration::from_millis(200));
1052        let mut layout = LayoutTree::new();
1053        mount(&mut layout, v)
1054    }
1055
1056    #[test]
1057    fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() {
1058        let mut reg = AnimRegistry::new();
1059        let t0 = Instant::now();
1060        // Primera aparición de un nodo `enter`: a diferencia de `animated`,
1061        // SÍ anima — arranca casi transparente y pide frames.
1062        let mut m = one_enter();
1063        let animating = reg.reconcile(&mut m, t0);
1064        assert!(animating, "la entrada debe animar desde el primer frame");
1065        assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente");
1066        // A mitad del tween, alpha intermedio.
1067        let mut m = one_enter();
1068        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1069        let a = m.nodes[0].alpha.expect("alpha");
1070        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
1071        // Pasada la duración: opaco exacto (None, sin capa residual) y quieto.
1072        let mut m = one_enter();
1073        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1074        assert!(!animating);
1075        assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa");
1076    }
1077
1078    /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`.
1079    fn one_exit() -> Mounted<()> {
1080        let v = View::<()>::new(Style::default())
1081            .fill(rgba(10, 20, 30))
1082            .animated_exit(7, Duration::from_millis(200));
1083        let mut layout = LayoutTree::new();
1084        mount(&mut layout, v)
1085    }
1086
1087    /// Árbol vacío de nodos animados (la key `exit` ya no aparece).
1088    fn empty() -> Mounted<()> {
1089        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
1090        let mut layout = LayoutTree::new();
1091        mount(&mut layout, v)
1092    }
1093
1094    #[test]
1095    fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() {
1096        let mut reg = AnimRegistry::new();
1097        let t0 = Instant::now();
1098        // Frame 1: el nodo exit está presente. No anima por sí solo, y el
1099        // runtime captura su subescena (aquí una vacía de prueba).
1100        let mut m = one_exit();
1101        let animating = reg.reconcile(&mut m, t0);
1102        assert!(!animating, "presente y quieto no anima");
1103        reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1104        // Frame 2: la key desaparece → se promueve a fantasma y pide frames.
1105        let mut m = empty();
1106        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1107        assert!(animating, "un fantasma vivo mantiene el ticker");
1108        assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
1109        // Frame 3: pasada la duración el fantasma se descarta y el loop para.
1110        let mut m = empty();
1111        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300));
1112        assert!(!animating, "fantasma agotado → sin más frames");
1113        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0));
1114    }
1115
1116    /// Monta un nodo `switch` (key=5) con la variante dada.
1117    fn one_switch(variant: u64) -> Mounted<()> {
1118        let v = View::<()>::new(Style::default())
1119            .fill(rgba(10, 20, 30))
1120            .animated_switch(5, variant, Duration::from_millis(200));
1121        let mut layout = LayoutTree::new();
1122        mount(&mut layout, v)
1123    }
1124
1125    /// **La quinta causa, y la que ningún contador decía** (2026-09-15). Un nodo
1126    /// `switch` que sale del árbol y vuelve **con otra variante** no cruza nada:
1127    /// su clave se estrenó, así que se re-asienta. Es correcto —una key que
1128    /// vuelve no debe conmutar contra la variante que tenía hace media hora— y
1129    /// es indistinguible de «no pasó nada» mirando los dos contadores viejos:
1130    /// `conmutaron=0 con-foto=0`, igual que en reposo. El tercer contador es el
1131    /// que lo nombra.
1132    #[test]
1133    fn una_clave_que_sale_y_vuelve_no_conmuta_y_el_contador_lo_dice() {
1134        let mut reg = AnimRegistry::new();
1135        let t0 = Instant::now();
1136
1137        // Frame 1: aparece con la variante 1. Estreno.
1138        let mut m = one_switch(1);
1139        reg.reconcile(&mut m, t0);
1140        assert_eq!(reg.ultimo_reconcile().0, 0, "una primera aparición no conmuta");
1141        assert_eq!(reg.ultimo_reconcile().3, 1, "…y se declara estreno");
1142
1143        // Frame 2: el nodo NO está en el árbol (el panel pintó otra cosa).
1144        let mut vacio = empty();
1145        reg.reconcile(&mut vacio, t0 + Duration::from_millis(10));
1146
1147        // Frame 3: vuelve, y con OTRA variante. Desde afuera es el mismo gesto
1148        // que un cambio de pestaña; desde adentro es un estreno.
1149        let mut m = one_switch(2);
1150        reg.reconcile(&mut m, t0 + Duration::from_millis(20));
1151        let (conmutaron, con_foto, _, estrenos) = reg.ultimo_reconcile();
1152        assert_eq!(conmutaron, 0, "no cruza: la clave venía de fuera del árbol");
1153        assert_eq!(con_foto, 0);
1154        assert_eq!(estrenos, 1, "el instrumento nombra la puerta en vez de callarse");
1155
1156        // Y el brazo de control: quedándose en el árbol, el mismo cambio SÍ cruza.
1157        let mut m = one_switch(3);
1158        reg.reconcile(&mut m, t0 + Duration::from_millis(30));
1159        let (conmutaron, _, _, estrenos) = reg.ultimo_reconcile();
1160        assert_eq!(conmutaron, 1, "sin salir del árbol, el cambio de variante cruza");
1161        assert_eq!(estrenos, 0);
1162    }
1163
1164    #[test]
1165    fn switch_de_variante_cruza_contenido() {
1166        let mut reg = AnimRegistry::new();
1167        let t0 = Instant::now();
1168        // Frame 1: variante 1, primera aparición → asienta, no cruza.
1169        let mut m = one_switch(1);
1170        assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
1171        // El runtime captura su subescena (de prueba, vacía).
1172        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1173        // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi
1174        // transparente y hay un fantasma del contenido viejo desvaneciéndose.
1175        let mut m = one_switch(2);
1176        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1177        assert!(animating, "el cross-fade pide frames");
1178        let a = m.nodes[0].alpha.expect("alpha de fade-in");
1179        assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}");
1180        assert!(
1181            reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0),
1182            "hay un fantasma del contenido viejo"
1183        );
1184        // Re-captura del frame 2 (lo haría el runtime tras el paint).
1185        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1186        // Frame 3: misma variante, pasada la duración → asentado y sin fantasma.
1187        let mut m = one_switch(2);
1188        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1189        assert!(!animating, "asentado tras la duración");
1190        assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual");
1191        assert!(
1192            !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0),
1193            "fantasma agotado"
1194        );
1195    }
1196
1197    #[test]
1198    fn switch_misma_variante_no_cruza() {
1199        let mut reg = AnimRegistry::new();
1200        let t0 = Instant::now();
1201        let mut m = one_switch(1);
1202        reg.reconcile(&mut m, t0);
1203        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1204        // Misma variante en el frame siguiente: ni fade-in ni fantasma.
1205        let mut m = one_switch(1);
1206        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1207        assert!(!animating, "sin cambio de variante no cruza");
1208        assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco");
1209        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
1210    }
1211
1212    #[test]
1213    fn reaparecer_cancela_el_fantasma() {
1214        let mut reg = AnimRegistry::new();
1215        let t0 = Instant::now();
1216        let mut m = one_exit();
1217        reg.reconcile(&mut m, t0);
1218        reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1219        // Se va → fantasma.
1220        let mut m = empty();
1221        assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1222        // Reaparece a mitad del fade → el fantasma se cancela (no hay doble).
1223        let mut m = one_exit();
1224        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1225        assert!(!animating, "al reaparecer no queda fantasma");
1226        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0));
1227    }
1228
1229    /// Monta un nodo con un transform afín explícito + anim(key=1).
1230    fn one_xf(xf: Affine) -> Mounted<()> {
1231        let v = View::<()>::new(Style::default())
1232            .transform(xf)
1233            .animated(1, Duration::from_millis(200));
1234        let mut layout = LayoutTree::new();
1235        mount(&mut layout, v)
1236    }
1237
1238    /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0).
1239    fn one_pop_in() -> Mounted<()> {
1240        let v = View::<()>::new(Style::default())
1241            .fill(rgba(1, 2, 3))
1242            .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5));
1243        let mut layout = LayoutTree::new();
1244        mount(&mut layout, v)
1245    }
1246
1247    #[test]
1248    fn cambio_de_transform_interpola_y_pide_frames() {
1249        let mut reg = AnimRegistry::new();
1250        let t0 = Instant::now();
1251        // Frame 1: identidad → se asienta sin animar.
1252        let mut m = one_xf(Affine::IDENTITY);
1253        assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
1254        // Frame 2: la view ahora pide scale(2.0) → arranca tween.
1255        let mut m = one_xf(Affine::scale(2.0));
1256        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1257        assert!(animating, "al cambiar la xf debe pedir frames");
1258        // Frame 3: a mitad, el m00 está entre 1.0 y 2.0.
1259        let mut m = one_xf(Affine::scale(2.0));
1260        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1261        let c = m.nodes[0].transform.expect("transform").as_coeffs();
1262        assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]);
1263        assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]);
1264    }
1265
1266    #[test]
1267    fn transform_al_terminar_llega_exacto() {
1268        let mut reg = AnimRegistry::new();
1269        let t0 = Instant::now();
1270        let mut m = one_xf(Affine::IDENTITY);
1271        reg.reconcile(&mut m, t0);
1272        let mut m = one_xf(Affine::translate((10.0, 20.0)));
1273        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1274        // Pasada la duración: aterriza exacto en la xf objetivo.
1275        let mut m = one_xf(Affine::translate((10.0, 20.0)));
1276        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1277        assert!(!animating);
1278        let c = m.nodes[0].transform.expect("xf").as_coeffs();
1279        assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]);
1280        assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]);
1281    }
1282
1283    #[test]
1284    fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() {
1285        let mut reg = AnimRegistry::new();
1286        let t0 = Instant::now();
1287        // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La
1288        // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller)
1289        // y debe pedir frames.
1290        let mut m = one_pop_in();
1291        let animating = reg.reconcile(&mut m, t0);
1292        assert!(animating, "pop-in anima desde el primer frame");
1293        let c = m.nodes[0].transform.expect("xf inicial").as_coeffs();
1294        assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]);
1295        // Frame intermedio: el m00 ya creció hacia 1.0.
1296        let mut m = one_pop_in();
1297        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1298        let c = m.nodes[0].transform.expect("xf medio").as_coeffs();
1299        assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]);
1300        // Frame final: aterriza en None (sin xf residual), igual que alpha.
1301        let mut m = one_pop_in();
1302        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1303        assert!(!animating, "asentado");
1304        assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse");
1305    }
1306
1307    #[test]
1308    fn cambio_de_alpha_interpola() {
1309        let mut reg = AnimRegistry::new();
1310        let t0 = Instant::now();
1311        // Frame 1: alpha 1.0, se asienta (no es `enter`).
1312        let mut m = one_alpha(1.0);
1313        let animating = reg.reconcile(&mut m, t0);
1314        assert!(!animating, "primera aparición sin enter no anima");
1315        // Frame 2: la view baja a 0.0 → arranca tween.
1316        let mut m = one_alpha(0.0);
1317        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1318        // Frame 3: a mitad, alpha intermedio.
1319        let mut m = one_alpha(0.0);
1320        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1321        let a = m.nodes[0].alpha.expect("alpha");
1322        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
1323    }
1324
1325    #[test]
1326    fn keys_que_se_van_se_descartan() {
1327        let mut reg = AnimRegistry::new();
1328        let now = Instant::now();
1329        let mut m = one(rgba(1, 2, 3));
1330        reg.reconcile(&mut m, now);
1331        assert_eq!(reg.entries.len(), 1);
1332        // Frame sin ningún nodo animado: la entrada se descarta.
1333        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
1334        let mut layout = LayoutTree::new();
1335        let mut m2 = mount(&mut layout, v);
1336        reg.reconcile(&mut m2, now);
1337        assert_eq!(reg.entries.len(), 0);
1338    }
1339
1340    // ─── Bloque 15: tests de SizeAnim / animateContentSize ───
1341
1342    fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> {
1343        use llimphi_layout::taffy::prelude::{length, Size};
1344        let mut style = Style::default();
1345        style.size = Size { width: length(w), height: length(h) };
1346        View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms))
1347    }
1348
1349    #[test]
1350    fn size_anim_primera_aparicion_no_anima() {
1351        let mut reg = SizeAnimRegistry::new();
1352        let mut v = sized_view(1, 100.0, 80.0, 200);
1353        let now = Instant::now();
1354        let animating = reconcile_size_anim(&mut v, &mut reg, now);
1355        assert!(!animating, "primera vez: sin animación");
1356        // El style.size queda intacto (length(100, 80)).
1357        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1358        assert_eq!((w, h), (100.0, 80.0));
1359    }
1360
1361    #[test]
1362    fn size_anim_cambia_target_interpola() {
1363        let mut reg = SizeAnimRegistry::new();
1364        let t0 = Instant::now();
1365        // Frame 1: target = 100×80, se asienta.
1366        let mut v = sized_view(1, 100.0, 80.0, 200);
1367        reconcile_size_anim(&mut v, &mut reg, t0);
1368        // Frame 2: target nuevo = 200×160. En el frame que se detecta el
1369        // cambio arranca el reloj — todavía pinta cerca del origen.
1370        let mut v = sized_view(1, 200.0, 160.0, 200);
1371        let animating = reconcile_size_anim(&mut v, &mut reg, t0);
1372        assert!(animating, "cambio de target: pide frames");
1373        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1374        assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}");
1375        assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}");
1376        // Frame 3: 100 ms (mitad del tween).
1377        let mut v = sized_view(1, 200.0, 160.0, 200);
1378        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100));
1379        assert!(animating, "a mitad del tween sigue animando");
1380        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1381        assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}");
1382        assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}");
1383    }
1384
1385    #[test]
1386    fn size_anim_termina_y_se_detiene() {
1387        let mut reg = SizeAnimRegistry::new();
1388        let t0 = Instant::now();
1389        let mut v = sized_view(1, 100.0, 80.0, 200);
1390        reconcile_size_anim(&mut v, &mut reg, t0);
1391        let mut v = sized_view(1, 200.0, 160.0, 200);
1392        reconcile_size_anim(&mut v, &mut reg, t0); // arranca
1393        // Pasada la duración: aterriza exacto en el objetivo y no pide más.
1394        let mut v = sized_view(1, 200.0, 160.0, 200);
1395        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400));
1396        assert!(!animating);
1397        assert_eq!(
1398            (v.style.size.width.value(), v.style.size.height.value()),
1399            (200.0, 160.0),
1400        );
1401    }
1402
1403    #[test]
1404    fn size_anim_no_animable_si_tamano_no_es_length() {
1405        // Si el caller declara percent o auto, el reconciler lo deja pasar
1406        // sin tracking — no hay valor en píxeles estable para interpolar.
1407        use llimphi_layout::taffy::prelude::{percent, Dimension, Size};
1408        let mut reg = SizeAnimRegistry::new();
1409        let mut style = Style::default();
1410        style.size = Size { width: percent(0.5), height: Dimension::auto() };
1411        let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200));
1412        let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now());
1413        assert!(!animating);
1414        // El size no se tocó: width sigue siendo percent (no LENGTH_TAG).
1415        use llimphi_layout::taffy::CompactLength;
1416        assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG);
1417    }
1418
1419    #[test]
1420    fn size_anim_descarta_keys_no_vistas() {
1421        let mut reg = SizeAnimRegistry::new();
1422        let now = Instant::now();
1423        let mut v = sized_view(42, 50.0, 50.0, 200);
1424        reconcile_size_anim(&mut v, &mut reg, now);
1425        assert_eq!(reg.entries.len(), 1);
1426        // Frame sin animated_size: la entrada se descarta.
1427        let mut v: View<()> = View::<()>::new(Style::default());
1428        reconcile_size_anim(&mut v, &mut reg, now);
1429        assert_eq!(reg.entries.len(), 0);
1430    }
1431
1432    // ── Salida direccional (exit_to_xf) — base del router de páginas ────────
1433
1434    #[test]
1435    fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() {
1436        let t0 = Instant::now();
1437        let g = Ghost {
1438            scene: Scene::new(),
1439            start: t0,
1440            duration: Duration::from_millis(200),
1441            easing: |t| t, // lineal para aserciones exactas
1442            centro: (100.0, 50.0),
1443            to_xf: Some(Affine::translate((-200.0, 0.0))),
1444            under: false,
1445            hold: false,
1446        };
1447        // t=0: afín ≈ identidad (el fantasma arranca donde estaba).
1448        let xf0 = g.xf(t0).expect("hay afín");
1449        let c0 = xf0.as_coeffs();
1450        assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}");
1451        // t=0.5: mitad del desplazamiento. Una traslación conmuta con la
1452        // conjugación por el centro, así que el coef es -100 exacto.
1453        let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva");
1454        let c = xf.as_coeffs();
1455        assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}");
1456        // Sin to_xf → None (fade puro, camino histórico).
1457        let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g };
1458        assert!(g2.xf(t0 + Duration::from_millis(100)).is_none());
1459    }
1460
1461    #[test]
1462    fn fantasma_con_scale_respira_desde_su_centro() {
1463        let t0 = Instant::now();
1464        let g = Ghost {
1465            scene: Scene::new(),
1466            start: t0,
1467            duration: Duration::from_millis(100),
1468            easing: |t| t,
1469            centro: (100.0, 100.0),
1470            to_xf: Some(Affine::scale(2.0)),
1471            under: false,
1472            hold: false,
1473        };
1474        // Al final del tween (t=1) la afín es scale(2) conjugada por el
1475        // centro: el centro queda FIJO (100,100 → 100,100).
1476        let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín");
1477        let p = xf * llimphi_text::peniko::kurbo::Point::new(100.0, 100.0);
1478        assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6,
1479                "el centro no se mueve: {p:?}");
1480        // Un punto a 50px del centro termina a 100px (escala 2).
1481        let q = xf * llimphi_text::peniko::kurbo::Point::new(150.0, 100.0);
1482        assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}");
1483    }
1484
1485    #[test]
1486    fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() {
1487        let salida = Affine::translate((-300.0, 0.0));
1488        let entrada = Affine::translate((300.0, 0.0));
1489        let one = |variant: u64| -> Mounted<()> {
1490            let v = View::<()>::new(Style::default())
1491                .fill(rgba(10, 20, 30))
1492                .animated_switch_from(5, variant, Duration::from_millis(200),
1493                                      Some(entrada), Some(salida));
1494            let mut layout = LayoutTree::new();
1495            mount(&mut layout, v)
1496        };
1497        let mut reg = AnimRegistry::new();
1498        let t0 = Instant::now();
1499        // Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1,
1500        // que NO es el que debe usarse al conmutar).
1501        let mut m = one(1);
1502        reg.reconcile(&mut m, t0);
1503        let mut senuelo = m.nodes[0].anim.expect("anim");
1504        senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse
1505        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo);
1506        // Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia
1507        // `entrada` (además de transparente) y el fantasma lleva la `salida`
1508        // declarada por el frame nuevo, no el señuelo capturado.
1509        let mut m = one(2);
1510        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1511        assert!(animating);
1512        let xf = m.nodes[0].transform.expect("transform de entrada");
1513        let c = xf.as_coeffs();
1514        assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}");
1515        let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo");
1516        assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo");
1517    }
1518
1519    /// **La captura va con cuentagotas.** Repintar el subárbol de un nodo
1520    /// `switch` en cada frame es el costo escondido de la animación, y lo paga
1521    /// el nodo más caro de la app. Sin foto se captura ya; con una fresca, no.
1522    #[test]
1523    fn la_foto_del_nodo_vivo_no_se_refresca_en_cada_frame() {
1524        let uno = || -> Mounted<()> {
1525            let v = View::<()>::new(Style::default())
1526                .fill(rgba(10, 20, 30))
1527                .animated_switch(7, 1, Duration::from_millis(200));
1528            let mut layout = LayoutTree::new();
1529            mount(&mut layout, v)
1530        };
1531        let mut reg = AnimRegistry::new();
1532        let t0 = Instant::now();
1533        let m = uno();
1534        // Sin foto: hay que capturar sí o sí — si no, un nodo que aparece y se
1535        // va dentro de la ventana no dejaría fantasma ninguno.
1536        assert_eq!(reg.live_exit_nodes(&m, t0).len(), 1, "la primera foto no espera");
1537        let anim = m.nodes[0].anim.expect("anim");
1538        reg.store_live_exit_en(7, Scene::new(), (0.0, 0.0), &anim, t0);
1539        // Frame siguiente (16 ms): la foto está fresca, no se repinta nada.
1540        assert!(
1541            reg.live_exit_nodes(&m, t0 + Duration::from_millis(16)).is_empty(),
1542            "con foto fresca no se vuelve a pintar el subárbol"
1543        );
1544        // Pasado el plazo, sí.
1545        assert_eq!(
1546            reg.live_exit_nodes(&m, t0 + REFRESCO_LIVE).len(),
1547            1,
1548            "vencida, se refresca"
1549        );
1550    }
1551
1552    /// Y el cuentagotas **no puede** romper la promesa: una key con foto sigue
1553    /// contando como presente, así que al desaparecer deja su fantasma igual.
1554    #[test]
1555    fn la_foto_atrasada_igual_deja_fantasma_al_desaparecer() {
1556        let con = || -> Mounted<()> {
1557            let v = View::<()>::new(Style::default())
1558                .fill(rgba(10, 20, 30))
1559                .animated_exit(8, Duration::from_millis(200));
1560            let mut layout = LayoutTree::new();
1561            mount(&mut layout, v)
1562        };
1563        let sin = || -> Mounted<()> {
1564            let mut layout = LayoutTree::new();
1565            mount(&mut layout, View::<()>::new(Style::default()))
1566        };
1567        let mut reg = AnimRegistry::new();
1568        let t0 = Instant::now();
1569        let mut m = con();
1570        reg.reconcile(&mut m, t0);
1571        let anim = m.nodes[0].anim.expect("anim");
1572        reg.store_live_exit_en(8, Scene::new(), (0.0, 0.0), &anim, t0);
1573        // Pasan varios frames sin refrescar la foto, y recién ahí se va.
1574        let mut m2 = sin();
1575        reg.reconcile(&mut m2, t0 + Duration::from_millis(500));
1576        assert!(reg.ghosts.contains_key(&8), "el fantasma sale de la foto atrasada");
1577    }
1578
1579    #[test]
1580    fn hold_mantiene_el_alpha_hasta_expirar() {
1581        let t0 = Instant::now();
1582        let g = Ghost {
1583            scene: Scene::new(),
1584            start: t0,
1585            duration: Duration::from_millis(200),
1586            easing: |t| t,
1587            centro: (0.0, 0.0),
1588            to_xf: None,
1589            under: false,
1590            hold: true,
1591        };
1592        // A mitad del reloj sigue OPACO (salida sin desvanecer).
1593        assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0);
1594        // Expirado: desaparece de golpe.
1595        assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0);
1596    }
1597
1598    #[test]
1599    fn switch_estilado_entra_opaco_y_coloca_underlay() {
1600        use crate::SwitchStyle;
1601        let style = SwitchStyle {
1602            enter_from_xf: Some(Affine::translate((300.0, 0.0))),
1603            enter_fade: false,
1604            exit_to_xf: None,
1605            exit_under: true,
1606            exit_hold: true,
1607        };
1608        let one = |variant: u64| -> Mounted<()> {
1609            let v = View::<()>::new(Style::default())
1610                .fill(rgba(10, 20, 30))
1611                .animated_switch_styled(5, variant, Duration::from_millis(200), style);
1612            let mut layout = LayoutTree::new();
1613            mount(&mut layout, v)
1614        };
1615        let mut reg = AnimRegistry::new();
1616        let t0 = Instant::now();
1617        let mut m = one(1);
1618        reg.reconcile(&mut m, t0);
1619        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1620        // Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha
1621        // intacto — push opaco), y el fantasma queda marcado under+hold.
1622        let mut m = one(2);
1623        let t1 = t0 + Duration::from_millis(10);
1624        assert!(reg.reconcile(&mut m, t1));
1625        assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)");
1626        assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose");
1627        let g = reg.ghosts.get(&5).expect("fantasma");
1628        assert!(g.under && g.hold, "placement y hold del frame nuevo");
1629        // prepare_underlays hornea la subescena en el nodo y la marca para
1630        // que replay_ghosts no la duplique encima.
1631        reg.prepare_underlays(&mut m, t1);
1632        assert!(m.nodes[0].underlay.is_some(), "underlay colocado");
1633        assert_eq!(reg.under_placed, vec![5]);
1634        // Un fantasma under colocado no se re-pinta arriba, pero la función
1635        // sigue reportando fantasmas vivos (ticker).
1636        assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0));
1637        // Si la key deja de estar montada (nodo desapareció), el fantasma
1638        // cae al camino histórico (encima): sin underlay que colocar.
1639        let mut vacio = empty();
1640        reg.prepare_underlays(&mut vacio, t1);
1641        assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay");
1642    }
1643
1644    #[test]
1645    fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() {
1646        let to = Affine::translate((0.0, 40.0));
1647        let v = View::<()>::new(Style::default())
1648            .fill(rgba(9, 9, 9))
1649            .animated_exit_to(7, Duration::from_millis(200), to);
1650        let mut layout = LayoutTree::new();
1651        let mut m = mount(&mut layout, v);
1652        let mut reg = AnimRegistry::new();
1653        let t0 = Instant::now();
1654        reg.reconcile(&mut m, t0);
1655        // El runtime captura con la to_xf del propio nodo (camino exit).
1656        reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim"));
1657        // Desaparece → el fantasma hereda la afín capturada.
1658        let mut m = empty();
1659        assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1660        let g = reg.ghosts.get(&7).expect("fantasma");
1661        assert_eq!(g.to_xf, Some(to));
1662        assert_eq!(g.centro, (20.0, 20.0));
1663    }
1664}