tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-compositor/src/anim.rs
Historia
Quién tocó qué
crudo
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}