tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-compositor/src/anim.rs Rust · 1431 lines · 61.2 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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