Skip to main content

llimphi_compositor/
anim.rs

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
47use std::collections::HashMap;
48use std::time::{Duration, Instant};
49
50use vello::kurbo::{Affine, Rect};
51use vello::peniko::{Color, Fill, Mix};
52use vello::Scene;
53
54use crate::Mounted;
55
56/// Declara que las props visuales de paint de este nodo se animan de forma
57/// implícita. `key` debe ser estable entre rebuilds del `View` (índice de
58/// item, hash de id, etc.) — es lo que enlaza "el mismo nodo" entre frames.
59#[derive(Clone, Copy, Debug)]
60pub struct Anim {
61    pub key: u64,
62    pub duration: Duration,
63    /// Easing aplicado a `t ∈ [0,1]`. Las canónicas viven en
64    /// `llimphi_theme::motion`; por defecto el builder usa un ease-out cúbico.
65    pub easing: fn(f32) -> f32,
66    /// `true` si la **primera aparición** de la key debe animar la opacidad de
67    /// 0 hacia su valor (fade-in de entrada, estilo `AnimatedSwitcher`). Las
68    /// animaciones de props (fill/radius/alpha) no entran por acá: sólo cambian
69    /// el arranque del primer frame. Sin él, la primera aparición se asienta
70    /// instantánea (default histórico de `View::animated`).
71    pub enter: bool,
72    /// `true` si la **salida** de la key debe animar (fade-out): cuando el nodo
73    /// desaparece del árbol, el runtime retiene la última subescena que pintó y
74    /// la reproduce con opacidad decreciente durante `duration`, en vez de que
75    /// el nodo se esfume de golpe. Tiene coste por frame (captura el subárbol
76    /// mientras vive) — usar en pocos nodos (toasts, modales, paneles), no en
77    /// cada fila de una lista grande.
78    pub exit: bool,
79    /// Transformación afín desde la que arrancar la **entrada** (`enter`). Por
80    /// ej. `Some(Affine::scale(0.6))` da el "pop" del FAB; `Some(Affine::
81    /// translate((0.0, 60.0)))` da slide-in vertical. Llega al target del nodo
82    /// (`node.transform` o identidad) en `duration`. Sin efecto si `enter` es
83    /// `false`. Combinable con el fade de entrada por defecto.
84    pub enter_from_xf: Option<Affine>,
85    /// Discriminador de **variante de contenido** para cross-fade real
86    /// (Flutter `AnimatedSwitcher`). Cuando es `Some(v)` y `v` **cambia**
87    /// entre frames bajo la misma `key`, el runtime promueve la subescena del
88    /// contenido anterior a fantasma (fade-out) y hace fade-in del nuevo, en
89    /// el mismo rect — una transición real entre dos identidades distintas, no
90    /// la combinación enter+exit de dos keys. Implica captura `live` por frame
91    /// (como `exit`). La primera aparición no cruza (sólo asienta la variante).
92    pub switch: Option<u64>,
93}
94
95/// Ease-out cúbico, el default razonable para transiciones implícitas
96/// (arranca rápido, frena suave). Copia local para no acoplar el compositor a
97/// `llimphi-theme`; el caller puede pasar cualquier `fn(f32)->f32`.
98pub fn ease_out_cubic(t: f32) -> f32 {
99    let u = 1.0 - t.clamp(0.0, 1.0);
100    1.0 - u * u * u
101}
102
103/// Declara que el **tamaño** de este nodo (CSS `width`/`height` /
104/// Flutter `AnimatedSize`/Compose `animateContentSize()`) se anima de
105/// forma implícita cuando cambia entre frames. Bloque 15 de
106/// PARIDAD-FLUTTER (extensión faltante del Bloque 4).
107///
108/// A diferencia de [`Anim`] (que interpola props de **paint** después
109/// del layout: fill/radius/alpha/transform), el tamaño tiene que estar
110/// fijo **antes** del layout — siblings y hijos dependen del rect del
111/// nodo. Por eso este registro vive aparte y el reconciler camina el
112/// `View` tree **antes** de `mount`, parchando `style.size` con el
113/// valor interpolado.
114///
115/// **Límite v1**: sólo anima cuando `style.size.width` y
116/// `style.size.height` son ambas `Dimension::Length(_)`. Si una es
117/// `Percent`/`Auto`, el nodo se monta tal cual sin animación (no hay
118/// "tamaño en píxeles" estable para interpolar). El caller que quiera
119/// animar un nodo flex debe declarar `length(...)` explícito.
120#[derive(Clone, Copy, Debug)]
121pub struct SizeAnim {
122    pub key: u64,
123    pub duration: Duration,
124    pub easing: fn(f32) -> f32,
125}
126
127#[derive(Clone, Copy)]
128struct SizeAnimEntry {
129    from: (f32, f32),
130    to: (f32, f32),
131    start: Instant,
132    duration: Duration,
133    easing: fn(f32) -> f32,
134}
135
136impl SizeAnimEntry {
137    /// Entrada "asentada" (from == to): no anima. Igual que
138    /// `AnimEntry::settled`, usamos `duration: ZERO` para que `done(now)`
139    /// devuelva `true` desde el frame 0 — así la primera aparición no
140    /// pide más frames. Cuando llegue un target nuevo el reconciler
141    /// sobreescribe `duration` con el de `SizeAnim`.
142    fn settled(target: (f32, f32), now: Instant, _dur: Duration, easing: fn(f32) -> f32) -> Self {
143        Self {
144            from: target,
145            to: target,
146            start: now,
147            duration: Duration::ZERO,
148            easing,
149        }
150    }
151
152    fn t(&self, now: Instant) -> f32 {
153        if self.duration.is_zero() {
154            return 1.0;
155        }
156        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
157        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
158        (self.easing)(raw)
159    }
160
161    fn value(&self, now: Instant) -> (f32, f32) {
162        let t = self.t(now);
163        let (fw, fh) = self.from;
164        let (tw, th) = self.to;
165        (fw + (tw - fw) * t, fh + (th - fh) * t)
166    }
167
168    fn done(&self, now: Instant) -> bool {
169        now.saturating_duration_since(self.start) >= self.duration
170    }
171}
172
173/// Registro de animaciones implícitas de **tamaño**, vivo entre
174/// frames. El runtime mantiene una instancia y llama
175/// [`reconcile_size_anim`] en cada redraw **antes** del mount/layout.
176#[derive(Default)]
177pub struct SizeAnimRegistry {
178    entries: HashMap<u64, SizeAnimEntry>,
179}
180
181impl SizeAnimRegistry {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    pub fn clear(&mut self) {
187        self.entries.clear();
188    }
189
190    /// Para tests: hay animación viva para esa key.
191    pub fn is_animating(&self, key: u64, now: Instant) -> bool {
192        self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false)
193    }
194}
195
196/// Lee `(width, height)` en píxeles si **ambos** son
197/// `Dimension::Length(_)`. Devuelve `None` si alguno es `Auto`,
198/// `Percent`, etc. — esos nodos no se animan en v1. (taffy 0.9 esconde
199/// las variantes detrás de un `CompactLength`; chequeamos por tag.)
200fn try_extract_length_size(
201    style: &llimphi_layout::Style,
202) -> Option<(f32, f32)> {
203    use llimphi_layout::taffy::CompactLength;
204    let w = style.size.width;
205    let h = style.size.height;
206    if w.tag() == CompactLength::LENGTH_TAG && h.tag() == CompactLength::LENGTH_TAG {
207        Some((w.value(), h.value()))
208    } else {
209        None
210    }
211}
212
213fn patch_length_size(style: &mut llimphi_layout::Style, size: (f32, f32)) {
214    use llimphi_layout::taffy::Dimension;
215    style.size.width = Dimension::length(size.0);
216    style.size.height = Dimension::length(size.1);
217}
218
219/// Recorre el `View` tree y, para cada nodo con [`SizeAnim`], reconcila
220/// su `style.size` con el registry: si cambió el objetivo, arranca un
221/// tween; si está animando, parcha `style.size` con el valor
222/// interpolado. Devuelve `true` si alguna animación de tamaño sigue
223/// viva → el runtime debe pedir otro redraw.
224///
225/// **Cuándo llamarlo**: el runtime lo invoca tras `A::view(model)` y
226/// **antes** de `mount`/`compute`, así el layout cascade ve el tamaño
227/// interpolado en vez del objetivo crudo (siblings y hijos reflowean
228/// suave).
229///
230/// Las keys no vistas este frame se descartan al final — un nodo que se
231/// va deja de animar (mismo comportamiento que [`AnimRegistry::reconcile`]).
232pub fn reconcile_size_anim<Msg>(
233    view: &mut crate::View<Msg>,
234    reg: &mut SizeAnimRegistry,
235    now: Instant,
236) -> bool {
237    let mut seen: Vec<u64> = Vec::new();
238    let animating = reconcile_size_anim_inner(view, reg, now, &mut seen);
239    if reg.entries.len() != seen.len() {
240        reg.entries.retain(|k, _| seen.contains(k));
241    }
242    animating
243}
244
245fn reconcile_size_anim_inner<Msg>(
246    view: &mut crate::View<Msg>,
247    reg: &mut SizeAnimRegistry,
248    now: Instant,
249    seen: &mut Vec<u64>,
250) -> bool {
251    let mut animating = false;
252    if let Some(sa) = view.animated_size {
253        if let Some(target) = try_extract_length_size(&view.style) {
254            seen.push(sa.key);
255            let entry = reg
256                .entries
257                .entry(sa.key)
258                .or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing));
259            if entry.to != target {
260                // Cambió el objetivo: congelá el valor actual como nuevo
261                // origen y rearrancá el reloj — mismo patrón que el
262                // `AnimRegistry` de props.
263                entry.from = entry.value(now);
264                entry.to = target;
265                entry.start = now;
266                entry.duration = sa.duration;
267                entry.easing = sa.easing;
268            }
269            let interp = if entry.done(now) { entry.to } else { entry.value(now) };
270            patch_length_size(&mut view.style, interp);
271            if !entry.done(now) {
272                animating = true;
273            }
274        }
275    }
276    for child in view.children.iter_mut() {
277        if reconcile_size_anim_inner(child, reg, now, seen) {
278            animating = true;
279        }
280    }
281    animating
282}
283
284/// Foto de las props animables de un nodo en un frame. `alpha == None` ≡ nodo
285/// opaco (1.0): es la convención de `View::alpha` y la usa el lerp para mezclar
286/// hacia/desde "sin alpha explícito" sin tratarlo como un salto. Lo mismo para
287/// `transform == None` ≡ identidad, así "sin transform" → "con transform" anima
288/// desde la identidad (estilo CSS `transform: none` → `transform: scale(1.5)`).
289#[derive(Clone, Copy, PartialEq)]
290struct AnimSnapshot {
291    fill: Option<Color>,
292    radius: f64,
293    alpha: Option<f32>,
294    transform: Option<Affine>,
295}
296
297#[inline]
298fn lerp_f64(a: f64, b: f64, t: f32) -> f64 {
299    a + (b - a) * t as f64
300}
301
302#[inline]
303fn lerp_color(a: Color, b: Color, t: f32) -> Color {
304    let p = a.components;
305    let q = b.components;
306    Color {
307        components: [
308            p[0] + (q[0] - p[0]) * t,
309            p[1] + (q[1] - p[1]) * t,
310            p[2] + (q[2] - p[2]) * t,
311            p[3] + (q[3] - p[3]) * t,
312        ],
313        ..a
314    }
315}
316
317/// Lerp componente-a-componente de las 6 coefs del afín (m00, m10, m01, m11,
318/// m02, m12). Es lo mismo que Flutter `MatrixTween`: no preserva una rotación
319/// pura entre matrices muy distintas, pero alcanza para las animaciones UI
320/// típicas (scale/translate/rotaciones chicas, slide-in, pop, hero).
321#[inline]
322fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
323    let p = a.as_coeffs();
324    let q = b.as_coeffs();
325    let ft = t as f64;
326    Affine::new([
327        p[0] + (q[0] - p[0]) * ft,
328        p[1] + (q[1] - p[1]) * ft,
329        p[2] + (q[2] - p[2]) * ft,
330        p[3] + (q[3] - p[3]) * ft,
331        p[4] + (q[4] - p[4]) * ft,
332        p[5] + (q[5] - p[5]) * ft,
333    ])
334}
335
336impl AnimSnapshot {
337    /// Interpola entre `self` (origen) y `to` (objetivo). El color sólo se
338    /// mezcla si ambos lados tienen fill sólido; si uno es `None` (gradiente o
339    /// sin fill) se salta al objetivo sin crossfade.
340    fn lerp(self, to: AnimSnapshot, t: f32) -> AnimSnapshot {
341        let fill = match (self.fill, to.fill) {
342            (Some(a), Some(b)) => Some(lerp_color(a, b, t)),
343            _ => to.fill,
344        };
345        // `None` ≡ opaco (1.0): un lado sin alpha se mezcla contra 1.0 en vez
346        // de saltar, así fade-in (0→opaco) y fade de un alpha explícito a/desde
347        // "sin alpha" interpolan suave. None↔None se mantiene None (sin capa).
348        let alpha = match (self.alpha, to.alpha) {
349            (None, None) => None,
350            (a, b) => {
351                let from = a.unwrap_or(1.0);
352                let dst = b.unwrap_or(1.0);
353                Some(from + (dst - from) * t)
354            }
355        };
356        // `None` ≡ identidad: idem. Un lado sin transform se mezcla contra
357        // `Affine::IDENTITY` en vez de saltar, así "sin xf" → `scale(1.5)`
358        // arranca desde scale(1) (Flutter/CSS hacen lo mismo). None↔None se
359        // mantiene None (sin push_layer afín en paint).
360        let transform = match (self.transform, to.transform) {
361            (None, None) => None,
362            (a, b) => {
363                let from = a.unwrap_or(Affine::IDENTITY);
364                let dst = b.unwrap_or(Affine::IDENTITY);
365                Some(lerp_affine(from, dst, t))
366            }
367        };
368        AnimSnapshot {
369            fill,
370            radius: lerp_f64(self.radius, to.radius, t),
371            alpha,
372            transform,
373        }
374    }
375}
376
377/// Estado retenido de una animación: tween entre `from` y `to`.
378struct AnimEntry {
379    from: AnimSnapshot,
380    to: AnimSnapshot,
381    start: Instant,
382    duration: Duration,
383    easing: fn(f32) -> f32,
384}
385
386impl AnimEntry {
387    /// Entrada ya asentada en `snap` (from == to): no anima.
388    fn settled(snap: AnimSnapshot, now: Instant) -> Self {
389        Self {
390            from: snap,
391            to: snap,
392            start: now,
393            duration: Duration::ZERO,
394            easing: |t| t,
395        }
396    }
397
398    /// Progreso `[0,1]` con easing aplicado.
399    fn t(&self, now: Instant) -> f32 {
400        if self.duration.is_zero() {
401            return 1.0;
402        }
403        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
404        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
405        (self.easing)(raw)
406    }
407
408    fn value(&self, now: Instant) -> AnimSnapshot {
409        self.from.lerp(self.to, self.t(now))
410    }
411
412    fn done(&self, now: Instant) -> bool {
413        now.saturating_duration_since(self.start) >= self.duration
414    }
415}
416
417/// Subescena retenida de un nodo marcado para animar su salida, capturada por
418/// el runtime el último frame que el nodo vivió. Mientras la key sigue presente
419/// se refresca cada frame; cuando desaparece, se promueve a [`Ghost`].
420struct LiveExit {
421    scene: Scene,
422    duration: Duration,
423    easing: fn(f32) -> f32,
424}
425
426/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
427/// + el reloj de fade-out.
428struct Ghost {
429    scene: Scene,
430    start: Instant,
431    duration: Duration,
432    easing: fn(f32) -> f32,
433}
434
435impl Ghost {
436    /// Opacidad actual del fantasma: `1 → 0` con easing aplicado.
437    fn alpha(&self, now: Instant) -> f32 {
438        if self.duration.is_zero() {
439            return 0.0;
440        }
441        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
442        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
443        1.0 - (self.easing)(raw)
444    }
445
446    fn done(&self, now: Instant) -> bool {
447        now.saturating_duration_since(self.start) >= self.duration
448    }
449}
450
451/// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene
452/// una instancia y llama [`Self::reconcile`] en cada redraw.
453#[derive(Default)]
454pub struct AnimRegistry {
455    entries: HashMap<u64, AnimEntry>,
456    /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el
457    /// runtime tras el paint de cada frame). Membresía = "presente el frame
458    /// anterior".
459    live: HashMap<u64, LiveExit>,
460    /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`)
461    /// que se están desvaneciendo.
462    ghosts: HashMap<u64, Ghost>,
463    /// Última variante vista por cada key con `switch` — para detectar el
464    /// cambio de contenido que dispara el cross-fade.
465    variants: HashMap<u64, u64>,
466}
467
468impl AnimRegistry {
469    pub fn new() -> Self {
470        Self::default()
471    }
472
473    /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con
474    /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y
475    /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las
476    /// keys que no aparecieron este frame se descartan (un nodo que se va deja
477    /// de animar). Devuelve `true` si alguna animación sigue en curso.
478    ///
479    /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del
480    /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes
481    /// controlados).
482    pub fn reconcile<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) -> bool {
483        let mut animating = false;
484        let mut seen: Vec<u64> = Vec::new();
485        // Keys presentes que requieren captura `live` y tracking de vanish
486        // (exit O switch). Membresía = "vive este frame".
487        let mut present_live: Vec<u64> = Vec::new();
488        // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de
489        // `switch` están presentes todos los frames y su ghost (contenido
490        // viejo) NO debe cancelarse por presencia.
491        let mut present_exit_only: Vec<u64> = Vec::new();
492        for node in &mut mounted.nodes {
493            let Some(anim) = node.anim else { continue };
494            seen.push(anim.key);
495            let target = AnimSnapshot {
496                fill: node.fill,
497                radius: node.radius,
498                alpha: node.alpha,
499                transform: node.transform,
500            };
501            // Detección de cross-fade (switch) ANTES de tomar prestado
502            // `entries`: si la variante cambió, el contenido viejo retenido en
503            // `live` (del frame anterior) se promueve a fantasma (fade-out) y
504            // el nodo nuevo arranca su fade-in desde alpha 0.
505            let mut switched = false;
506            if anim.exit {
507                present_live.push(anim.key);
508                present_exit_only.push(anim.key);
509            } else if let Some(variant) = anim.switch {
510                present_live.push(anim.key);
511                if let Some(prev) = self.variants.insert(anim.key, variant) {
512                    if prev != variant {
513                        switched = true;
514                        if let Some(le) = self.live.remove(&anim.key) {
515                            self.ghosts.insert(
516                                anim.key,
517                                Ghost {
518                                    scene: le.scene,
519                                    start: now,
520                                    duration: le.duration,
521                                    easing: le.easing,
522                                },
523                            );
524                        }
525                    }
526                }
527            }
528            let entry = self.entries.entry(anim.key).or_insert_with(|| {
529                // Primera aparición. Con `enter`, arranca un tween de opacidad
530                // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también
531                // arranca de esa transform → target.transform (scale-in/slide-in).
532                if anim.enter {
533                    let from = AnimSnapshot {
534                        alpha: Some(0.0),
535                        transform: anim.enter_from_xf.or(target.transform),
536                        ..target
537                    };
538                    AnimEntry {
539                        from,
540                        to: target,
541                        start: now,
542                        duration: anim.duration,
543                        easing: anim.easing,
544                    }
545                } else {
546                    AnimEntry::settled(target, now)
547                }
548            });
549            if switched {
550                // Cross-fade: el contenido nuevo entra desde transparente
551                // (el viejo ya quedó como fantasma desvaneciéndose encima).
552                entry.from = AnimSnapshot {
553                    alpha: Some(0.0),
554                    ..target
555                };
556                entry.to = target;
557                entry.start = now;
558                entry.duration = anim.duration;
559                entry.easing = anim.easing;
560            } else if entry.to != target {
561                // Cambió el objetivo: congelá el valor actual como nuevo origen
562                // y rearrancá el reloj hacia el objetivo nuevo.
563                entry.from = entry.value(now);
564                entry.to = target;
565                entry.start = now;
566                entry.duration = anim.duration;
567                entry.easing = anim.easing;
568            }
569            // Al terminar aterriza EXACTO en el objetivo (incluido `alpha:
570            // None` / `transform: None`, que evita capa de opacidad residual o
571            // un push_layer afín espurio frame a frame).
572            let v = if entry.done(now) { entry.to } else { entry.value(now) };
573            node.fill = v.fill;
574            node.radius = v.radius;
575            node.alpha = v.alpha;
576            node.transform = v.transform;
577            if !entry.done(now) {
578                animating = true;
579            }
580        }
581        if self.entries.len() != seen.len() {
582            self.entries.retain(|k, _| seen.contains(k));
583        }
584        // Las variantes de keys que ya no aparecen se descartan (si la key
585        // vuelve, su primera aparición re-asienta sin cross-fade).
586        if self.variants.len() != seen.len() {
587            self.variants.retain(|k, _| seen.contains(k));
588        }
589
590        // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior
591        // (vive en `live`) que ya no aparece → se promueve a fantasma con su
592        // última subescena retenida. Si una key `exit` con fantasma reaparece,
593        // se cancela el fade (no las de `switch`: su fantasma es contenido viejo
594        // que debe seguir desvaneciéndose aunque la key siga presente). Por
595        // último, descartamos los fantasmas cuyo reloj se agotó.
596        let vanished: Vec<u64> = self
597            .live
598            .keys()
599            .filter(|k| !present_live.contains(k))
600            .copied()
601            .collect();
602        for key in vanished {
603            if let Some(le) = self.live.remove(&key) {
604                self.ghosts.insert(
605                    key,
606                    Ghost {
607                        scene: le.scene,
608                        start: now,
609                        duration: le.duration,
610                        easing: le.easing,
611                    },
612                );
613            }
614        }
615        for key in &present_exit_only {
616            self.ghosts.remove(key);
617        }
618        self.ghosts.retain(|_, g| !g.done(now));
619        animating || !self.ghosts.is_empty()
620    }
621
622    /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por
623    /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en
624    /// una subescena con [`crate::paint_range`] y entregarla a
625    /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y
626    /// la geometría ya están firmes).
627    pub fn live_exit_nodes<Msg>(&self, mounted: &Mounted<Msg>) -> Vec<(usize, usize, u64)> {
628        mounted
629            .nodes
630            .iter()
631            .enumerate()
632            .filter_map(|(idx, n)| {
633                n.anim
634                    .filter(|a| a.exit || a.switch.is_some())
635                    .map(|a| (idx, n.subtree_end, a.key))
636            })
637            .collect()
638    }
639
640    /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El
641    /// runtime la captura con [`crate::paint_range`] tras el paint. `duration` y
642    /// `easing` se heredan al fantasma cuando la key desaparezca.
643    pub fn store_live_exit(
644        &mut self,
645        key: u64,
646        scene: Scene,
647        duration: Duration,
648        easing: fn(f32) -> f32,
649    ) {
650        self.live.insert(key, LiveExit { scene, duration, easing });
651    }
652
653    /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
654    /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint
655    /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo
656    /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo).
657    pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool {
658        if self.ghosts.is_empty() {
659            return false;
660        }
661        let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
662        for g in self.ghosts.values() {
663            let a = g.alpha(now);
664            if a <= 0.0 {
665                continue;
666            }
667            scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip);
668            scene.append(&g.scene, None);
669            scene.pop_layer();
670        }
671        true
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::{mount, View};
679    use llimphi_layout::{LayoutTree, Style};
680
681    fn rgba(r: u8, g: u8, b: u8) -> Color {
682        Color::from_rgba8(r, g, b, 255)
683    }
684
685    /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`.
686    fn one(fill: Color) -> Mounted<()> {
687        let v = View::<()>::new(Style::default())
688            .fill(fill)
689            .animated(1, Duration::from_millis(200));
690        let mut layout = LayoutTree::new();
691        mount(&mut layout, v)
692    }
693
694    #[test]
695    fn primera_aparicion_no_anima() {
696        let mut reg = AnimRegistry::new();
697        let mut m = one(rgba(255, 0, 0));
698        let now = Instant::now();
699        let animating = reg.reconcile(&mut m, now);
700        assert!(!animating, "la primera vez no debe animar");
701        assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0)));
702    }
703
704    #[test]
705    fn cambio_de_color_interpola_y_pide_frames() {
706        let mut reg = AnimRegistry::new();
707        let t0 = Instant::now();
708        // Frame 1: rojo, se asienta.
709        let mut m = one(rgba(255, 0, 0));
710        reg.reconcile(&mut m, t0);
711        // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que
712        // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo)
713        // pero ya pide frames.
714        let mut m = one(rgba(0, 0, 255));
715        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
716        assert!(animating, "al detectar el cambio debe pedir frames");
717        // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado:
718        // ni rojo puro ni azul puro.
719        let mut m = one(rgba(0, 0, 255));
720        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200));
721        assert!(animating, "a mitad del tween debe seguir animando");
722        let c = m.nodes[0].fill.expect("fill").components;
723        assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]);
724        assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]);
725    }
726
727    #[test]
728    fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() {
729        let mut reg = AnimRegistry::new();
730        let t0 = Instant::now();
731        let mut m = one(rgba(255, 0, 0));
732        reg.reconcile(&mut m, t0);
733        let mut m = one(rgba(0, 0, 255));
734        reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca
735        // Pasada la duración, llega exacto al objetivo y no pide más frames.
736        let mut m = one(rgba(0, 0, 255));
737        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
738        assert!(!animating);
739        assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255)));
740    }
741
742    /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`.
743    fn one_alpha(alpha: f32) -> Mounted<()> {
744        let v = View::<()>::new(Style::default())
745            .alpha(alpha)
746            .animated(1, Duration::from_millis(200));
747        let mut layout = LayoutTree::new();
748        mount(&mut layout, v)
749    }
750
751    /// Monta un nodo opaco (sin alpha) con animación de ENTRADA.
752    fn one_enter() -> Mounted<()> {
753        let v = View::<()>::new(Style::default())
754            .fill(rgba(10, 20, 30))
755            .animated_enter(1, Duration::from_millis(200));
756        let mut layout = LayoutTree::new();
757        mount(&mut layout, v)
758    }
759
760    #[test]
761    fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() {
762        let mut reg = AnimRegistry::new();
763        let t0 = Instant::now();
764        // Primera aparición de un nodo `enter`: a diferencia de `animated`,
765        // SÍ anima — arranca casi transparente y pide frames.
766        let mut m = one_enter();
767        let animating = reg.reconcile(&mut m, t0);
768        assert!(animating, "la entrada debe animar desde el primer frame");
769        assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente");
770        // A mitad del tween, alpha intermedio.
771        let mut m = one_enter();
772        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
773        let a = m.nodes[0].alpha.expect("alpha");
774        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
775        // Pasada la duración: opaco exacto (None, sin capa residual) y quieto.
776        let mut m = one_enter();
777        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
778        assert!(!animating);
779        assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa");
780    }
781
782    /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`.
783    fn one_exit() -> Mounted<()> {
784        let v = View::<()>::new(Style::default())
785            .fill(rgba(10, 20, 30))
786            .animated_exit(7, Duration::from_millis(200));
787        let mut layout = LayoutTree::new();
788        mount(&mut layout, v)
789    }
790
791    /// Árbol vacío de nodos animados (la key `exit` ya no aparece).
792    fn empty() -> Mounted<()> {
793        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
794        let mut layout = LayoutTree::new();
795        mount(&mut layout, v)
796    }
797
798    #[test]
799    fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() {
800        let mut reg = AnimRegistry::new();
801        let t0 = Instant::now();
802        // Frame 1: el nodo exit está presente. No anima por sí solo, y el
803        // runtime captura su subescena (acá una vacía de prueba).
804        let mut m = one_exit();
805        let animating = reg.reconcile(&mut m, t0);
806        assert!(!animating, "presente y quieto no anima");
807        reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic);
808        // Frame 2: la key desaparece → se promueve a fantasma y pide frames.
809        let mut m = empty();
810        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
811        assert!(animating, "un fantasma vivo mantiene el ticker");
812        assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
813        // Frame 3: pasada la duración el fantasma se descarta y el loop para.
814        let mut m = empty();
815        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300));
816        assert!(!animating, "fantasma agotado → sin más frames");
817        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0));
818    }
819
820    /// Monta un nodo `switch` (key=5) con la variante dada.
821    fn one_switch(variant: u64) -> Mounted<()> {
822        let v = View::<()>::new(Style::default())
823            .fill(rgba(10, 20, 30))
824            .animated_switch(5, variant, Duration::from_millis(200));
825        let mut layout = LayoutTree::new();
826        mount(&mut layout, v)
827    }
828
829    #[test]
830    fn switch_de_variante_cruza_contenido() {
831        let mut reg = AnimRegistry::new();
832        let t0 = Instant::now();
833        // Frame 1: variante 1, primera aparición → asienta, no cruza.
834        let mut m = one_switch(1);
835        assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
836        // El runtime captura su subescena (de prueba, vacía).
837        reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
838        // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi
839        // transparente y hay un fantasma del contenido viejo desvaneciéndose.
840        let mut m = one_switch(2);
841        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
842        assert!(animating, "el cross-fade pide frames");
843        let a = m.nodes[0].alpha.expect("alpha de fade-in");
844        assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}");
845        assert!(
846            reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0),
847            "hay un fantasma del contenido viejo"
848        );
849        // Re-captura del frame 2 (lo haría el runtime tras el paint).
850        reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
851        // Frame 3: misma variante, pasada la duración → asentado y sin fantasma.
852        let mut m = one_switch(2);
853        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
854        assert!(!animating, "asentado tras la duración");
855        assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual");
856        assert!(
857            !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0),
858            "fantasma agotado"
859        );
860    }
861
862    #[test]
863    fn switch_misma_variante_no_cruza() {
864        let mut reg = AnimRegistry::new();
865        let t0 = Instant::now();
866        let mut m = one_switch(1);
867        reg.reconcile(&mut m, t0);
868        reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
869        // Misma variante en el frame siguiente: ni fade-in ni fantasma.
870        let mut m = one_switch(1);
871        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
872        assert!(!animating, "sin cambio de variante no cruza");
873        assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco");
874        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
875    }
876
877    #[test]
878    fn reaparecer_cancela_el_fantasma() {
879        let mut reg = AnimRegistry::new();
880        let t0 = Instant::now();
881        let mut m = one_exit();
882        reg.reconcile(&mut m, t0);
883        reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic);
884        // Se va → fantasma.
885        let mut m = empty();
886        assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
887        // Reaparece a mitad del fade → el fantasma se cancela (no hay doble).
888        let mut m = one_exit();
889        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
890        assert!(!animating, "al reaparecer no queda fantasma");
891        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0));
892    }
893
894    /// Monta un nodo con un transform afín explícito + anim(key=1).
895    fn one_xf(xf: Affine) -> Mounted<()> {
896        let v = View::<()>::new(Style::default())
897            .transform(xf)
898            .animated(1, Duration::from_millis(200));
899        let mut layout = LayoutTree::new();
900        mount(&mut layout, v)
901    }
902
903    /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0).
904    fn one_pop_in() -> Mounted<()> {
905        let v = View::<()>::new(Style::default())
906            .fill(rgba(1, 2, 3))
907            .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5));
908        let mut layout = LayoutTree::new();
909        mount(&mut layout, v)
910    }
911
912    #[test]
913    fn cambio_de_transform_interpola_y_pide_frames() {
914        let mut reg = AnimRegistry::new();
915        let t0 = Instant::now();
916        // Frame 1: identidad → se asienta sin animar.
917        let mut m = one_xf(Affine::IDENTITY);
918        assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
919        // Frame 2: la view ahora pide scale(2.0) → arranca tween.
920        let mut m = one_xf(Affine::scale(2.0));
921        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50));
922        assert!(animating, "al cambiar la xf debe pedir frames");
923        // Frame 3: a mitad, el m00 está entre 1.0 y 2.0.
924        let mut m = one_xf(Affine::scale(2.0));
925        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
926        let c = m.nodes[0].transform.expect("transform").as_coeffs();
927        assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]);
928        assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]);
929    }
930
931    #[test]
932    fn transform_al_terminar_llega_exacto() {
933        let mut reg = AnimRegistry::new();
934        let t0 = Instant::now();
935        let mut m = one_xf(Affine::IDENTITY);
936        reg.reconcile(&mut m, t0);
937        let mut m = one_xf(Affine::translate((10.0, 20.0)));
938        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
939        // Pasada la duración: aterriza exacto en la xf objetivo.
940        let mut m = one_xf(Affine::translate((10.0, 20.0)));
941        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
942        assert!(!animating);
943        let c = m.nodes[0].transform.expect("xf").as_coeffs();
944        assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]);
945        assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]);
946    }
947
948    #[test]
949    fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() {
950        let mut reg = AnimRegistry::new();
951        let t0 = Instant::now();
952        // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La
953        // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller)
954        // y debe pedir frames.
955        let mut m = one_pop_in();
956        let animating = reg.reconcile(&mut m, t0);
957        assert!(animating, "pop-in anima desde el primer frame");
958        let c = m.nodes[0].transform.expect("xf inicial").as_coeffs();
959        assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]);
960        // Frame intermedio: el m00 ya creció hacia 1.0.
961        let mut m = one_pop_in();
962        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
963        let c = m.nodes[0].transform.expect("xf medio").as_coeffs();
964        assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]);
965        // Frame final: aterriza en None (sin xf residual), igual que alpha.
966        let mut m = one_pop_in();
967        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
968        assert!(!animating, "asentado");
969        assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse");
970    }
971
972    #[test]
973    fn cambio_de_alpha_interpola() {
974        let mut reg = AnimRegistry::new();
975        let t0 = Instant::now();
976        // Frame 1: alpha 1.0, se asienta (no es `enter`).
977        let mut m = one_alpha(1.0);
978        let animating = reg.reconcile(&mut m, t0);
979        assert!(!animating, "primera aparición sin enter no anima");
980        // Frame 2: la view baja a 0.0 → arranca tween.
981        let mut m = one_alpha(0.0);
982        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
983        // Frame 3: a mitad, alpha intermedio.
984        let mut m = one_alpha(0.0);
985        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
986        let a = m.nodes[0].alpha.expect("alpha");
987        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
988    }
989
990    #[test]
991    fn keys_que_se_van_se_descartan() {
992        let mut reg = AnimRegistry::new();
993        let now = Instant::now();
994        let mut m = one(rgba(1, 2, 3));
995        reg.reconcile(&mut m, now);
996        assert_eq!(reg.entries.len(), 1);
997        // Frame sin ningún nodo animado: la entrada se descarta.
998        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
999        let mut layout = LayoutTree::new();
1000        let mut m2 = mount(&mut layout, v);
1001        reg.reconcile(&mut m2, now);
1002        assert_eq!(reg.entries.len(), 0);
1003    }
1004
1005    // ─── Bloque 15: tests de SizeAnim / animateContentSize ───
1006
1007    fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> {
1008        use llimphi_layout::taffy::prelude::{length, Size};
1009        let mut style = Style::default();
1010        style.size = Size { width: length(w), height: length(h) };
1011        View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms))
1012    }
1013
1014    #[test]
1015    fn size_anim_primera_aparicion_no_anima() {
1016        let mut reg = SizeAnimRegistry::new();
1017        let mut v = sized_view(1, 100.0, 80.0, 200);
1018        let now = Instant::now();
1019        let animating = reconcile_size_anim(&mut v, &mut reg, now);
1020        assert!(!animating, "primera vez: sin animación");
1021        // El style.size queda intacto (length(100, 80)).
1022        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1023        assert_eq!((w, h), (100.0, 80.0));
1024    }
1025
1026    #[test]
1027    fn size_anim_cambia_target_interpola() {
1028        let mut reg = SizeAnimRegistry::new();
1029        let t0 = Instant::now();
1030        // Frame 1: target = 100×80, se asienta.
1031        let mut v = sized_view(1, 100.0, 80.0, 200);
1032        reconcile_size_anim(&mut v, &mut reg, t0);
1033        // Frame 2: target nuevo = 200×160. En el frame que se detecta el
1034        // cambio arranca el reloj — todavía pinta cerca del origen.
1035        let mut v = sized_view(1, 200.0, 160.0, 200);
1036        let animating = reconcile_size_anim(&mut v, &mut reg, t0);
1037        assert!(animating, "cambio de target: pide frames");
1038        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1039        assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}");
1040        assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}");
1041        // Frame 3: 100 ms (mitad del tween).
1042        let mut v = sized_view(1, 200.0, 160.0, 200);
1043        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100));
1044        assert!(animating, "a mitad del tween sigue animando");
1045        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1046        assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}");
1047        assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}");
1048    }
1049
1050    #[test]
1051    fn size_anim_termina_y_se_detiene() {
1052        let mut reg = SizeAnimRegistry::new();
1053        let t0 = Instant::now();
1054        let mut v = sized_view(1, 100.0, 80.0, 200);
1055        reconcile_size_anim(&mut v, &mut reg, t0);
1056        let mut v = sized_view(1, 200.0, 160.0, 200);
1057        reconcile_size_anim(&mut v, &mut reg, t0); // arranca
1058        // Pasada la duración: aterriza exacto en el objetivo y no pide más.
1059        let mut v = sized_view(1, 200.0, 160.0, 200);
1060        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400));
1061        assert!(!animating);
1062        assert_eq!(
1063            (v.style.size.width.value(), v.style.size.height.value()),
1064            (200.0, 160.0),
1065        );
1066    }
1067
1068    #[test]
1069    fn size_anim_no_animable_si_tamano_no_es_length() {
1070        // Si el caller declara percent o auto, el reconciler lo deja pasar
1071        // sin tracking — no hay valor en píxeles estable para interpolar.
1072        use llimphi_layout::taffy::prelude::{percent, Dimension, Size};
1073        let mut reg = SizeAnimRegistry::new();
1074        let mut style = Style::default();
1075        style.size = Size { width: percent(0.5), height: Dimension::auto() };
1076        let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200));
1077        let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now());
1078        assert!(!animating);
1079        // El size no se tocó: width sigue siendo percent (no LENGTH_TAG).
1080        use llimphi_layout::taffy::CompactLength;
1081        assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG);
1082    }
1083
1084    #[test]
1085    fn size_anim_descarta_keys_no_vistas() {
1086        let mut reg = SizeAnimRegistry::new();
1087        let now = Instant::now();
1088        let mut v = sized_view(42, 50.0, 50.0, 200);
1089        reconcile_size_anim(&mut v, &mut reg, now);
1090        assert_eq!(reg.entries.len(), 1);
1091        // Frame sin animated_size: la entrada se descarta.
1092        let mut v: View<()> = View::<()>::new(Style::default());
1093        reconcile_size_anim(&mut v, &mut reg, now);
1094        assert_eq!(reg.entries.len(), 0);
1095    }
1096}