tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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/// Cada cuánto se **refresca** la foto de un nodo `exit`/`switch` que sigue
477/// vivo.
478///
479/// Refrescarla en CADA frame es lo que hacía antes, y es carísimo donde más se
480/// usa: la captura repinta el subárbol entero con [`crate::paint_range`], por
481/// fuera del caché de repintado, así que marcar el lienzo de una app con
482/// `animated_switch` le duplicaba el costo de pintado **para siempre**, no
483/// durante la transición. Con el cuentagotas la foto queda como mucho
484/// `REFRESCO_LIVE` atrasada, y eso no se ve: lo único que se hace con ella es
485/// el fantasma que se desvanece en ~200 ms.
486///
487/// Es el mismo reparo que ya tiene el compositor para la miniatura del cajón
488/// (`DrmState::recordar_cajon` se autolimita a una copia cada 500 ms).
489///
490/// Un nodo **sin** foto se captura siempre, sin esperar: si no, uno que aparece
491/// y se va dentro de la misma ventana no dejaría fantasma ninguno.
492const REFRESCO_LIVE: Duration = Duration::from_millis(120);
493
494/// Subescena retenida de un nodo marcado para animar su salida, capturada por
495/// el runtime el último frame que el nodo vivió. Mientras la key sigue presente
496/// se refresca con cuentagotas (`REFRESCO_LIVE`); cuando desaparece, se
497/// promueve a [`Ghost`].
498struct LiveExit {
499 scene: Scene,
500 /// Cuándo se tomó esta foto. La captura se **refresca con cuentagotas**
501 /// (ver [`REFRESCO_LIVE`]), así que hace falta saber cuánto tiene.
502 sellado: Instant,
503 duration: Duration,
504 easing: fn(f32) -> f32,
505 /// Centro del rect del nodo al capturarse — origen de la afín de salida.
506 centro: (f32, f32),
507 /// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`).
508 to_xf: Option<Affine>,
509 /// El fantasma va debajo del contenido nuevo (`Anim::exit_under`).
510 under: bool,
511 /// El fantasma no se desvanece (`Anim::exit_hold`).
512 hold: bool,
513}
514
515/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
516/// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza).
517struct Ghost {
518 scene: Scene,
519 start: Instant,
520 duration: Duration,
521 easing: fn(f32) -> f32,
522 centro: (f32, f32),
523 to_xf: Option<Affine>,
524 under: bool,
525 hold: bool,
526}
527
528impl Ghost {
529 /// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con
530 /// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca).
531 fn alpha(&self, now: Instant) -> f32 {
532 if self.hold {
533 return if self.done(now) { 0.0 } else { 1.0 };
534 }
535 1.0 - self.t(now)
536 }
537
538 /// Progreso eased `0 → 1` del fantasma.
539 fn t(&self, now: Instant) -> f32 {
540 if self.duration.is_zero() {
541 return 1.0;
542 }
543 let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
544 let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
545 (self.easing)(raw)
546 }
547
548 /// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del
549 /// rect capturado (misma convención que `View::transform`, así un scale
550 /// "respira" desde su centro y no desde el origen de la ventana). `None`
551 /// si el fantasma sólo se desvanece en el lugar.
552 fn xf(&self, now: Instant) -> Option<Affine> {
553 let to = self.to_xf?;
554 let t = self.t(now);
555 let local = lerp_affine(Affine::IDENTITY, to, t);
556 let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64);
557 Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)))
558 }
559
560 fn done(&self, now: Instant) -> bool {
561 now.saturating_duration_since(self.start) >= self.duration
562 }
563}
564
565/// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene
566/// una instancia y llama [`Self::reconcile`] en cada redraw.
567#[derive(Default)]
568pub struct AnimRegistry {
569 entries: HashMap<u64, AnimEntry>,
570 /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el
571 /// runtime tras el paint de cada frame). Membresía = "presente el frame
572 /// anterior".
573 live: HashMap<u64, LiveExit>,
574 /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`)
575 /// que se están desvaneciendo.
576 ghosts: HashMap<u64, Ghost>,
577 /// Última variante vista por cada key con `switch` — para detectar el
578 /// cambio de contenido que dispara el cross-fade.
579 variants: HashMap<u64, u64>,
580 /// Keys colocadas como underlay ESTE frame (por
581 /// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no
582 /// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`.
583 under_placed: Vec<u64>,
584 /// **Instrumento.** Cuántos nodos `switch` conmutaron de variante en el
585 /// último [`Self::reconcile`], y de ésos cuántos tenían foto retenida para
586 /// dejar fantasma. Dos contadores de un `u16` cada uno, sin asignaciones:
587 /// existen porque un `animated_switch` que no se ve tiene **cuatro** causas
588 /// que en el píxel son la misma (el nodo no se marcó, la variante no cambió,
589 /// no había foto, o la afín es la identidad) y ninguna se deduce mirando.
590 /// Los lee el host por [`Self::ultimo_reconcile`].
591 switches_ultimo: u16,
592 switches_con_foto_ultimo: u16,
593 /// **La quinta causa, y la única muda** (2026-09-15). Cuántos nodos `switch`
594 /// aparecieron este frame con la clave **estrenada** — o sea que no estaba en
595 /// `variants` la vuelta anterior, porque el nodo no existía en el árbol.
596 ///
597 /// Esa primera aparición **re-asienta sin animar** a propósito (una key que
598 /// vuelve no debe conmutar contra la variante que tenía hace media hora),
599 /// pero desde afuera se ve idéntica a «la variante no cambió»: los dos
600 /// contadores dan cero y el píxel no se mueve. Si el nodo sale y vuelve a
601 /// entrar al árbol en el MISMO gesto que cambia la variante, no anima nunca
602 /// y ningún contador lo dice. Éste lo dice.
603 switches_estreno_ultimo: u16,
604}
605
606impl AnimRegistry {
607 pub fn new() -> Self {
608 Self::default()
609 }
610
611 /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con
612 /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y
613 /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las
614 /// keys que no aparecieron este frame se descartan (un nodo que se va deja
615 /// de animar). Devuelve `true` si alguna animación sigue en curso.
616 ///
617 /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del
618 /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes
619 /// controlados).
620 /// **Instrumento**: `(nodos marcados con switch que conmutaron, de ésos
621 /// cuántos tenían foto, fantasmas vivos, claves estrenadas)` en el último
622 /// [`Self::reconcile`]. Lo consume el diag del host — ver el `diag!` de
623 /// `pata-llimphi::dibujar_panel`. El cuarto es el que habla cuando los dos
624 /// primeros callan: ver [`Self::switches_estreno_ultimo`].
625 pub fn ultimo_reconcile(&self) -> (u16, u16, usize, u16) {
626 (
627 self.switches_ultimo,
628 self.switches_con_foto_ultimo,
629 self.ghosts.len(),
630 self.switches_estreno_ultimo,
631 )
632 }
633
634 pub fn reconcile<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) -> bool {
635 let mut animating = false;
636 self.switches_ultimo = 0;
637 self.switches_con_foto_ultimo = 0;
638 self.switches_estreno_ultimo = 0;
639 let mut seen: Vec<u64> = Vec::new();
640 // Keys presentes que requieren captura `live` y tracking de vanish
641 // (exit O switch). Membresía = "vive este frame".
642 let mut present_live: Vec<u64> = Vec::new();
643 // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de
644 // `switch` están presentes todos los frames y su ghost (contenido
645 // viejo) NO debe cancelarse por presencia.
646 let mut present_exit_only: Vec<u64> = Vec::new();
647 for node in &mut mounted.nodes {
648 let Some(anim) = node.anim else { continue };
649 seen.push(anim.key);
650 let target = AnimSnapshot {
651 fill: node.fill,
652 radius: node.radius,
653 alpha: node.alpha,
654 transform: node.transform,
655 };
656 // Detección de cross-fade (switch) ANTES de tomar prestado
657 // `entries`: si la variante cambió, el contenido viejo retenido en
658 // `live` (del frame anterior) se promueve a fantasma (fade-out) y
659 // el nodo nuevo arranca su fade-in desde alpha 0.
660 let mut switched = false;
661 if anim.exit {
662 present_live.push(anim.key);
663 present_exit_only.push(anim.key);
664 } else if let Some(variant) = anim.switch {
665 present_live.push(anim.key);
666 let anterior = self.variants.insert(anim.key, variant);
667 if anterior.is_none() {
668 // Clave estrenada: el nodo no estaba en el árbol la vuelta
669 // pasada. Se re-asienta sin animar, y eso no se distingue de
670 // «la variante no cambió» mirando los otros dos contadores.
671 self.switches_estreno_ultimo =
672 self.switches_estreno_ultimo.saturating_add(1);
673 }
674 if let Some(prev) = anterior {
675 if prev != variant {
676 switched = true;
677 self.switches_ultimo = self.switches_ultimo.saturating_add(1);
678 if let Some(le) = self.live.remove(&anim.key) {
679 self.switches_con_foto_ultimo =
680 self.switches_con_foto_ultimo.saturating_add(1);
681 // La afín/placement/hold de salida del contenido
682 // VIEJO los declara el frame nuevo (`anim.exit_*`):
683 // sólo quien conmuta sabe hacia dónde debe irse lo
684 // anterior (push ≠ pop). El centro sí es el capturado.
685 self.ghosts.insert(
686 anim.key,
687 Ghost {
688 scene: le.scene,
689 start: now,
690 duration: le.duration,
691 easing: le.easing,
692 centro: le.centro,
693 to_xf: anim.exit_to_xf,
694 under: anim.exit_under,
695 hold: anim.exit_hold,
696 },
697 );
698 }
699 }
700 }
701 }
702 let entry = self.entries.entry(anim.key).or_insert_with(|| {
703 // Primera aparición. Con `enter`, arranca un tween de opacidad
704 // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también
705 // arranca de esa transform → target.transform (scale-in/slide-in).
706 if anim.enter {
707 let from = AnimSnapshot {
708 alpha: Some(0.0),
709 transform: anim.enter_from_xf.or(target.transform),
710 ..target
711 };
712 AnimEntry {
713 from,
714 to: target,
715 start: now,
716 duration: anim.duration,
717 easing: anim.easing,
718 }
719 } else {
720 AnimEntry::settled(target, now)
721 }
722 });
723 if switched {
724 // Cross-fade: el contenido nuevo entra desde transparente
725 // (el viejo ya quedó como fantasma desvaneciéndose). Con
726 // `enter_fade: false` entra OPACO (push estilo UIKit: la
727 // página nueva tapa, no se funde). Si además declara
728 // `enter_from_xf`, entra desde esa afín (slide/zoom).
729 entry.from = AnimSnapshot {
730 alpha: if anim.enter_fade { Some(0.0) } else { target.alpha },
731 transform: anim.enter_from_xf.or(target.transform),
732 ..target
733 };
734 entry.to = target;
735 entry.start = now;
736 entry.duration = anim.duration;
737 entry.easing = anim.easing;
738 } else if entry.to != target {
739 // Cambió el objetivo: congela el valor actual como nuevo origen
740 // y rearranca el reloj hacia el objetivo nuevo.
741 entry.from = entry.value(now);
742 entry.to = target;
743 entry.start = now;
744 entry.duration = anim.duration;
745 entry.easing = anim.easing;
746 }
747 // Al terminar aterriza EXACTO en el objetivo (incluido `alpha:
748 // None` / `transform: None`, que evita capa de opacidad residual o
749 // un push_layer afín espurio frame a frame).
750 let v = if entry.done(now) { entry.to } else { entry.value(now) };
751 node.fill = v.fill;
752 node.radius = v.radius;
753 node.alpha = v.alpha;
754 node.transform = v.transform;
755 if !entry.done(now) {
756 animating = true;
757 }
758 }
759 if self.entries.len() != seen.len() {
760 self.entries.retain(|k, _| seen.contains(k));
761 }
762 // Las variantes de keys que ya no aparecen se descartan (si la key
763 // vuelve, su primera aparición re-asienta sin cross-fade).
764 if self.variants.len() != seen.len() {
765 self.variants.retain(|k, _| seen.contains(k));
766 }
767
768 // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior
769 // (vive en `live`) que ya no aparece → se promueve a fantasma con su
770 // última subescena retenida. Si una key `exit` con fantasma reaparece,
771 // se cancela el fade (no las de `switch`: su fantasma es contenido viejo
772 // que debe seguir desvaneciéndose aunque la key siga presente). Por
773 // último, descartamos los fantasmas cuyo reloj se agotó.
774 let vanished: Vec<u64> = self
775 .live
776 .keys()
777 .filter(|k| !present_live.contains(k))
778 .copied()
779 .collect();
780 for key in vanished {
781 if let Some(le) = self.live.remove(&key) {
782 self.ghosts.insert(
783 key,
784 Ghost {
785 scene: le.scene,
786 start: now,
787 duration: le.duration,
788 easing: le.easing,
789 centro: le.centro,
790 to_xf: le.to_xf,
791 under: le.under,
792 hold: le.hold,
793 },
794 );
795 }
796 }
797 for key in &present_exit_only {
798 self.ghosts.remove(key);
799 }
800 self.ghosts.retain(|_, g| !g.done(now));
801 animating || !self.ghosts.is_empty()
802 }
803
804 /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por
805 /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en
806 /// una subescena con [`crate::paint_range`] y entregarla a
807 /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y
808 /// la geometría ya están firmes).
809 ///
810 /// **No devuelve todos los nodos marcados: sólo los que toca refrescar.**
811 /// Ver `REFRESCO_LIVE` — repintar el subárbol de cada uno en cada frame
812 /// era el precio oculto de `animated_switch`, y lo paga quien lo pone sobre
813 /// el nodo más caro de su app, que es justo para lo que sirve.
814 pub fn live_exit_nodes<Msg>(
815 &self,
816 mounted: &Mounted<Msg>,
817 now: Instant,
818 ) -> Vec<(usize, usize, u64)> {
819 mounted
820 .nodes
821 .iter()
822 .enumerate()
823 .filter_map(|(idx, n)| {
824 n.anim
825 .filter(|a| a.exit || a.switch.is_some())
826 .filter(|a| self.toca_refrescar(a.key, now))
827 .map(|a| (idx, n.subtree_end, a.key))
828 })
829 .collect()
830 }
831
832 /// ¿La foto de `key` está vencida (o no existe)?
833 fn toca_refrescar(&self, key: u64, now: Instant) -> bool {
834 match self.live.get(&key) {
835 None => true,
836 Some(l) => now.saturating_duration_since(l.sellado) >= REFRESCO_LIVE,
837 }
838 }
839
840 /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El
841 /// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim`
842 /// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/
843 /// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en
844 /// un `switch` la salida la re-declara el frame nuevo). `centro` es el
845 /// centro del rect del nodo (origen de la afín de salida).
846 ///
847 /// **Sólo existe con `std`**, porque lee el reloj. Sin sistema operativo no
848 /// hay reloj global al que preguntarle la hora —lo dice el encabezado del
849 /// crate— y el runtime ya trae el instante del fotograma: ahí va
850 /// [`Self::store_live_exit_en`]. La ausencia de `Instant::now()` está puesta
851 /// para que un uso equivocado sea un error de compilación en vez de una
852 /// animación congelada; **este atajo se le había escapado**, y el error salía
853 /// sólo en un perfil que ningún check del árbol compilaba.
854 #[cfg(feature = "std")]
855 pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) {
856 self.store_live_exit_en(key, scene, centro, anim, Instant::now())
857 }
858
859 /// Como [`Self::store_live_exit`] con el reloj explícito — lo usa el runtime
860 /// (que ya tiene el `now` del frame) y los tests.
861 pub fn store_live_exit_en(
862 &mut self,
863 key: u64,
864 scene: Scene,
865 centro: (f32, f32),
866 anim: &Anim,
867 now: Instant,
868 ) {
869 self.live.insert(
870 key,
871 LiveExit {
872 scene,
873 sellado: now,
874 duration: anim.duration,
875 easing: anim.easing,
876 centro,
877 to_xf: anim.exit_to_xf,
878 under: anim.exit_under,
879 hold: anim.exit_hold,
880 },
881 );
882 }
883
884 /// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la
885 /// subescena (ya envuelta en su capa de alpha + afín de salida) de cada
886 /// fantasma `under` cuyo `switch` sigue montado — el paint la apendea
887 /// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro
888 /// del clip/transform de sus ancestros). Las keys colocadas se saltean en
889 /// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`]
890 /// y ANTES del paint.
891 pub fn prepare_underlays<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) {
892 self.under_placed.clear();
893 if self.ghosts.is_empty() {
894 return;
895 }
896 for node in &mut mounted.nodes {
897 let Some(anim) = node.anim else { continue };
898 if anim.switch.is_none() {
899 continue;
900 }
901 let Some(g) = self.ghosts.get(&anim.key) else { continue };
902 if !g.under {
903 continue;
904 }
905 let a = g.alpha(now);
906 if a <= 0.0 {
907 continue;
908 }
909 // Envoltorio: capa de alpha (rect generoso — el clip real lo
910 // ponen los ancestros del nodo en el paint) + la afín de salida.
911 let mut wrap = Scene::new();
912 let big = Rect::new(-1e6, -1e6, 1e6, 1e6);
913 wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big);
914 wrap.append(&g.scene, g.xf(now));
915 wrap.pop_layer();
916 node.underlay = Some(wrap);
917 self.under_placed.push(anim.key);
918 }
919 }
920
921 /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
922 /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint
923 /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo
924 /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo).
925 /// **Olvidá lo que estaba saliendo**: tira los fantasmas vivos y las fotos
926 /// retenidas de los nodos marcados para animar su salida.
927 ///
928 /// Lo pide el host cuando lo que se estaba desvaneciendo **dejó de existir**
929 /// de un modo que la animación no puede saber. El caso que lo trajo: el
930 /// cajón de shuma se guarda con un enrollado propio (un clip que lo deja
931 /// invisible en 115 ms) y su lienzo, que está marcado con `animated_switch`
932 /// para paginar entre sesiones, desaparece del árbol. Acá eso se lee como
933 /// una salida y se pinta la foto retenida —**el cajón abierto y entero**,
934 /// hasta 120 ms vieja— desvaneciéndose 160 ms más, sin el clip que lo estaba
935 /// enrollando y sobre una surface que todavía mide la pantalla completa. En
936 /// el píxel eso es el cajón volviendo un instante después de guardarlo, y el
937 /// operador lo reportó como *«guarda y luego parpadea para guardarse otra
938 /// vez»* (medido cuadro a cuadro el 2026-09-17).
939 ///
940 /// No es un caso que la animación pueda detectar sola: desde acá, un nodo
941 /// que se va es un nodo que se va. Quien sabe que el cajón se guardó —y que
942 /// por lo tanto no hay nada que despedir— es el host.
943 pub fn clear_exits(&mut self) {
944 self.ghosts.clear();
945 self.live.clear();
946 }
947
948 pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool {
949 if self.ghosts.is_empty() {
950 return false;
951 }
952 let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
953 for (key, g) in &self.ghosts {
954 // Los colocados como underlay este frame ya se pintaron en su
955 // posición z (dentro del árbol) — no duplicarlos encima.
956 if self.under_placed.contains(key) {
957 continue;
958 }
959 let a = g.alpha(now);
960 if a <= 0.0 {
961 continue;
962 }
963 scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip);
964 scene.append(&g.scene, g.xf(now));
965 scene.pop_layer();
966 }
967 true
968 }
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use crate::{mount, View};
975 use llimphi_layout::{LayoutTree, Style};
976
977 fn rgba(r: u8, g: u8, b: u8) -> Color {
978 Color::from_rgba8(r, g, b, 255)
979 }
980
981 /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`.
982 fn one(fill: Color) -> Mounted<()> {
983 let v = View::<()>::new(Style::default())
984 .fill(fill)
985 .animated(1, Duration::from_millis(200));
986 let mut layout = LayoutTree::new();
987 mount(&mut layout, v)
988 }
989
990 #[test]
991 fn primera_aparicion_no_anima() {
992 let mut reg = AnimRegistry::new();
993 let mut m = one(rgba(255, 0, 0));
994 let now = Instant::now();
995 let animating = reg.reconcile(&mut m, now);
996 assert!(!animating, "la primera vez no debe animar");
997 assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0)));
998 }
999
1000 #[test]
1001 fn cambio_de_color_interpola_y_pide_frames() {
1002 let mut reg = AnimRegistry::new();
1003 let t0 = Instant::now();
1004 // Frame 1: rojo, se asienta.
1005 let mut m = one(rgba(255, 0, 0));
1006 reg.reconcile(&mut m, t0);
1007 // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que
1008 // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo)
1009 // pero ya pide frames.
1010 let mut m = one(rgba(0, 0, 255));
1011 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1012 assert!(animating, "al detectar el cambio debe pedir frames");
1013 // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado:
1014 // ni rojo puro ni azul puro.
1015 let mut m = one(rgba(0, 0, 255));
1016 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200));
1017 assert!(animating, "a mitad del tween debe seguir animando");
1018 let c = m.nodes[0].fill.expect("fill").components;
1019 assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]);
1020 assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]);
1021 }
1022
1023 #[test]
1024 fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() {
1025 let mut reg = AnimRegistry::new();
1026 let t0 = Instant::now();
1027 let mut m = one(rgba(255, 0, 0));
1028 reg.reconcile(&mut m, t0);
1029 let mut m = one(rgba(0, 0, 255));
1030 reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca
1031 // Pasada la duración, llega exacto al objetivo y no pide más frames.
1032 let mut m = one(rgba(0, 0, 255));
1033 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1034 assert!(!animating);
1035 assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255)));
1036 }
1037
1038 /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`.
1039 fn one_alpha(alpha: f32) -> Mounted<()> {
1040 let v = View::<()>::new(Style::default())
1041 .alpha(alpha)
1042 .animated(1, Duration::from_millis(200));
1043 let mut layout = LayoutTree::new();
1044 mount(&mut layout, v)
1045 }
1046
1047 /// Monta un nodo opaco (sin alpha) con animación de ENTRADA.
1048 fn one_enter() -> Mounted<()> {
1049 let v = View::<()>::new(Style::default())
1050 .fill(rgba(10, 20, 30))
1051 .animated_enter(1, Duration::from_millis(200));
1052 let mut layout = LayoutTree::new();
1053 mount(&mut layout, v)
1054 }
1055
1056 #[test]
1057 fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() {
1058 let mut reg = AnimRegistry::new();
1059 let t0 = Instant::now();
1060 // Primera aparición de un nodo `enter`: a diferencia de `animated`,
1061 // SÍ anima — arranca casi transparente y pide frames.
1062 let mut m = one_enter();
1063 let animating = reg.reconcile(&mut m, t0);
1064 assert!(animating, "la entrada debe animar desde el primer frame");
1065 assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente");
1066 // A mitad del tween, alpha intermedio.
1067 let mut m = one_enter();
1068 reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1069 let a = m.nodes[0].alpha.expect("alpha");
1070 assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
1071 // Pasada la duración: opaco exacto (None, sin capa residual) y quieto.
1072 let mut m = one_enter();
1073 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1074 assert!(!animating);
1075 assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa");
1076 }
1077
1078 /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`.
1079 fn one_exit() -> Mounted<()> {
1080 let v = View::<()>::new(Style::default())
1081 .fill(rgba(10, 20, 30))
1082 .animated_exit(7, Duration::from_millis(200));
1083 let mut layout = LayoutTree::new();
1084 mount(&mut layout, v)
1085 }
1086
1087 /// Árbol vacío de nodos animados (la key `exit` ya no aparece).
1088 fn empty() -> Mounted<()> {
1089 let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
1090 let mut layout = LayoutTree::new();
1091 mount(&mut layout, v)
1092 }
1093
1094 #[test]
1095 fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() {
1096 let mut reg = AnimRegistry::new();
1097 let t0 = Instant::now();
1098 // Frame 1: el nodo exit está presente. No anima por sí solo, y el
1099 // runtime captura su subescena (aquí una vacía de prueba).
1100 let mut m = one_exit();
1101 let animating = reg.reconcile(&mut m, t0);
1102 assert!(!animating, "presente y quieto no anima");
1103 reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1104 // Frame 2: la key desaparece → se promueve a fantasma y pide frames.
1105 let mut m = empty();
1106 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1107 assert!(animating, "un fantasma vivo mantiene el ticker");
1108 assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
1109 // Frame 3: pasada la duración el fantasma se descarta y el loop para.
1110 let mut m = empty();
1111 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300));
1112 assert!(!animating, "fantasma agotado → sin más frames");
1113 assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0));
1114 }
1115
1116 /// Monta un nodo `switch` (key=5) con la variante dada.
1117 fn one_switch(variant: u64) -> Mounted<()> {
1118 let v = View::<()>::new(Style::default())
1119 .fill(rgba(10, 20, 30))
1120 .animated_switch(5, variant, Duration::from_millis(200));
1121 let mut layout = LayoutTree::new();
1122 mount(&mut layout, v)
1123 }
1124
1125 /// **La quinta causa, y la que ningún contador decía** (2026-09-15). Un nodo
1126 /// `switch` que sale del árbol y vuelve **con otra variante** no cruza nada:
1127 /// su clave se estrenó, así que se re-asienta. Es correcto —una key que
1128 /// vuelve no debe conmutar contra la variante que tenía hace media hora— y
1129 /// es indistinguible de «no pasó nada» mirando los dos contadores viejos:
1130 /// `conmutaron=0 con-foto=0`, igual que en reposo. El tercer contador es el
1131 /// que lo nombra.
1132 #[test]
1133 fn una_clave_que_sale_y_vuelve_no_conmuta_y_el_contador_lo_dice() {
1134 let mut reg = AnimRegistry::new();
1135 let t0 = Instant::now();
1136
1137 // Frame 1: aparece con la variante 1. Estreno.
1138 let mut m = one_switch(1);
1139 reg.reconcile(&mut m, t0);
1140 assert_eq!(reg.ultimo_reconcile().0, 0, "una primera aparición no conmuta");
1141 assert_eq!(reg.ultimo_reconcile().3, 1, "…y se declara estreno");
1142
1143 // Frame 2: el nodo NO está en el árbol (el panel pintó otra cosa).
1144 let mut vacio = empty();
1145 reg.reconcile(&mut vacio, t0 + Duration::from_millis(10));
1146
1147 // Frame 3: vuelve, y con OTRA variante. Desde afuera es el mismo gesto
1148 // que un cambio de pestaña; desde adentro es un estreno.
1149 let mut m = one_switch(2);
1150 reg.reconcile(&mut m, t0 + Duration::from_millis(20));
1151 let (conmutaron, con_foto, _, estrenos) = reg.ultimo_reconcile();
1152 assert_eq!(conmutaron, 0, "no cruza: la clave venía de fuera del árbol");
1153 assert_eq!(con_foto, 0);
1154 assert_eq!(estrenos, 1, "el instrumento nombra la puerta en vez de callarse");
1155
1156 // Y el brazo de control: quedándose en el árbol, el mismo cambio SÍ cruza.
1157 let mut m = one_switch(3);
1158 reg.reconcile(&mut m, t0 + Duration::from_millis(30));
1159 let (conmutaron, _, _, estrenos) = reg.ultimo_reconcile();
1160 assert_eq!(conmutaron, 1, "sin salir del árbol, el cambio de variante cruza");
1161 assert_eq!(estrenos, 0);
1162 }
1163
1164 #[test]
1165 fn switch_de_variante_cruza_contenido() {
1166 let mut reg = AnimRegistry::new();
1167 let t0 = Instant::now();
1168 // Frame 1: variante 1, primera aparición → asienta, no cruza.
1169 let mut m = one_switch(1);
1170 assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
1171 // El runtime captura su subescena (de prueba, vacía).
1172 reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1173 // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi
1174 // transparente y hay un fantasma del contenido viejo desvaneciéndose.
1175 let mut m = one_switch(2);
1176 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1177 assert!(animating, "el cross-fade pide frames");
1178 let a = m.nodes[0].alpha.expect("alpha de fade-in");
1179 assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}");
1180 assert!(
1181 reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0),
1182 "hay un fantasma del contenido viejo"
1183 );
1184 // Re-captura del frame 2 (lo haría el runtime tras el paint).
1185 reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1186 // Frame 3: misma variante, pasada la duración → asentado y sin fantasma.
1187 let mut m = one_switch(2);
1188 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1189 assert!(!animating, "asentado tras la duración");
1190 assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual");
1191 assert!(
1192 !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0),
1193 "fantasma agotado"
1194 );
1195 }
1196
1197 #[test]
1198 fn switch_misma_variante_no_cruza() {
1199 let mut reg = AnimRegistry::new();
1200 let t0 = Instant::now();
1201 let mut m = one_switch(1);
1202 reg.reconcile(&mut m, t0);
1203 reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1204 // Misma variante en el frame siguiente: ni fade-in ni fantasma.
1205 let mut m = one_switch(1);
1206 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1207 assert!(!animating, "sin cambio de variante no cruza");
1208 assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco");
1209 assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
1210 }
1211
1212 #[test]
1213 fn reaparecer_cancela_el_fantasma() {
1214 let mut reg = AnimRegistry::new();
1215 let t0 = Instant::now();
1216 let mut m = one_exit();
1217 reg.reconcile(&mut m, t0);
1218 reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1219 // Se va → fantasma.
1220 let mut m = empty();
1221 assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1222 // Reaparece a mitad del fade → el fantasma se cancela (no hay doble).
1223 let mut m = one_exit();
1224 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1225 assert!(!animating, "al reaparecer no queda fantasma");
1226 assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0));
1227 }
1228
1229 /// Monta un nodo con un transform afín explícito + anim(key=1).
1230 fn one_xf(xf: Affine) -> Mounted<()> {
1231 let v = View::<()>::new(Style::default())
1232 .transform(xf)
1233 .animated(1, Duration::from_millis(200));
1234 let mut layout = LayoutTree::new();
1235 mount(&mut layout, v)
1236 }
1237
1238 /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0).
1239 fn one_pop_in() -> Mounted<()> {
1240 let v = View::<()>::new(Style::default())
1241 .fill(rgba(1, 2, 3))
1242 .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5));
1243 let mut layout = LayoutTree::new();
1244 mount(&mut layout, v)
1245 }
1246
1247 #[test]
1248 fn cambio_de_transform_interpola_y_pide_frames() {
1249 let mut reg = AnimRegistry::new();
1250 let t0 = Instant::now();
1251 // Frame 1: identidad → se asienta sin animar.
1252 let mut m = one_xf(Affine::IDENTITY);
1253 assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
1254 // Frame 2: la view ahora pide scale(2.0) → arranca tween.
1255 let mut m = one_xf(Affine::scale(2.0));
1256 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1257 assert!(animating, "al cambiar la xf debe pedir frames");
1258 // Frame 3: a mitad, el m00 está entre 1.0 y 2.0.
1259 let mut m = one_xf(Affine::scale(2.0));
1260 reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1261 let c = m.nodes[0].transform.expect("transform").as_coeffs();
1262 assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]);
1263 assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]);
1264 }
1265
1266 #[test]
1267 fn transform_al_terminar_llega_exacto() {
1268 let mut reg = AnimRegistry::new();
1269 let t0 = Instant::now();
1270 let mut m = one_xf(Affine::IDENTITY);
1271 reg.reconcile(&mut m, t0);
1272 let mut m = one_xf(Affine::translate((10.0, 20.0)));
1273 reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1274 // Pasada la duración: aterriza exacto en la xf objetivo.
1275 let mut m = one_xf(Affine::translate((10.0, 20.0)));
1276 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1277 assert!(!animating);
1278 let c = m.nodes[0].transform.expect("xf").as_coeffs();
1279 assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]);
1280 assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]);
1281 }
1282
1283 #[test]
1284 fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() {
1285 let mut reg = AnimRegistry::new();
1286 let t0 = Instant::now();
1287 // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La
1288 // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller)
1289 // y debe pedir frames.
1290 let mut m = one_pop_in();
1291 let animating = reg.reconcile(&mut m, t0);
1292 assert!(animating, "pop-in anima desde el primer frame");
1293 let c = m.nodes[0].transform.expect("xf inicial").as_coeffs();
1294 assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]);
1295 // Frame intermedio: el m00 ya creció hacia 1.0.
1296 let mut m = one_pop_in();
1297 reg.reconcile(&mut m, t0 + Duration::from_millis(100));
1298 let c = m.nodes[0].transform.expect("xf medio").as_coeffs();
1299 assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]);
1300 // Frame final: aterriza en None (sin xf residual), igual que alpha.
1301 let mut m = one_pop_in();
1302 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
1303 assert!(!animating, "asentado");
1304 assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse");
1305 }
1306
1307 #[test]
1308 fn cambio_de_alpha_interpola() {
1309 let mut reg = AnimRegistry::new();
1310 let t0 = Instant::now();
1311 // Frame 1: alpha 1.0, se asienta (no es `enter`).
1312 let mut m = one_alpha(1.0);
1313 let animating = reg.reconcile(&mut m, t0);
1314 assert!(!animating, "primera aparición sin enter no anima");
1315 // Frame 2: la view baja a 0.0 → arranca tween.
1316 let mut m = one_alpha(0.0);
1317 reg.reconcile(&mut m, t0 + Duration::from_millis(50));
1318 // Frame 3: a mitad, alpha intermedio.
1319 let mut m = one_alpha(0.0);
1320 reg.reconcile(&mut m, t0 + Duration::from_millis(150));
1321 let a = m.nodes[0].alpha.expect("alpha");
1322 assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
1323 }
1324
1325 #[test]
1326 fn keys_que_se_van_se_descartan() {
1327 let mut reg = AnimRegistry::new();
1328 let now = Instant::now();
1329 let mut m = one(rgba(1, 2, 3));
1330 reg.reconcile(&mut m, now);
1331 assert_eq!(reg.entries.len(), 1);
1332 // Frame sin ningún nodo animado: la entrada se descarta.
1333 let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
1334 let mut layout = LayoutTree::new();
1335 let mut m2 = mount(&mut layout, v);
1336 reg.reconcile(&mut m2, now);
1337 assert_eq!(reg.entries.len(), 0);
1338 }
1339
1340 // ─── Bloque 15: tests de SizeAnim / animateContentSize ───
1341
1342 fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> {
1343 use llimphi_layout::taffy::prelude::{length, Size};
1344 let mut style = Style::default();
1345 style.size = Size { width: length(w), height: length(h) };
1346 View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms))
1347 }
1348
1349 #[test]
1350 fn size_anim_primera_aparicion_no_anima() {
1351 let mut reg = SizeAnimRegistry::new();
1352 let mut v = sized_view(1, 100.0, 80.0, 200);
1353 let now = Instant::now();
1354 let animating = reconcile_size_anim(&mut v, &mut reg, now);
1355 assert!(!animating, "primera vez: sin animación");
1356 // El style.size queda intacto (length(100, 80)).
1357 let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1358 assert_eq!((w, h), (100.0, 80.0));
1359 }
1360
1361 #[test]
1362 fn size_anim_cambia_target_interpola() {
1363 let mut reg = SizeAnimRegistry::new();
1364 let t0 = Instant::now();
1365 // Frame 1: target = 100×80, se asienta.
1366 let mut v = sized_view(1, 100.0, 80.0, 200);
1367 reconcile_size_anim(&mut v, &mut reg, t0);
1368 // Frame 2: target nuevo = 200×160. En el frame que se detecta el
1369 // cambio arranca el reloj — todavía pinta cerca del origen.
1370 let mut v = sized_view(1, 200.0, 160.0, 200);
1371 let animating = reconcile_size_anim(&mut v, &mut reg, t0);
1372 assert!(animating, "cambio de target: pide frames");
1373 let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1374 assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}");
1375 assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}");
1376 // Frame 3: 100 ms (mitad del tween).
1377 let mut v = sized_view(1, 200.0, 160.0, 200);
1378 let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100));
1379 assert!(animating, "a mitad del tween sigue animando");
1380 let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
1381 assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}");
1382 assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}");
1383 }
1384
1385 #[test]
1386 fn size_anim_termina_y_se_detiene() {
1387 let mut reg = SizeAnimRegistry::new();
1388 let t0 = Instant::now();
1389 let mut v = sized_view(1, 100.0, 80.0, 200);
1390 reconcile_size_anim(&mut v, &mut reg, t0);
1391 let mut v = sized_view(1, 200.0, 160.0, 200);
1392 reconcile_size_anim(&mut v, &mut reg, t0); // arranca
1393 // Pasada la duración: aterriza exacto en el objetivo y no pide más.
1394 let mut v = sized_view(1, 200.0, 160.0, 200);
1395 let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400));
1396 assert!(!animating);
1397 assert_eq!(
1398 (v.style.size.width.value(), v.style.size.height.value()),
1399 (200.0, 160.0),
1400 );
1401 }
1402
1403 #[test]
1404 fn size_anim_no_animable_si_tamano_no_es_length() {
1405 // Si el caller declara percent o auto, el reconciler lo deja pasar
1406 // sin tracking — no hay valor en píxeles estable para interpolar.
1407 use llimphi_layout::taffy::prelude::{percent, Dimension, Size};
1408 let mut reg = SizeAnimRegistry::new();
1409 let mut style = Style::default();
1410 style.size = Size { width: percent(0.5), height: Dimension::auto() };
1411 let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200));
1412 let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now());
1413 assert!(!animating);
1414 // El size no se tocó: width sigue siendo percent (no LENGTH_TAG).
1415 use llimphi_layout::taffy::CompactLength;
1416 assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG);
1417 }
1418
1419 #[test]
1420 fn size_anim_descarta_keys_no_vistas() {
1421 let mut reg = SizeAnimRegistry::new();
1422 let now = Instant::now();
1423 let mut v = sized_view(42, 50.0, 50.0, 200);
1424 reconcile_size_anim(&mut v, &mut reg, now);
1425 assert_eq!(reg.entries.len(), 1);
1426 // Frame sin animated_size: la entrada se descarta.
1427 let mut v: View<()> = View::<()>::new(Style::default());
1428 reconcile_size_anim(&mut v, &mut reg, now);
1429 assert_eq!(reg.entries.len(), 0);
1430 }
1431
1432 // ── Salida direccional (exit_to_xf) — base del router de páginas ────────
1433
1434 #[test]
1435 fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() {
1436 let t0 = Instant::now();
1437 let g = Ghost {
1438 scene: Scene::new(),
1439 start: t0,
1440 duration: Duration::from_millis(200),
1441 easing: |t| t, // lineal para aserciones exactas
1442 centro: (100.0, 50.0),
1443 to_xf: Some(Affine::translate((-200.0, 0.0))),
1444 under: false,
1445 hold: false,
1446 };
1447 // t=0: afín ≈ identidad (el fantasma arranca donde estaba).
1448 let xf0 = g.xf(t0).expect("hay afín");
1449 let c0 = xf0.as_coeffs();
1450 assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}");
1451 // t=0.5: mitad del desplazamiento. Una traslación conmuta con la
1452 // conjugación por el centro, así que el coef es -100 exacto.
1453 let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva");
1454 let c = xf.as_coeffs();
1455 assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}");
1456 // Sin to_xf → None (fade puro, camino histórico).
1457 let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g };
1458 assert!(g2.xf(t0 + Duration::from_millis(100)).is_none());
1459 }
1460
1461 #[test]
1462 fn fantasma_con_scale_respira_desde_su_centro() {
1463 let t0 = Instant::now();
1464 let g = Ghost {
1465 scene: Scene::new(),
1466 start: t0,
1467 duration: Duration::from_millis(100),
1468 easing: |t| t,
1469 centro: (100.0, 100.0),
1470 to_xf: Some(Affine::scale(2.0)),
1471 under: false,
1472 hold: false,
1473 };
1474 // Al final del tween (t=1) la afín es scale(2) conjugada por el
1475 // centro: el centro queda FIJO (100,100 → 100,100).
1476 let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín");
1477 let p = xf * llimphi_text::peniko::kurbo::Point::new(100.0, 100.0);
1478 assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6,
1479 "el centro no se mueve: {p:?}");
1480 // Un punto a 50px del centro termina a 100px (escala 2).
1481 let q = xf * llimphi_text::peniko::kurbo::Point::new(150.0, 100.0);
1482 assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}");
1483 }
1484
1485 #[test]
1486 fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() {
1487 let salida = Affine::translate((-300.0, 0.0));
1488 let entrada = Affine::translate((300.0, 0.0));
1489 let one = |variant: u64| -> Mounted<()> {
1490 let v = View::<()>::new(Style::default())
1491 .fill(rgba(10, 20, 30))
1492 .animated_switch_from(5, variant, Duration::from_millis(200),
1493 Some(entrada), Some(salida));
1494 let mut layout = LayoutTree::new();
1495 mount(&mut layout, v)
1496 };
1497 let mut reg = AnimRegistry::new();
1498 let t0 = Instant::now();
1499 // Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1,
1500 // que NO es el que debe usarse al conmutar).
1501 let mut m = one(1);
1502 reg.reconcile(&mut m, t0);
1503 let mut senuelo = m.nodes[0].anim.expect("anim");
1504 senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse
1505 reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo);
1506 // Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia
1507 // `entrada` (además de transparente) y el fantasma lleva la `salida`
1508 // declarada por el frame nuevo, no el señuelo capturado.
1509 let mut m = one(2);
1510 let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
1511 assert!(animating);
1512 let xf = m.nodes[0].transform.expect("transform de entrada");
1513 let c = xf.as_coeffs();
1514 assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}");
1515 let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo");
1516 assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo");
1517 }
1518
1519 /// **La captura va con cuentagotas.** Repintar el subárbol de un nodo
1520 /// `switch` en cada frame es el costo escondido de la animación, y lo paga
1521 /// el nodo más caro de la app. Sin foto se captura ya; con una fresca, no.
1522 #[test]
1523 fn la_foto_del_nodo_vivo_no_se_refresca_en_cada_frame() {
1524 let uno = || -> Mounted<()> {
1525 let v = View::<()>::new(Style::default())
1526 .fill(rgba(10, 20, 30))
1527 .animated_switch(7, 1, Duration::from_millis(200));
1528 let mut layout = LayoutTree::new();
1529 mount(&mut layout, v)
1530 };
1531 let mut reg = AnimRegistry::new();
1532 let t0 = Instant::now();
1533 let m = uno();
1534 // Sin foto: hay que capturar sí o sí — si no, un nodo que aparece y se
1535 // va dentro de la ventana no dejaría fantasma ninguno.
1536 assert_eq!(reg.live_exit_nodes(&m, t0).len(), 1, "la primera foto no espera");
1537 let anim = m.nodes[0].anim.expect("anim");
1538 reg.store_live_exit_en(7, Scene::new(), (0.0, 0.0), &anim, t0);
1539 // Frame siguiente (16 ms): la foto está fresca, no se repinta nada.
1540 assert!(
1541 reg.live_exit_nodes(&m, t0 + Duration::from_millis(16)).is_empty(),
1542 "con foto fresca no se vuelve a pintar el subárbol"
1543 );
1544 // Pasado el plazo, sí.
1545 assert_eq!(
1546 reg.live_exit_nodes(&m, t0 + REFRESCO_LIVE).len(),
1547 1,
1548 "vencida, se refresca"
1549 );
1550 }
1551
1552 /// Y el cuentagotas **no puede** romper la promesa: una key con foto sigue
1553 /// contando como presente, así que al desaparecer deja su fantasma igual.
1554 #[test]
1555 fn la_foto_atrasada_igual_deja_fantasma_al_desaparecer() {
1556 let con = || -> Mounted<()> {
1557 let v = View::<()>::new(Style::default())
1558 .fill(rgba(10, 20, 30))
1559 .animated_exit(8, Duration::from_millis(200));
1560 let mut layout = LayoutTree::new();
1561 mount(&mut layout, v)
1562 };
1563 let sin = || -> Mounted<()> {
1564 let mut layout = LayoutTree::new();
1565 mount(&mut layout, View::<()>::new(Style::default()))
1566 };
1567 let mut reg = AnimRegistry::new();
1568 let t0 = Instant::now();
1569 let mut m = con();
1570 reg.reconcile(&mut m, t0);
1571 let anim = m.nodes[0].anim.expect("anim");
1572 reg.store_live_exit_en(8, Scene::new(), (0.0, 0.0), &anim, t0);
1573 // Pasan varios frames sin refrescar la foto, y recién ahí se va.
1574 let mut m2 = sin();
1575 reg.reconcile(&mut m2, t0 + Duration::from_millis(500));
1576 assert!(reg.ghosts.contains_key(&8), "el fantasma sale de la foto atrasada");
1577 }
1578
1579 #[test]
1580 fn hold_mantiene_el_alpha_hasta_expirar() {
1581 let t0 = Instant::now();
1582 let g = Ghost {
1583 scene: Scene::new(),
1584 start: t0,
1585 duration: Duration::from_millis(200),
1586 easing: |t| t,
1587 centro: (0.0, 0.0),
1588 to_xf: None,
1589 under: false,
1590 hold: true,
1591 };
1592 // A mitad del reloj sigue OPACO (salida sin desvanecer).
1593 assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0);
1594 // Expirado: desaparece de golpe.
1595 assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0);
1596 }
1597
1598 #[test]
1599 fn switch_estilado_entra_opaco_y_coloca_underlay() {
1600 use crate::SwitchStyle;
1601 let style = SwitchStyle {
1602 enter_from_xf: Some(Affine::translate((300.0, 0.0))),
1603 enter_fade: false,
1604 exit_to_xf: None,
1605 exit_under: true,
1606 exit_hold: true,
1607 };
1608 let one = |variant: u64| -> Mounted<()> {
1609 let v = View::<()>::new(Style::default())
1610 .fill(rgba(10, 20, 30))
1611 .animated_switch_styled(5, variant, Duration::from_millis(200), style);
1612 let mut layout = LayoutTree::new();
1613 mount(&mut layout, v)
1614 };
1615 let mut reg = AnimRegistry::new();
1616 let t0 = Instant::now();
1617 let mut m = one(1);
1618 reg.reconcile(&mut m, t0);
1619 reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
1620 // Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha
1621 // intacto — push opaco), y el fantasma queda marcado under+hold.
1622 let mut m = one(2);
1623 let t1 = t0 + Duration::from_millis(10);
1624 assert!(reg.reconcile(&mut m, t1));
1625 assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)");
1626 assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose");
1627 let g = reg.ghosts.get(&5).expect("fantasma");
1628 assert!(g.under && g.hold, "placement y hold del frame nuevo");
1629 // prepare_underlays hornea la subescena en el nodo y la marca para
1630 // que replay_ghosts no la duplique encima.
1631 reg.prepare_underlays(&mut m, t1);
1632 assert!(m.nodes[0].underlay.is_some(), "underlay colocado");
1633 assert_eq!(reg.under_placed, vec![5]);
1634 // Un fantasma under colocado no se re-pinta arriba, pero la función
1635 // sigue reportando fantasmas vivos (ticker).
1636 assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0));
1637 // Si la key deja de estar montada (nodo desapareció), el fantasma
1638 // cae al camino histórico (encima): sin underlay que colocar.
1639 let mut vacio = empty();
1640 reg.prepare_underlays(&mut vacio, t1);
1641 assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay");
1642 }
1643
1644 #[test]
1645 fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() {
1646 let to = Affine::translate((0.0, 40.0));
1647 let v = View::<()>::new(Style::default())
1648 .fill(rgba(9, 9, 9))
1649 .animated_exit_to(7, Duration::from_millis(200), to);
1650 let mut layout = LayoutTree::new();
1651 let mut m = mount(&mut layout, v);
1652 let mut reg = AnimRegistry::new();
1653 let t0 = Instant::now();
1654 reg.reconcile(&mut m, t0);
1655 // El runtime captura con la to_xf del propio nodo (camino exit).
1656 reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim"));
1657 // Desaparece → el fantasma hereda la afín capturada.
1658 let mut m = empty();
1659 assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
1660 let g = reg.ghosts.get(&7).expect("fantasma");
1661 assert_eq!(g.to_xf, Some(to));
1662 assert_eq!(g.centro, (20.0, 20.0));
1663 }
1664}