tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-compositor/src/anim.rs Rust · 1426 líneas · 60.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · 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
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 aquí: 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    /// Transformación afín hacia la que **sale el contenido saliente** mientras
94    /// se desvanece (fantasma): se interpola de identidad a esta afín, aplicada
95    /// alrededor del centro del rect capturado (misma convención que
96    /// `View::transform`). `Some(Affine::translate((-w, 0)))` = slide-out a la
97    /// izquierda; `Some(Affine::scale(1.05))` = zoom-out. Con `exit`, describe
98    /// cómo sale **este nodo** al desaparecer (capturada mientras vive). Con
99    /// `switch`, describe cómo sale el **contenido viejo** al cambiar la
100    /// variante — se lee del frame NUEVO (quien conmuta conoce la dirección:
101    /// push vs pop). `None` = fade en el lugar (comportamiento histórico).
102    pub exit_to_xf: Option<Affine>,
103    /// `false` = el contenido nuevo de un `switch` NO arranca transparente
104    /// (entra opaco — necesario para un push estilo UIKit donde la página
105    /// entrante tapa a la saliente). `true` = fade-in histórico.
106    pub enter_fade: bool,
107    /// `true` = el fantasma del contenido saliente se pinta **DEBAJO** del
108    /// contenido nuevo, en la posición z del nodo (vía
109    /// [`AnimRegistry::prepare_underlays`] + `MountedNode::underlay`), en vez
110    /// de encima de toda la escena. Es el orden natural de una transición de
111    /// páginas: lo nuevo cubre a lo viejo. Como `exit_to_xf`, en un `switch`
112    /// lo declara el frame NUEVO.
113    pub exit_under: bool,
114    /// `true` = el fantasma NO se desvanece: mantiene alpha 1 hasta que su
115    /// reloj expira (y ahí desaparece de golpe — para entonces el contenido
116    /// nuevo ya lo cubre). Para salidas opacas (Cover/push UIKit).
117    pub exit_hold: bool,
118}
119
120/// Estilo completo de una transición de `switch` direccional — agrupa los
121/// cinco knobs que [`crate::View::animated_switch_styled`] pasa al [`Anim`].
122/// `Default` = cross-fade puro (idéntico a `animated_switch`).
123#[derive(Clone, Copy, Debug)]
124pub struct SwitchStyle {
125    /// Afín desde la que entra el contenido nuevo (`None` = en el lugar).
126    pub enter_from_xf: Option<Affine>,
127    /// `false` = el contenido nuevo entra opaco (sin fade-in).
128    pub enter_fade: bool,
129    /// Afín hacia la que sale el contenido viejo (`None` = en el lugar).
130    pub exit_to_xf: Option<Affine>,
131    /// `true` = el fantasma va DEBAJO del contenido nuevo (posición z real).
132    pub exit_under: bool,
133    /// `true` = el fantasma no se desvanece (salida opaca).
134    pub exit_hold: bool,
135}
136
137impl Default for SwitchStyle {
138    fn default() -> Self {
139        Self {
140            enter_from_xf: None,
141            enter_fade: true,
142            exit_to_xf: None,
143            exit_under: false,
144            exit_hold: false,
145        }
146    }
147}
148
149/// Ease-out cúbico, el default razonable para transiciones implícitas
150/// (arranca rápido, frena suave). Copia local para no acoplar el compositor a
151/// `llimphi-theme`; el caller puede pasar cualquier `fn(f32)->f32`.
152pub fn ease_out_cubic(t: f32) -> f32 {
153    let u = 1.0 - t.clamp(0.0, 1.0);
154    1.0 - u * u * u
155}
156
157/// Declara que el **tamaño** de este nodo (CSS `width`/`height` /
158/// Flutter `AnimatedSize`/Compose `animateContentSize()`) se anima de
159/// forma implícita cuando cambia entre frames. Bloque 15 de
160/// PARIDAD-FLUTTER (extensión faltante del Bloque 4).
161///
162/// A diferencia de [`Anim`] (que interpola props de **paint** después
163/// del layout: fill/radius/alpha/transform), el tamaño tiene que estar
164/// fijo **antes** del layout — siblings y hijos dependen del rect del
165/// nodo. Por eso este registro vive aparte y el reconciler camina el
166/// `View` tree **antes** de `mount`, parchando `style.size` con el
167/// valor interpolado.
168///
169/// **Límite v1**: sólo anima cuando `style.size.width` y
170/// `style.size.height` son ambas `Dimension::Length(_)`. Si una es
171/// `Percent`/`Auto`, el nodo se monta tal cual sin animación (no hay
172/// "tamaño en píxeles" estable para interpolar). El caller que quiera
173/// animar un nodo flex debe declarar `length(...)` explícito.
174#[derive(Clone, Copy, Debug)]
175pub struct SizeAnim {
176    pub key: u64,
177    pub duration: Duration,
178    pub easing: fn(f32) -> f32,
179}
180
181#[derive(Clone, Copy)]
182struct SizeAnimEntry {
183    from: (f32, f32),
184    to: (f32, f32),
185    start: Instant,
186    duration: Duration,
187    easing: fn(f32) -> f32,
188}
189
190impl SizeAnimEntry {
191    /// Entrada "asentada" (from == to): no anima. Igual que
192    /// `AnimEntry::settled`, usamos `duration: ZERO` para que `done(now)`
193    /// devuelva `true` desde el frame 0 — así la primera aparición no
194    /// pide más frames. Cuando llegue un target nuevo el reconciler
195    /// sobreescribe `duration` con el de `SizeAnim`.
196    fn settled(target: (f32, f32), now: Instant, _dur: Duration, easing: fn(f32) -> f32) -> Self {
197        Self {
198            from: target,
199            to: target,
200            start: now,
201            duration: Duration::ZERO,
202            easing,
203        }
204    }
205
206    fn t(&self, now: Instant) -> f32 {
207        if self.duration.is_zero() {
208            return 1.0;
209        }
210        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
211        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
212        (self.easing)(raw)
213    }
214
215    fn value(&self, now: Instant) -> (f32, f32) {
216        let t = self.t(now);
217        let (fw, fh) = self.from;
218        let (tw, th) = self.to;
219        (fw + (tw - fw) * t, fh + (th - fh) * t)
220    }
221
222    fn done(&self, now: Instant) -> bool {
223        now.saturating_duration_since(self.start) >= self.duration
224    }
225}
226
227/// Registro de animaciones implícitas de **tamaño**, vivo entre
228/// frames. El runtime mantiene una instancia y llama
229/// [`reconcile_size_anim`] en cada redraw **antes** del mount/layout.
230#[derive(Default)]
231pub struct SizeAnimRegistry {
232    entries: HashMap<u64, SizeAnimEntry>,
233}
234
235impl SizeAnimRegistry {
236    pub fn new() -> Self {
237        Self::default()
238    }
239
240    pub fn clear(&mut self) {
241        self.entries.clear();
242    }
243
244    /// Para tests: hay animación viva para esa key.
245    pub fn is_animating(&self, key: u64, now: Instant) -> bool {
246        self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false)
247    }
248}
249
250/// Lee `(width, height)` en píxeles si **ambos** son
251/// `Dimension::Length(_)`. Devuelve `None` si alguno es `Auto`,
252/// `Percent`, etc. — esos nodos no se animan en v1. (taffy 0.9 esconde
253/// las variantes detrás de un `CompactLength`; chequeamos por tag.)
254fn try_extract_length_size(
255    style: &llimphi_layout::Style,
256) -> Option<(f32, f32)> {
257    use llimphi_layout::taffy::CompactLength;
258    let w = style.size.width;
259    let h = style.size.height;
260    if w.tag() == CompactLength::LENGTH_TAG && h.tag() == CompactLength::LENGTH_TAG {
261        Some((w.value(), h.value()))
262    } else {
263        None
264    }
265}
266
267fn patch_length_size(style: &mut llimphi_layout::Style, size: (f32, f32)) {
268    use llimphi_layout::taffy::Dimension;
269    style.size.width = Dimension::length(size.0);
270    style.size.height = Dimension::length(size.1);
271}
272
273/// Recorre el `View` tree y, para cada nodo con [`SizeAnim`], reconcila
274/// su `style.size` con el registry: si cambió el objetivo, arranca un
275/// tween; si está animando, parcha `style.size` con el valor
276/// interpolado. Devuelve `true` si alguna animación de tamaño sigue
277/// viva → el runtime debe pedir otro redraw.
278///
279/// **Cuándo llamarlo**: el runtime lo invoca tras `A::view(model)` y
280/// **antes** de `mount`/`compute`, así el layout cascade ve el tamaño
281/// interpolado en vez del objetivo crudo (siblings y hijos reflowean
282/// suave).
283///
284/// Las keys no vistas este frame se descartan al final — un nodo que se
285/// va deja de animar (mismo comportamiento que [`AnimRegistry::reconcile`]).
286pub fn reconcile_size_anim<Msg>(
287    view: &mut crate::View<Msg>,
288    reg: &mut SizeAnimRegistry,
289    now: Instant,
290) -> bool {
291    let mut seen: Vec<u64> = Vec::new();
292    let animating = reconcile_size_anim_inner(view, reg, now, &mut seen);
293    if reg.entries.len() != seen.len() {
294        reg.entries.retain(|k, _| seen.contains(k));
295    }
296    animating
297}
298
299fn reconcile_size_anim_inner<Msg>(
300    view: &mut crate::View<Msg>,
301    reg: &mut SizeAnimRegistry,
302    now: Instant,
303    seen: &mut Vec<u64>,
304) -> bool {
305    let mut animating = false;
306    if let Some(sa) = view.animated_size {
307        if let Some(target) = try_extract_length_size(&view.style) {
308            seen.push(sa.key);
309            let entry = reg
310                .entries
311                .entry(sa.key)
312                .or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing));
313            if entry.to != target {
314                // Cambió el objetivo: congela el valor actual como nuevo
315                // origen y rearranca el reloj — mismo patrón que el
316                // `AnimRegistry` de props.
317                entry.from = entry.value(now);
318                entry.to = target;
319                entry.start = now;
320                entry.duration = sa.duration;
321                entry.easing = sa.easing;
322            }
323            let interp = if entry.done(now) { entry.to } else { entry.value(now) };
324            patch_length_size(&mut view.style, interp);
325            if !entry.done(now) {
326                animating = true;
327            }
328        }
329    }
330    for child in view.children.iter_mut() {
331        if reconcile_size_anim_inner(child, reg, now, seen) {
332            animating = true;
333        }
334    }
335    animating
336}
337
338/// Foto de las props animables de un nodo en un frame. `alpha == None` ≡ nodo
339/// opaco (1.0): es la convención de `View::alpha` y la usa el lerp para mezclar
340/// hacia/desde "sin alpha explícito" sin tratarlo como un salto. Lo mismo para
341/// `transform == None` ≡ identidad, así "sin transform" → "con transform" anima
342/// desde la identidad (estilo CSS `transform: none` → `transform: scale(1.5)`).
343#[derive(Clone, Copy, PartialEq)]
344struct AnimSnapshot {
345    fill: Option<Color>,
346    radius: f64,
347    alpha: Option<f32>,
348    transform: Option<Affine>,
349}
350
351#[inline]
352fn lerp_f64(a: f64, b: f64, t: f32) -> f64 {
353    a + (b - a) * t as f64
354}
355
356#[inline]
357fn lerp_color(a: Color, b: Color, t: f32) -> Color {
358    let p = a.components;
359    let q = b.components;
360    Color {
361        components: [
362            p[0] + (q[0] - p[0]) * t,
363            p[1] + (q[1] - p[1]) * t,
364            p[2] + (q[2] - p[2]) * t,
365            p[3] + (q[3] - p[3]) * t,
366        ],
367        ..a
368    }
369}
370
371/// Lerp componente-a-componente de las 6 coefs del afín (m00, m10, m01, m11,
372/// m02, m12). Es lo mismo que Flutter `MatrixTween`: no preserva una rotación
373/// pura entre matrices muy distintas, pero alcanza para las animaciones UI
374/// típicas (scale/translate/rotaciones chicas, slide-in, pop, hero).
375#[inline]
376fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
377    let p = a.as_coeffs();
378    let q = b.as_coeffs();
379    let ft = t as f64;
380    Affine::new([
381        p[0] + (q[0] - p[0]) * ft,
382        p[1] + (q[1] - p[1]) * ft,
383        p[2] + (q[2] - p[2]) * ft,
384        p[3] + (q[3] - p[3]) * ft,
385        p[4] + (q[4] - p[4]) * ft,
386        p[5] + (q[5] - p[5]) * ft,
387    ])
388}
389
390impl AnimSnapshot {
391    /// Interpola entre `self` (origen) y `to` (objetivo). El color sólo se
392    /// mezcla si ambos lados tienen fill sólido; si uno es `None` (gradiente o
393    /// sin fill) se salta al objetivo sin crossfade.
394    fn lerp(self, to: AnimSnapshot, t: f32) -> AnimSnapshot {
395        let fill = match (self.fill, to.fill) {
396            (Some(a), Some(b)) => Some(lerp_color(a, b, t)),
397            _ => to.fill,
398        };
399        // `None` ≡ opaco (1.0): un lado sin alpha se mezcla contra 1.0 en vez
400        // de saltar, así fade-in (0→opaco) y fade de un alpha explícito a/desde
401        // "sin alpha" interpolan suave. None↔None se mantiene None (sin capa).
402        let alpha = match (self.alpha, to.alpha) {
403            (None, None) => None,
404            (a, b) => {
405                let from = a.unwrap_or(1.0);
406                let dst = b.unwrap_or(1.0);
407                Some(from + (dst - from) * t)
408            }
409        };
410        // `None` ≡ identidad: idem. Un lado sin transform se mezcla contra
411        // `Affine::IDENTITY` en vez de saltar, así "sin xf" → `scale(1.5)`
412        // arranca desde scale(1) (Flutter/CSS hacen lo mismo). None↔None se
413        // mantiene None (sin push_layer afín en paint).
414        let transform = match (self.transform, to.transform) {
415            (None, None) => None,
416            (a, b) => {
417                let from = a.unwrap_or(Affine::IDENTITY);
418                let dst = b.unwrap_or(Affine::IDENTITY);
419                Some(lerp_affine(from, dst, t))
420            }
421        };
422        AnimSnapshot {
423            fill,
424            radius: lerp_f64(self.radius, to.radius, t),
425            alpha,
426            transform,
427        }
428    }
429}
430
431/// Estado retenido de una animación: tween entre `from` y `to`.
432struct AnimEntry {
433    from: AnimSnapshot,
434    to: AnimSnapshot,
435    start: Instant,
436    duration: Duration,
437    easing: fn(f32) -> f32,
438}
439
440impl AnimEntry {
441    /// Entrada ya asentada en `snap` (from == to): no anima.
442    fn settled(snap: AnimSnapshot, now: Instant) -> Self {
443        Self {
444            from: snap,
445            to: snap,
446            start: now,
447            duration: Duration::ZERO,
448            easing: |t| t,
449        }
450    }
451
452    /// Progreso `[0,1]` con easing aplicado.
453    fn t(&self, now: Instant) -> f32 {
454        if self.duration.is_zero() {
455            return 1.0;
456        }
457        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
458        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
459        (self.easing)(raw)
460    }
461
462    fn value(&self, now: Instant) -> AnimSnapshot {
463        self.from.lerp(self.to, self.t(now))
464    }
465
466    fn done(&self, now: Instant) -> bool {
467        now.saturating_duration_since(self.start) >= self.duration
468    }
469}
470
471/// Subescena retenida de un nodo marcado para animar su salida, capturada por
472/// el runtime el último frame que el nodo vivió. Mientras la key sigue presente
473/// se refresca cada frame; cuando desaparece, se promueve a [`Ghost`].
474struct LiveExit {
475    scene: Scene,
476    duration: Duration,
477    easing: fn(f32) -> f32,
478    /// Centro del rect del nodo al capturarse — origen de la afín de salida.
479    centro: (f32, f32),
480    /// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`).
481    to_xf: Option<Affine>,
482    /// El fantasma va debajo del contenido nuevo (`Anim::exit_under`).
483    under: bool,
484    /// El fantasma no se desvanece (`Anim::exit_hold`).
485    hold: bool,
486}
487
488/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
489/// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza).
490struct Ghost {
491    scene: Scene,
492    start: Instant,
493    duration: Duration,
494    easing: fn(f32) -> f32,
495    centro: (f32, f32),
496    to_xf: Option<Affine>,
497    under: bool,
498    hold: bool,
499}
500
501impl Ghost {
502    /// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con
503    /// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca).
504    fn alpha(&self, now: Instant) -> f32 {
505        if self.hold {
506            return if self.done(now) { 0.0 } else { 1.0 };
507        }
508        1.0 - self.t(now)
509    }
510
511    /// Progreso eased `0 → 1` del fantasma.
512    fn t(&self, now: Instant) -> f32 {
513        if self.duration.is_zero() {
514            return 1.0;
515        }
516        let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
517        let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
518        (self.easing)(raw)
519    }
520
521    /// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del
522    /// rect capturado (misma convención que `View::transform`, así un scale
523    /// "respira" desde su centro y no desde el origen de la ventana). `None`
524    /// si el fantasma sólo se desvanece en el lugar.
525    fn xf(&self, now: Instant) -> Option<Affine> {
526        let to = self.to_xf?;
527        let t = self.t(now);
528        let local = lerp_affine(Affine::IDENTITY, to, t);
529        let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64);
530        Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)))
531    }
532
533    fn done(&self, now: Instant) -> bool {
534        now.saturating_duration_since(self.start) >= self.duration
535    }
536}
537
538/// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene
539/// una instancia y llama [`Self::reconcile`] en cada redraw.
540#[derive(Default)]
541pub struct AnimRegistry {
542    entries: HashMap<u64, AnimEntry>,
543    /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el
544    /// runtime tras el paint de cada frame). Membresía = "presente el frame
545    /// anterior".
546    live: HashMap<u64, LiveExit>,
547    /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`)
548    /// que se están desvaneciendo.
549    ghosts: HashMap<u64, Ghost>,
550    /// Última variante vista por cada key con `switch` — para detectar el
551    /// cambio de contenido que dispara el cross-fade.
552    variants: HashMap<u64, u64>,
553    /// Keys colocadas como underlay ESTE frame (por
554    /// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no
555    /// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`.
556    under_placed: Vec<u64>,
557}
558
559impl AnimRegistry {
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con
565    /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y
566    /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las
567    /// keys que no aparecieron este frame se descartan (un nodo que se va deja
568    /// de animar). Devuelve `true` si alguna animación sigue en curso.
569    ///
570    /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del
571    /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes
572    /// controlados).
573    pub fn reconcile<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) -> bool {
574        let mut animating = false;
575        let mut seen: Vec<u64> = Vec::new();
576        // Keys presentes que requieren captura `live` y tracking de vanish
577        // (exit O switch). Membresía = "vive este frame".
578        let mut present_live: Vec<u64> = Vec::new();
579        // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de
580        // `switch` están presentes todos los frames y su ghost (contenido
581        // viejo) NO debe cancelarse por presencia.
582        let mut present_exit_only: Vec<u64> = Vec::new();
583        for node in &mut mounted.nodes {
584            let Some(anim) = node.anim else { continue };
585            seen.push(anim.key);
586            let target = AnimSnapshot {
587                fill: node.fill,
588                radius: node.radius,
589                alpha: node.alpha,
590                transform: node.transform,
591            };
592            // Detección de cross-fade (switch) ANTES de tomar prestado
593            // `entries`: si la variante cambió, el contenido viejo retenido en
594            // `live` (del frame anterior) se promueve a fantasma (fade-out) y
595            // el nodo nuevo arranca su fade-in desde alpha 0.
596            let mut switched = false;
597            if anim.exit {
598                present_live.push(anim.key);
599                present_exit_only.push(anim.key);
600            } else if let Some(variant) = anim.switch {
601                present_live.push(anim.key);
602                if let Some(prev) = self.variants.insert(anim.key, variant) {
603                    if prev != variant {
604                        switched = true;
605                        if let Some(le) = self.live.remove(&anim.key) {
606                            // La afín/placement/hold de salida del contenido
607                            // VIEJO los declara el frame nuevo (`anim.exit_*`):
608                            // sólo quien conmuta sabe hacia dónde debe irse lo
609                            // anterior (push ≠ pop). El centro sí es el capturado.
610                            self.ghosts.insert(
611                                anim.key,
612                                Ghost {
613                                    scene: le.scene,
614                                    start: now,
615                                    duration: le.duration,
616                                    easing: le.easing,
617                                    centro: le.centro,
618                                    to_xf: anim.exit_to_xf,
619                                    under: anim.exit_under,
620                                    hold: anim.exit_hold,
621                                },
622                            );
623                        }
624                    }
625                }
626            }
627            let entry = self.entries.entry(anim.key).or_insert_with(|| {
628                // Primera aparición. Con `enter`, arranca un tween de opacidad
629                // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también
630                // arranca de esa transform → target.transform (scale-in/slide-in).
631                if anim.enter {
632                    let from = AnimSnapshot {
633                        alpha: Some(0.0),
634                        transform: anim.enter_from_xf.or(target.transform),
635                        ..target
636                    };
637                    AnimEntry {
638                        from,
639                        to: target,
640                        start: now,
641                        duration: anim.duration,
642                        easing: anim.easing,
643                    }
644                } else {
645                    AnimEntry::settled(target, now)
646                }
647            });
648            if switched {
649                // Cross-fade: el contenido nuevo entra desde transparente
650                // (el viejo ya quedó como fantasma desvaneciéndose). Con
651                // `enter_fade: false` entra OPACO (push estilo UIKit: la
652                // página nueva tapa, no se funde). Si además declara
653                // `enter_from_xf`, entra desde esa afín (slide/zoom).
654                entry.from = AnimSnapshot {
655                    alpha: if anim.enter_fade { Some(0.0) } else { target.alpha },
656                    transform: anim.enter_from_xf.or(target.transform),
657                    ..target
658                };
659                entry.to = target;
660                entry.start = now;
661                entry.duration = anim.duration;
662                entry.easing = anim.easing;
663            } else if entry.to != target {
664                // Cambió el objetivo: congela el valor actual como nuevo origen
665                // y rearranca el reloj hacia el objetivo nuevo.
666                entry.from = entry.value(now);
667                entry.to = target;
668                entry.start = now;
669                entry.duration = anim.duration;
670                entry.easing = anim.easing;
671            }
672            // Al terminar aterriza EXACTO en el objetivo (incluido `alpha:
673            // None` / `transform: None`, que evita capa de opacidad residual o
674            // un push_layer afín espurio frame a frame).
675            let v = if entry.done(now) { entry.to } else { entry.value(now) };
676            node.fill = v.fill;
677            node.radius = v.radius;
678            node.alpha = v.alpha;
679            node.transform = v.transform;
680            if !entry.done(now) {
681                animating = true;
682            }
683        }
684        if self.entries.len() != seen.len() {
685            self.entries.retain(|k, _| seen.contains(k));
686        }
687        // Las variantes de keys que ya no aparecen se descartan (si la key
688        // vuelve, su primera aparición re-asienta sin cross-fade).
689        if self.variants.len() != seen.len() {
690            self.variants.retain(|k, _| seen.contains(k));
691        }
692
693        // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior
694        // (vive en `live`) que ya no aparece → se promueve a fantasma con su
695        // última subescena retenida. Si una key `exit` con fantasma reaparece,
696        // se cancela el fade (no las de `switch`: su fantasma es contenido viejo
697        // que debe seguir desvaneciéndose aunque la key siga presente). Por
698        // último, descartamos los fantasmas cuyo reloj se agotó.
699        let vanished: Vec<u64> = self
700            .live
701            .keys()
702            .filter(|k| !present_live.contains(k))
703            .copied()
704            .collect();
705        for key in vanished {
706            if let Some(le) = self.live.remove(&key) {
707                self.ghosts.insert(
708                    key,
709                    Ghost {
710                        scene: le.scene,
711                        start: now,
712                        duration: le.duration,
713                        easing: le.easing,
714                        centro: le.centro,
715                        to_xf: le.to_xf,
716                        under: le.under,
717                        hold: le.hold,
718                    },
719                );
720            }
721        }
722        for key in &present_exit_only {
723            self.ghosts.remove(key);
724        }
725        self.ghosts.retain(|_, g| !g.done(now));
726        animating || !self.ghosts.is_empty()
727    }
728
729    /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por
730    /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en
731    /// una subescena con [`crate::paint_range`] y entregarla a
732    /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y
733    /// la geometría ya están firmes).
734    pub fn live_exit_nodes<Msg>(&self, mounted: &Mounted<Msg>) -> Vec<(usize, usize, u64)> {
735        mounted
736            .nodes
737            .iter()
738            .enumerate()
739            .filter_map(|(idx, n)| {
740                n.anim
741                    .filter(|a| a.exit || a.switch.is_some())
742                    .map(|a| (idx, n.subtree_end, a.key))
743            })
744            .collect()
745    }
746
747    /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El
748    /// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim`
749    /// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/
750    /// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en
751    /// un `switch` la salida la re-declara el frame nuevo). `centro` es el
752    /// centro del rect del nodo (origen de la afín de salida).
753    pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) {
754        self.live.insert(
755            key,
756            LiveExit {
757                scene,
758                duration: anim.duration,
759                easing: anim.easing,
760                centro,
761                to_xf: anim.exit_to_xf,
762                under: anim.exit_under,
763                hold: anim.exit_hold,
764            },
765        );
766    }
767
768    /// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la
769    /// subescena (ya envuelta en su capa de alpha + afín de salida) de cada
770    /// fantasma `under` cuyo `switch` sigue montado — el paint la apendea
771    /// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro
772    /// del clip/transform de sus ancestros). Las keys colocadas se saltean en
773    /// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`]
774    /// y ANTES del paint.
775    pub fn prepare_underlays<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) {
776        self.under_placed.clear();
777        if self.ghosts.is_empty() {
778            return;
779        }
780        for node in &mut mounted.nodes {
781            let Some(anim) = node.anim else { continue };
782            if anim.switch.is_none() {
783                continue;
784            }
785            let Some(g) = self.ghosts.get(&anim.key) else { continue };
786            if !g.under {
787                continue;
788            }
789            let a = g.alpha(now);
790            if a <= 0.0 {
791                continue;
792            }
793            // Envoltorio: capa de alpha (rect generoso — el clip real lo
794            // ponen los ancestros del nodo en el paint) + la afín de salida.
795            let mut wrap = Scene::new();
796            let big = Rect::new(-1e6, -1e6, 1e6, 1e6);
797            wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big);
798            wrap.append(&g.scene, g.xf(now));
799            wrap.pop_layer();
800            node.underlay = Some(wrap);
801            self.under_placed.push(anim.key);
802        }
803    }
804
805    /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
806    /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint
807    /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo
808    /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo).
809    pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool {
810        if self.ghosts.is_empty() {
811            return false;
812        }
813        let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
814        for (key, g) in &self.ghosts {
815            // Los colocados como underlay este frame ya se pintaron en su
816            // posición z (dentro del árbol) — no duplicarlos encima.
817            if self.under_placed.contains(key) {
818                continue;
819            }
820            let a = g.alpha(now);
821            if a <= 0.0 {
822                continue;
823            }
824            scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip);
825            scene.append(&g.scene, g.xf(now));
826            scene.pop_layer();
827        }
828        true
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use crate::{mount, View};
836    use llimphi_layout::{LayoutTree, Style};
837
838    fn rgba(r: u8, g: u8, b: u8) -> Color {
839        Color::from_rgba8(r, g, b, 255)
840    }
841
842    /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`.
843    fn one(fill: Color) -> Mounted<()> {
844        let v = View::<()>::new(Style::default())
845            .fill(fill)
846            .animated(1, Duration::from_millis(200));
847        let mut layout = LayoutTree::new();
848        mount(&mut layout, v)
849    }
850
851    #[test]
852    fn primera_aparicion_no_anima() {
853        let mut reg = AnimRegistry::new();
854        let mut m = one(rgba(255, 0, 0));
855        let now = Instant::now();
856        let animating = reg.reconcile(&mut m, now);
857        assert!(!animating, "la primera vez no debe animar");
858        assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0)));
859    }
860
861    #[test]
862    fn cambio_de_color_interpola_y_pide_frames() {
863        let mut reg = AnimRegistry::new();
864        let t0 = Instant::now();
865        // Frame 1: rojo, se asienta.
866        let mut m = one(rgba(255, 0, 0));
867        reg.reconcile(&mut m, t0);
868        // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que
869        // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo)
870        // pero ya pide frames.
871        let mut m = one(rgba(0, 0, 255));
872        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
873        assert!(animating, "al detectar el cambio debe pedir frames");
874        // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado:
875        // ni rojo puro ni azul puro.
876        let mut m = one(rgba(0, 0, 255));
877        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200));
878        assert!(animating, "a mitad del tween debe seguir animando");
879        let c = m.nodes[0].fill.expect("fill").components;
880        assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]);
881        assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]);
882    }
883
884    #[test]
885    fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() {
886        let mut reg = AnimRegistry::new();
887        let t0 = Instant::now();
888        let mut m = one(rgba(255, 0, 0));
889        reg.reconcile(&mut m, t0);
890        let mut m = one(rgba(0, 0, 255));
891        reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca
892        // Pasada la duración, llega exacto al objetivo y no pide más frames.
893        let mut m = one(rgba(0, 0, 255));
894        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
895        assert!(!animating);
896        assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255)));
897    }
898
899    /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`.
900    fn one_alpha(alpha: f32) -> Mounted<()> {
901        let v = View::<()>::new(Style::default())
902            .alpha(alpha)
903            .animated(1, Duration::from_millis(200));
904        let mut layout = LayoutTree::new();
905        mount(&mut layout, v)
906    }
907
908    /// Monta un nodo opaco (sin alpha) con animación de ENTRADA.
909    fn one_enter() -> Mounted<()> {
910        let v = View::<()>::new(Style::default())
911            .fill(rgba(10, 20, 30))
912            .animated_enter(1, Duration::from_millis(200));
913        let mut layout = LayoutTree::new();
914        mount(&mut layout, v)
915    }
916
917    #[test]
918    fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() {
919        let mut reg = AnimRegistry::new();
920        let t0 = Instant::now();
921        // Primera aparición de un nodo `enter`: a diferencia de `animated`,
922        // SÍ anima — arranca casi transparente y pide frames.
923        let mut m = one_enter();
924        let animating = reg.reconcile(&mut m, t0);
925        assert!(animating, "la entrada debe animar desde el primer frame");
926        assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente");
927        // A mitad del tween, alpha intermedio.
928        let mut m = one_enter();
929        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
930        let a = m.nodes[0].alpha.expect("alpha");
931        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
932        // Pasada la duración: opaco exacto (None, sin capa residual) y quieto.
933        let mut m = one_enter();
934        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
935        assert!(!animating);
936        assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa");
937    }
938
939    /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`.
940    fn one_exit() -> Mounted<()> {
941        let v = View::<()>::new(Style::default())
942            .fill(rgba(10, 20, 30))
943            .animated_exit(7, Duration::from_millis(200));
944        let mut layout = LayoutTree::new();
945        mount(&mut layout, v)
946    }
947
948    /// Árbol vacío de nodos animados (la key `exit` ya no aparece).
949    fn empty() -> Mounted<()> {
950        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
951        let mut layout = LayoutTree::new();
952        mount(&mut layout, v)
953    }
954
955    #[test]
956    fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() {
957        let mut reg = AnimRegistry::new();
958        let t0 = Instant::now();
959        // Frame 1: el nodo exit está presente. No anima por sí solo, y el
960        // runtime captura su subescena (aquí una vacía de prueba).
961        let mut m = one_exit();
962        let animating = reg.reconcile(&mut m, t0);
963        assert!(!animating, "presente y quieto no anima");
964        reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
965        // Frame 2: la key desaparece → se promueve a fantasma y pide frames.
966        let mut m = empty();
967        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
968        assert!(animating, "un fantasma vivo mantiene el ticker");
969        assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
970        // Frame 3: pasada la duración el fantasma se descarta y el loop para.
971        let mut m = empty();
972        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300));
973        assert!(!animating, "fantasma agotado → sin más frames");
974        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0));
975    }
976
977    /// Monta un nodo `switch` (key=5) con la variante dada.
978    fn one_switch(variant: u64) -> Mounted<()> {
979        let v = View::<()>::new(Style::default())
980            .fill(rgba(10, 20, 30))
981            .animated_switch(5, variant, Duration::from_millis(200));
982        let mut layout = LayoutTree::new();
983        mount(&mut layout, v)
984    }
985
986    #[test]
987    fn switch_de_variante_cruza_contenido() {
988        let mut reg = AnimRegistry::new();
989        let t0 = Instant::now();
990        // Frame 1: variante 1, primera aparición → asienta, no cruza.
991        let mut m = one_switch(1);
992        assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
993        // El runtime captura su subescena (de prueba, vacía).
994        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
995        // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi
996        // transparente y hay un fantasma del contenido viejo desvaneciéndose.
997        let mut m = one_switch(2);
998        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
999        assert!(animating, "el cross-fade pide frames");
1000        let a = m.nodes[0].alpha.expect("alpha de fade-in");
1001        assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}");
1002        assert!(
1003            reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0),
1004            "hay un fantasma del contenido viejo"
1005        );
1006        // Re-captura del frame 2 (lo haría el runtime tras el paint).
1007        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1008        // Frame 3: misma variante, pasada la duración → asentado y sin fantasma.
1009        let mut m = one_switch(2);
1010        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1011        assert!(!animating, "asentado tras la duración");
1012        assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual");
1013        assert!(
1014            !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0),
1015            "fantasma agotado"
1016        );
1017    }
1018
1019    #[test]
1020    fn switch_misma_variante_no_cruza() {
1021        let mut reg = AnimRegistry::new();
1022        let t0 = Instant::now();
1023        let mut m = one_switch(1);
1024        reg.reconcile(&mut m, t0);
1025        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1026        // Misma variante en el frame siguiente: ni fade-in ni fantasma.
1027        let mut m = one_switch(1);
1028        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1029        assert!(!animating, "sin cambio de variante no cruza");
1030        assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco");
1031        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
1032    }
1033
1034    #[test]
1035    fn reaparecer_cancela_el_fantasma() {
1036        let mut reg = AnimRegistry::new();
1037        let t0 = Instant::now();
1038        let mut m = one_exit();
1039        reg.reconcile(&mut m, t0);
1040        reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1041        // Se va → fantasma.
1042        let mut m = empty();
1043        assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1044        // Reaparece a mitad del fade → el fantasma se cancela (no hay doble).
1045        let mut m = one_exit();
1046        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1047        assert!(!animating, "al reaparecer no queda fantasma");
1048        assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0));
1049    }
1050
1051    /// Monta un nodo con un transform afín explícito + anim(key=1).
1052    fn one_xf(xf: Affine) -> Mounted<()> {
1053        let v = View::<()>::new(Style::default())
1054            .transform(xf)
1055            .animated(1, Duration::from_millis(200));
1056        let mut layout = LayoutTree::new();
1057        mount(&mut layout, v)
1058    }
1059
1060    /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0).
1061    fn one_pop_in() -> Mounted<()> {
1062        let v = View::<()>::new(Style::default())
1063            .fill(rgba(1, 2, 3))
1064            .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5));
1065        let mut layout = LayoutTree::new();
1066        mount(&mut layout, v)
1067    }
1068
1069    #[test]
1070    fn cambio_de_transform_interpola_y_pide_frames() {
1071        let mut reg = AnimRegistry::new();
1072        let t0 = Instant::now();
1073        // Frame 1: identidad → se asienta sin animar.
1074        let mut m = one_xf(Affine::IDENTITY);
1075        assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
1076        // Frame 2: la view ahora pide scale(2.0) → arranca tween.
1077        let mut m = one_xf(Affine::scale(2.0));
1078        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1079        assert!(animating, "al cambiar la xf debe pedir frames");
1080        // Frame 3: a mitad, el m00 está entre 1.0 y 2.0.
1081        let mut m = one_xf(Affine::scale(2.0));
1082        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1083        let c = m.nodes[0].transform.expect("transform").as_coeffs();
1084        assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]);
1085        assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]);
1086    }
1087
1088    #[test]
1089    fn transform_al_terminar_llega_exacto() {
1090        let mut reg = AnimRegistry::new();
1091        let t0 = Instant::now();
1092        let mut m = one_xf(Affine::IDENTITY);
1093        reg.reconcile(&mut m, t0);
1094        let mut m = one_xf(Affine::translate((10.0, 20.0)));
1095        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1096        // Pasada la duración: aterriza exacto en la xf objetivo.
1097        let mut m = one_xf(Affine::translate((10.0, 20.0)));
1098        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1099        assert!(!animating);
1100        let c = m.nodes[0].transform.expect("xf").as_coeffs();
1101        assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]);
1102        assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]);
1103    }
1104
1105    #[test]
1106    fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() {
1107        let mut reg = AnimRegistry::new();
1108        let t0 = Instant::now();
1109        // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La
1110        // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller)
1111        // y debe pedir frames.
1112        let mut m = one_pop_in();
1113        let animating = reg.reconcile(&mut m, t0);
1114        assert!(animating, "pop-in anima desde el primer frame");
1115        let c = m.nodes[0].transform.expect("xf inicial").as_coeffs();
1116        assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]);
1117        // Frame intermedio: el m00 ya creció hacia 1.0.
1118        let mut m = one_pop_in();
1119        reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1120        let c = m.nodes[0].transform.expect("xf medio").as_coeffs();
1121        assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]);
1122        // Frame final: aterriza en None (sin xf residual), igual que alpha.
1123        let mut m = one_pop_in();
1124        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1125        assert!(!animating, "asentado");
1126        assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse");
1127    }
1128
1129    #[test]
1130    fn cambio_de_alpha_interpola() {
1131        let mut reg = AnimRegistry::new();
1132        let t0 = Instant::now();
1133        // Frame 1: alpha 1.0, se asienta (no es `enter`).
1134        let mut m = one_alpha(1.0);
1135        let animating = reg.reconcile(&mut m, t0);
1136        assert!(!animating, "primera aparición sin enter no anima");
1137        // Frame 2: la view baja a 0.0 → arranca tween.
1138        let mut m = one_alpha(0.0);
1139        reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1140        // Frame 3: a mitad, alpha intermedio.
1141        let mut m = one_alpha(0.0);
1142        reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1143        let a = m.nodes[0].alpha.expect("alpha");
1144        assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
1145    }
1146
1147    #[test]
1148    fn keys_que_se_van_se_descartan() {
1149        let mut reg = AnimRegistry::new();
1150        let now = Instant::now();
1151        let mut m = one(rgba(1, 2, 3));
1152        reg.reconcile(&mut m, now);
1153        assert_eq!(reg.entries.len(), 1);
1154        // Frame sin ningún nodo animado: la entrada se descarta.
1155        let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
1156        let mut layout = LayoutTree::new();
1157        let mut m2 = mount(&mut layout, v);
1158        reg.reconcile(&mut m2, now);
1159        assert_eq!(reg.entries.len(), 0);
1160    }
1161
1162    // ─── Bloque 15: tests de SizeAnim / animateContentSize ───
1163
1164    fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> {
1165        use llimphi_layout::taffy::prelude::{length, Size};
1166        let mut style = Style::default();
1167        style.size = Size { width: length(w), height: length(h) };
1168        View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms))
1169    }
1170
1171    #[test]
1172    fn size_anim_primera_aparicion_no_anima() {
1173        let mut reg = SizeAnimRegistry::new();
1174        let mut v = sized_view(1, 100.0, 80.0, 200);
1175        let now = Instant::now();
1176        let animating = reconcile_size_anim(&mut v, &mut reg, now);
1177        assert!(!animating, "primera vez: sin animación");
1178        // El style.size queda intacto (length(100, 80)).
1179        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1180        assert_eq!((w, h), (100.0, 80.0));
1181    }
1182
1183    #[test]
1184    fn size_anim_cambia_target_interpola() {
1185        let mut reg = SizeAnimRegistry::new();
1186        let t0 = Instant::now();
1187        // Frame 1: target = 100×80, se asienta.
1188        let mut v = sized_view(1, 100.0, 80.0, 200);
1189        reconcile_size_anim(&mut v, &mut reg, t0);
1190        // Frame 2: target nuevo = 200×160. En el frame que se detecta el
1191        // cambio arranca el reloj — todavía pinta cerca del origen.
1192        let mut v = sized_view(1, 200.0, 160.0, 200);
1193        let animating = reconcile_size_anim(&mut v, &mut reg, t0);
1194        assert!(animating, "cambio de target: pide frames");
1195        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1196        assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}");
1197        assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}");
1198        // Frame 3: 100 ms (mitad del tween).
1199        let mut v = sized_view(1, 200.0, 160.0, 200);
1200        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100));
1201        assert!(animating, "a mitad del tween sigue animando");
1202        let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1203        assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}");
1204        assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}");
1205    }
1206
1207    #[test]
1208    fn size_anim_termina_y_se_detiene() {
1209        let mut reg = SizeAnimRegistry::new();
1210        let t0 = Instant::now();
1211        let mut v = sized_view(1, 100.0, 80.0, 200);
1212        reconcile_size_anim(&mut v, &mut reg, t0);
1213        let mut v = sized_view(1, 200.0, 160.0, 200);
1214        reconcile_size_anim(&mut v, &mut reg, t0); // arranca
1215        // Pasada la duración: aterriza exacto en el objetivo y no pide más.
1216        let mut v = sized_view(1, 200.0, 160.0, 200);
1217        let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400));
1218        assert!(!animating);
1219        assert_eq!(
1220            (v.style.size.width.value(), v.style.size.height.value()),
1221            (200.0, 160.0),
1222        );
1223    }
1224
1225    #[test]
1226    fn size_anim_no_animable_si_tamano_no_es_length() {
1227        // Si el caller declara percent o auto, el reconciler lo deja pasar
1228        // sin tracking — no hay valor en píxeles estable para interpolar.
1229        use llimphi_layout::taffy::prelude::{percent, Dimension, Size};
1230        let mut reg = SizeAnimRegistry::new();
1231        let mut style = Style::default();
1232        style.size = Size { width: percent(0.5), height: Dimension::auto() };
1233        let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200));
1234        let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now());
1235        assert!(!animating);
1236        // El size no se tocó: width sigue siendo percent (no LENGTH_TAG).
1237        use llimphi_layout::taffy::CompactLength;
1238        assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG);
1239    }
1240
1241    #[test]
1242    fn size_anim_descarta_keys_no_vistas() {
1243        let mut reg = SizeAnimRegistry::new();
1244        let now = Instant::now();
1245        let mut v = sized_view(42, 50.0, 50.0, 200);
1246        reconcile_size_anim(&mut v, &mut reg, now);
1247        assert_eq!(reg.entries.len(), 1);
1248        // Frame sin animated_size: la entrada se descarta.
1249        let mut v: View<()> = View::<()>::new(Style::default());
1250        reconcile_size_anim(&mut v, &mut reg, now);
1251        assert_eq!(reg.entries.len(), 0);
1252    }
1253
1254    // ── Salida direccional (exit_to_xf) — base del router de páginas ────────
1255
1256    #[test]
1257    fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() {
1258        let t0 = Instant::now();
1259        let g = Ghost {
1260            scene: Scene::new(),
1261            start: t0,
1262            duration: Duration::from_millis(200),
1263            easing: |t| t, // lineal para aserciones exactas
1264            centro: (100.0, 50.0),
1265            to_xf: Some(Affine::translate((-200.0, 0.0))),
1266            under: false,
1267            hold: false,
1268        };
1269        // t=0: afín ≈ identidad (el fantasma arranca donde estaba).
1270        let xf0 = g.xf(t0).expect("hay afín");
1271        let c0 = xf0.as_coeffs();
1272        assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}");
1273        // t=0.5: mitad del desplazamiento. Una traslación conmuta con la
1274        // conjugación por el centro, así que el coef es -100 exacto.
1275        let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva");
1276        let c = xf.as_coeffs();
1277        assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}");
1278        // Sin to_xf → None (fade puro, camino histórico).
1279        let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g };
1280        assert!(g2.xf(t0 + Duration::from_millis(100)).is_none());
1281    }
1282
1283    #[test]
1284    fn fantasma_con_scale_respira_desde_su_centro() {
1285        let t0 = Instant::now();
1286        let g = Ghost {
1287            scene: Scene::new(),
1288            start: t0,
1289            duration: Duration::from_millis(100),
1290            easing: |t| t,
1291            centro: (100.0, 100.0),
1292            to_xf: Some(Affine::scale(2.0)),
1293            under: false,
1294            hold: false,
1295        };
1296        // Al final del tween (t=1) la afín es scale(2) conjugada por el
1297        // centro: el centro queda FIJO (100,100 → 100,100).
1298        let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín");
1299        let p = xf * vello::kurbo::Point::new(100.0, 100.0);
1300        assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6,
1301                "el centro no se mueve: {p:?}");
1302        // Un punto a 50px del centro termina a 100px (escala 2).
1303        let q = xf * vello::kurbo::Point::new(150.0, 100.0);
1304        assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}");
1305    }
1306
1307    #[test]
1308    fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() {
1309        let salida = Affine::translate((-300.0, 0.0));
1310        let entrada = Affine::translate((300.0, 0.0));
1311        let one = |variant: u64| -> Mounted<()> {
1312            let v = View::<()>::new(Style::default())
1313                .fill(rgba(10, 20, 30))
1314                .animated_switch_from(5, variant, Duration::from_millis(200),
1315                                      Some(entrada), Some(salida));
1316            let mut layout = LayoutTree::new();
1317            mount(&mut layout, v)
1318        };
1319        let mut reg = AnimRegistry::new();
1320        let t0 = Instant::now();
1321        // Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1,
1322        // que NO es el que debe usarse al conmutar).
1323        let mut m = one(1);
1324        reg.reconcile(&mut m, t0);
1325        let mut senuelo = m.nodes[0].anim.expect("anim");
1326        senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse
1327        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo);
1328        // Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia
1329        // `entrada` (además de transparente) y el fantasma lleva la `salida`
1330        // declarada por el frame nuevo, no el señuelo capturado.
1331        let mut m = one(2);
1332        let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1333        assert!(animating);
1334        let xf = m.nodes[0].transform.expect("transform de entrada");
1335        let c = xf.as_coeffs();
1336        assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}");
1337        let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo");
1338        assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo");
1339    }
1340
1341    #[test]
1342    fn hold_mantiene_el_alpha_hasta_expirar() {
1343        let t0 = Instant::now();
1344        let g = Ghost {
1345            scene: Scene::new(),
1346            start: t0,
1347            duration: Duration::from_millis(200),
1348            easing: |t| t,
1349            centro: (0.0, 0.0),
1350            to_xf: None,
1351            under: false,
1352            hold: true,
1353        };
1354        // A mitad del reloj sigue OPACO (salida sin desvanecer).
1355        assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0);
1356        // Expirado: desaparece de golpe.
1357        assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0);
1358    }
1359
1360    #[test]
1361    fn switch_estilado_entra_opaco_y_coloca_underlay() {
1362        use crate::SwitchStyle;
1363        let style = SwitchStyle {
1364            enter_from_xf: Some(Affine::translate((300.0, 0.0))),
1365            enter_fade: false,
1366            exit_to_xf: None,
1367            exit_under: true,
1368            exit_hold: true,
1369        };
1370        let one = |variant: u64| -> Mounted<()> {
1371            let v = View::<()>::new(Style::default())
1372                .fill(rgba(10, 20, 30))
1373                .animated_switch_styled(5, variant, Duration::from_millis(200), style);
1374            let mut layout = LayoutTree::new();
1375            mount(&mut layout, v)
1376        };
1377        let mut reg = AnimRegistry::new();
1378        let t0 = Instant::now();
1379        let mut m = one(1);
1380        reg.reconcile(&mut m, t0);
1381        reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1382        // Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha
1383        // intacto — push opaco), y el fantasma queda marcado under+hold.
1384        let mut m = one(2);
1385        let t1 = t0 + Duration::from_millis(10);
1386        assert!(reg.reconcile(&mut m, t1));
1387        assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)");
1388        assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose");
1389        let g = reg.ghosts.get(&5).expect("fantasma");
1390        assert!(g.under && g.hold, "placement y hold del frame nuevo");
1391        // prepare_underlays hornea la subescena en el nodo y la marca para
1392        // que replay_ghosts no la duplique encima.
1393        reg.prepare_underlays(&mut m, t1);
1394        assert!(m.nodes[0].underlay.is_some(), "underlay colocado");
1395        assert_eq!(reg.under_placed, vec![5]);
1396        // Un fantasma under colocado no se re-pinta arriba, pero la función
1397        // sigue reportando fantasmas vivos (ticker).
1398        assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0));
1399        // Si la key deja de estar montada (nodo desapareció), el fantasma
1400        // cae al camino histórico (encima): sin underlay que colocar.
1401        let mut vacio = empty();
1402        reg.prepare_underlays(&mut vacio, t1);
1403        assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay");
1404    }
1405
1406    #[test]
1407    fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() {
1408        let to = Affine::translate((0.0, 40.0));
1409        let v = View::<()>::new(Style::default())
1410            .fill(rgba(9, 9, 9))
1411            .animated_exit_to(7, Duration::from_millis(200), to);
1412        let mut layout = LayoutTree::new();
1413        let mut m = mount(&mut layout, v);
1414        let mut reg = AnimRegistry::new();
1415        let t0 = Instant::now();
1416        reg.reconcile(&mut m, t0);
1417        // El runtime captura con la to_xf del propio nodo (camino exit).
1418        reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim"));
1419        // Desaparece → el fantasma hereda la afín capturada.
1420        let mut m = empty();
1421        assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1422        let g = reg.ghosts.get(&7).expect("fantasma");
1423        assert_eq!(g.to_xf, Some(to));
1424        assert_eq!(g.centro, (20.0, 20.0));
1425    }
1426}