tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-compositor/src/anim.rs
History
Who touched what
raw
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}