//! Animaciones **implícitas** (estilo Flutter `AnimatedContainer`): un nodo //! del `View` declara una `key` estable y, cuando sus props visuales de paint //! cambian entre frames, el runtime **interpola** en vez de saltar — sin que //! la app cablee un `Tween` en su `Model` ni un loop de ticks. //! //! El modelo de Llimphi reconstruye el árbol `View` cada frame desde el //! `Model`, así que no hay estado retenido por nodo. Este registro lo aporta: //! mapea `key → AnimEntry` (valor actual + objetivo + reloj) y vive en el //! runtime entre frames. En cada redraw, DESPUÉS de `compute` y ANTES de //! `paint`, el runtime llama [`AnimRegistry::reconcile`], que: //! //! 1. Para cada nodo con [`Anim`], toma su valor objetivo (lo que la `view` //! pintó este frame). //! 2. Si el objetivo cambió respecto del guardado, arranca un tween desde el //! valor interpolado actual hacia el nuevo. //! 3. Escribe el valor interpolado de vuelta en el nodo (fill/radius) para //! que `paint` lo use. //! 4. Devuelve `true` si alguna animación sigue viva → el runtime pide otro //! frame (`request_redraw`). Cuando todas se asientan, deja de pedir frames //! (el ticker se autodetiene; no hay render loop ocioso). //! //! La **primera** aparición de una key no anima (igual que Flutter): sólo los //! **cambios** posteriores se interpolan. Props soportadas hoy: `fill` (color), //! `radius`, `alpha` (opacidad) y `transform` (afín 2D — scale/rotate/translate //! alrededor del centro del rect). Es ampliable agregando campos a //! [`AnimSnapshot`]. //! //! **Animación de contenido (entrada y salida).** Aparte de los cambios de //! props, una key puede animar su **entrada** ([`crate::View::animated_enter`]: //! la primera aparición sube la opacidad de 0 a su valor) y su **salida** //! ([`crate::View::animated_exit`]: al desaparecer del árbol). El exit no se //! puede hacer sólo modificando nodos vivos — el nodo ya no está. La solución: //! el runtime captura la **subescena vello** que el nodo `exit` pinta cada //! frame mientras vive (vía [`AnimRegistry::live_exit_nodes`] + //! [`AnimRegistry::store_live_exit`]); cuando la key desaparece, esa subescena //! retenida se promueve a **fantasma** y [`AnimRegistry::replay_ghosts`] la //! reproduce con opacidad decreciente hasta que el reloj se agota. //! //! **Cross-fade real (`AnimatedSwitcher`).** Un nodo puede declarar //! ([`crate::View::animated_switch`]) una `key` estable + una **variante** de //! contenido. Cuando la variante cambia entre frames, el runtime promueve el //! contenido anterior (retenido en `live` el frame previo) a fantasma //! (fade-out) y arranca el subárbol nuevo desde alpha 0 (fade-in), en el mismo //! rect — una transición entre dos identidades distintas reusando la misma //! infra de ghosts del `exit`, sin tener que combinar enter+exit de dos keys. // Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos. #[allow(unused_imports)] use alloc::{boxed::Box, string::{String, ToString}, vec::Vec}; use hashbrown::HashMap; use core::time::Duration; use crate::Instant; use llimphi_text::peniko::kurbo::{Affine, Rect}; use llimphi_text::peniko::{Color, Fill, Mix}; use crate::Lienzo as Scene; use crate::Mounted; /// Declara que las props visuales de paint de este nodo se animan de forma /// implícita. `key` debe ser estable entre rebuilds del `View` (índice de /// item, hash de id, etc.) — es lo que enlaza "el mismo nodo" entre frames. #[derive(Clone, Copy, Debug)] pub struct Anim { pub key: u64, pub duration: Duration, /// Easing aplicado a `t ∈ [0,1]`. Las canónicas viven en /// `llimphi_theme::motion`; por defecto el builder usa un ease-out cúbico. pub easing: fn(f32) -> f32, /// `true` si la **primera aparición** de la key debe animar la opacidad de /// 0 hacia su valor (fade-in de entrada, estilo `AnimatedSwitcher`). Las /// animaciones de props (fill/radius/alpha) no entran por aquí: sólo cambian /// el arranque del primer frame. Sin él, la primera aparición se asienta /// instantánea (default histórico de `View::animated`). pub enter: bool, /// `true` si la **salida** de la key debe animar (fade-out): cuando el nodo /// desaparece del árbol, el runtime retiene la última subescena que pintó y /// la reproduce con opacidad decreciente durante `duration`, en vez de que /// el nodo se esfume de golpe. Tiene coste por frame (captura el subárbol /// mientras vive) — usar en pocos nodos (toasts, modales, paneles), no en /// cada fila de una lista grande. pub exit: bool, /// Transformación afín desde la que arrancar la **entrada** (`enter`). Por /// ej. `Some(Affine::scale(0.6))` da el "pop" del FAB; `Some(Affine:: /// translate((0.0, 60.0)))` da slide-in vertical. Llega al target del nodo /// (`node.transform` o identidad) en `duration`. Sin efecto si `enter` es /// `false`. Combinable con el fade de entrada por defecto. pub enter_from_xf: Option, /// Discriminador de **variante de contenido** para cross-fade real /// (Flutter `AnimatedSwitcher`). Cuando es `Some(v)` y `v` **cambia** /// entre frames bajo la misma `key`, el runtime promueve la subescena del /// contenido anterior a fantasma (fade-out) y hace fade-in del nuevo, en /// el mismo rect — una transición real entre dos identidades distintas, no /// la combinación enter+exit de dos keys. Implica captura `live` por frame /// (como `exit`). La primera aparición no cruza (sólo asienta la variante). pub switch: Option, /// Transformación afín hacia la que **sale el contenido saliente** mientras /// se desvanece (fantasma): se interpola de identidad a esta afín, aplicada /// alrededor del centro del rect capturado (misma convención que /// `View::transform`). `Some(Affine::translate((-w, 0)))` = slide-out a la /// izquierda; `Some(Affine::scale(1.05))` = zoom-out. Con `exit`, describe /// cómo sale **este nodo** al desaparecer (capturada mientras vive). Con /// `switch`, describe cómo sale el **contenido viejo** al cambiar la /// variante — se lee del frame NUEVO (quien conmuta conoce la dirección: /// push vs pop). `None` = fade en el lugar (comportamiento histórico). pub exit_to_xf: Option, /// `false` = el contenido nuevo de un `switch` NO arranca transparente /// (entra opaco — necesario para un push estilo UIKit donde la página /// entrante tapa a la saliente). `true` = fade-in histórico. pub enter_fade: bool, /// `true` = el fantasma del contenido saliente se pinta **DEBAJO** del /// contenido nuevo, en la posición z del nodo (vía /// [`AnimRegistry::prepare_underlays`] + `MountedNode::underlay`), en vez /// de encima de toda la escena. Es el orden natural de una transición de /// páginas: lo nuevo cubre a lo viejo. Como `exit_to_xf`, en un `switch` /// lo declara el frame NUEVO. pub exit_under: bool, /// `true` = el fantasma NO se desvanece: mantiene alpha 1 hasta que su /// reloj expira (y ahí desaparece de golpe — para entonces el contenido /// nuevo ya lo cubre). Para salidas opacas (Cover/push UIKit). pub exit_hold: bool, } /// Estilo completo de una transición de `switch` direccional — agrupa los /// cinco knobs que [`crate::View::animated_switch_styled`] pasa al [`Anim`]. /// `Default` = cross-fade puro (idéntico a `animated_switch`). #[derive(Clone, Copy, Debug)] pub struct SwitchStyle { /// Afín desde la que entra el contenido nuevo (`None` = en el lugar). pub enter_from_xf: Option, /// `false` = el contenido nuevo entra opaco (sin fade-in). pub enter_fade: bool, /// Afín hacia la que sale el contenido viejo (`None` = en el lugar). pub exit_to_xf: Option, /// `true` = el fantasma va DEBAJO del contenido nuevo (posición z real). pub exit_under: bool, /// `true` = el fantasma no se desvanece (salida opaca). pub exit_hold: bool, } impl Default for SwitchStyle { fn default() -> Self { Self { enter_from_xf: None, enter_fade: true, exit_to_xf: None, exit_under: false, exit_hold: false, } } } /// Ease-out cúbico, el default razonable para transiciones implícitas /// (arranca rápido, frena suave). Copia local para no acoplar el compositor a /// `llimphi-theme`; el caller puede pasar cualquier `fn(f32)->f32`. pub fn ease_out_cubic(t: f32) -> f32 { let u = 1.0 - t.clamp(0.0, 1.0); 1.0 - u * u * u } /// Declara que el **tamaño** de este nodo (CSS `width`/`height` / /// Flutter `AnimatedSize`/Compose `animateContentSize()`) se anima de /// forma implícita cuando cambia entre frames. Bloque 15 de /// PARIDAD-FLUTTER (extensión faltante del Bloque 4). /// /// A diferencia de [`Anim`] (que interpola props de **paint** después /// del layout: fill/radius/alpha/transform), el tamaño tiene que estar /// fijo **antes** del layout — siblings y hijos dependen del rect del /// nodo. Por eso este registro vive aparte y el reconciler camina el /// `View` tree **antes** de `mount`, parchando `style.size` con el /// valor interpolado. /// /// **Límite v1**: sólo anima cuando `style.size.width` y /// `style.size.height` son ambas `Dimension::Length(_)`. Si una es /// `Percent`/`Auto`, el nodo se monta tal cual sin animación (no hay /// "tamaño en píxeles" estable para interpolar). El caller que quiera /// animar un nodo flex debe declarar `length(...)` explícito. #[derive(Clone, Copy, Debug)] pub struct SizeAnim { pub key: u64, pub duration: Duration, pub easing: fn(f32) -> f32, } #[derive(Clone, Copy)] struct SizeAnimEntry { from: (f32, f32), to: (f32, f32), start: Instant, duration: Duration, easing: fn(f32) -> f32, } impl SizeAnimEntry { /// Entrada "asentada" (from == to): no anima. Igual que /// `AnimEntry::settled`, usamos `duration: ZERO` para que `done(now)` /// devuelva `true` desde el frame 0 — así la primera aparición no /// pide más frames. Cuando llegue un target nuevo el reconciler /// sobreescribe `duration` con el de `SizeAnim`. fn settled(target: (f32, f32), now: Instant, _dur: Duration, easing: fn(f32) -> f32) -> Self { Self { from: target, to: target, start: now, duration: Duration::ZERO, easing, } } fn t(&self, now: Instant) -> f32 { if self.duration.is_zero() { return 1.0; } let elapsed = now.saturating_duration_since(self.start).as_secs_f32(); let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0); (self.easing)(raw) } fn value(&self, now: Instant) -> (f32, f32) { let t = self.t(now); let (fw, fh) = self.from; let (tw, th) = self.to; (fw + (tw - fw) * t, fh + (th - fh) * t) } fn done(&self, now: Instant) -> bool { now.saturating_duration_since(self.start) >= self.duration } } /// Registro de animaciones implícitas de **tamaño**, vivo entre /// frames. El runtime mantiene una instancia y llama /// [`reconcile_size_anim`] en cada redraw **antes** del mount/layout. #[derive(Default)] pub struct SizeAnimRegistry { entries: HashMap, } impl SizeAnimRegistry { pub fn new() -> Self { Self::default() } pub fn clear(&mut self) { self.entries.clear(); } /// Para tests: hay animación viva para esa key. pub fn is_animating(&self, key: u64, now: Instant) -> bool { self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false) } } /// Lee `(width, height)` en píxeles si **ambos** son /// `Dimension::Length(_)`. Devuelve `None` si alguno es `Auto`, /// `Percent`, etc. — esos nodos no se animan en v1. (taffy 0.9 esconde /// las variantes detrás de un `CompactLength`; chequeamos por tag.) fn try_extract_length_size( style: &llimphi_layout::Style, ) -> Option<(f32, f32)> { use llimphi_layout::taffy::CompactLength; let w = style.size.width; let h = style.size.height; if w.tag() == CompactLength::LENGTH_TAG && h.tag() == CompactLength::LENGTH_TAG { Some((w.value(), h.value())) } else { None } } fn patch_length_size(style: &mut llimphi_layout::Style, size: (f32, f32)) { use llimphi_layout::taffy::Dimension; style.size.width = Dimension::length(size.0); style.size.height = Dimension::length(size.1); } /// Recorre el `View` tree y, para cada nodo con [`SizeAnim`], reconcila /// su `style.size` con el registry: si cambió el objetivo, arranca un /// tween; si está animando, parcha `style.size` con el valor /// interpolado. Devuelve `true` si alguna animación de tamaño sigue /// viva → el runtime debe pedir otro redraw. /// /// **Cuándo llamarlo**: el runtime lo invoca tras `A::view(model)` y /// **antes** de `mount`/`compute`, así el layout cascade ve el tamaño /// interpolado en vez del objetivo crudo (siblings y hijos reflowean /// suave). /// /// Las keys no vistas este frame se descartan al final — un nodo que se /// va deja de animar (mismo comportamiento que [`AnimRegistry::reconcile`]). pub fn reconcile_size_anim( view: &mut crate::View, reg: &mut SizeAnimRegistry, now: Instant, ) -> bool { let mut seen: Vec = Vec::new(); let animating = reconcile_size_anim_inner(view, reg, now, &mut seen); if reg.entries.len() != seen.len() { reg.entries.retain(|k, _| seen.contains(k)); } animating } fn reconcile_size_anim_inner( view: &mut crate::View, reg: &mut SizeAnimRegistry, now: Instant, seen: &mut Vec, ) -> bool { let mut animating = false; if let Some(sa) = view.rare.as_ref().and_then(|r| r.animated_size) { if let Some(target) = try_extract_length_size(&view.style) { seen.push(sa.key); let entry = reg .entries .entry(sa.key) .or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing)); if entry.to != target { // Cambió el objetivo: congela el valor actual como nuevo // origen y rearranca el reloj — mismo patrón que el // `AnimRegistry` de props. entry.from = entry.value(now); entry.to = target; entry.start = now; entry.duration = sa.duration; entry.easing = sa.easing; } let interp = if entry.done(now) { entry.to } else { entry.value(now) }; patch_length_size(&mut view.style, interp); if !entry.done(now) { animating = true; } } } for child in view.children.iter_mut() { if reconcile_size_anim_inner(child, reg, now, seen) { animating = true; } } animating } /// Foto de las props animables de un nodo en un frame. `alpha == None` ≡ nodo /// opaco (1.0): es la convención de `View::alpha` y la usa el lerp para mezclar /// hacia/desde "sin alpha explícito" sin tratarlo como un salto. Lo mismo para /// `transform == None` ≡ identidad, así "sin transform" → "con transform" anima /// desde la identidad (estilo CSS `transform: none` → `transform: scale(1.5)`). #[derive(Clone, Copy, PartialEq)] struct AnimSnapshot { fill: Option, radius: f64, alpha: Option, transform: Option, } #[inline] fn lerp_f64(a: f64, b: f64, t: f32) -> f64 { a + (b - a) * t as f64 } #[inline] fn lerp_color(a: Color, b: Color, t: f32) -> Color { let p = a.components; let q = b.components; Color { components: [ p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t, p[3] + (q[3] - p[3]) * t, ], ..a } } /// Lerp componente-a-componente de las 6 coefs del afín (m00, m10, m01, m11, /// m02, m12). Es lo mismo que Flutter `MatrixTween`: no preserva una rotación /// pura entre matrices muy distintas, pero alcanza para las animaciones UI /// típicas (scale/translate/rotaciones chicas, slide-in, pop, hero). #[inline] fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine { let p = a.as_coeffs(); let q = b.as_coeffs(); let ft = t as f64; Affine::new([ p[0] + (q[0] - p[0]) * ft, p[1] + (q[1] - p[1]) * ft, p[2] + (q[2] - p[2]) * ft, p[3] + (q[3] - p[3]) * ft, p[4] + (q[4] - p[4]) * ft, p[5] + (q[5] - p[5]) * ft, ]) } impl AnimSnapshot { /// Interpola entre `self` (origen) y `to` (objetivo). El color sólo se /// mezcla si ambos lados tienen fill sólido; si uno es `None` (gradiente o /// sin fill) se salta al objetivo sin crossfade. fn lerp(self, to: AnimSnapshot, t: f32) -> AnimSnapshot { let fill = match (self.fill, to.fill) { (Some(a), Some(b)) => Some(lerp_color(a, b, t)), _ => to.fill, }; // `None` ≡ opaco (1.0): un lado sin alpha se mezcla contra 1.0 en vez // de saltar, así fade-in (0→opaco) y fade de un alpha explícito a/desde // "sin alpha" interpolan suave. None↔None se mantiene None (sin capa). let alpha = match (self.alpha, to.alpha) { (None, None) => None, (a, b) => { let from = a.unwrap_or(1.0); let dst = b.unwrap_or(1.0); Some(from + (dst - from) * t) } }; // `None` ≡ identidad: idem. Un lado sin transform se mezcla contra // `Affine::IDENTITY` en vez de saltar, así "sin xf" → `scale(1.5)` // arranca desde scale(1) (Flutter/CSS hacen lo mismo). None↔None se // mantiene None (sin push_layer afín en paint). let transform = match (self.transform, to.transform) { (None, None) => None, (a, b) => { let from = a.unwrap_or(Affine::IDENTITY); let dst = b.unwrap_or(Affine::IDENTITY); Some(lerp_affine(from, dst, t)) } }; AnimSnapshot { fill, radius: lerp_f64(self.radius, to.radius, t), alpha, transform, } } } /// Estado retenido de una animación: tween entre `from` y `to`. struct AnimEntry { from: AnimSnapshot, to: AnimSnapshot, start: Instant, duration: Duration, easing: fn(f32) -> f32, } impl AnimEntry { /// Entrada ya asentada en `snap` (from == to): no anima. fn settled(snap: AnimSnapshot, now: Instant) -> Self { Self { from: snap, to: snap, start: now, duration: Duration::ZERO, easing: |t| t, } } /// Progreso `[0,1]` con easing aplicado. fn t(&self, now: Instant) -> f32 { if self.duration.is_zero() { return 1.0; } let elapsed = now.saturating_duration_since(self.start).as_secs_f32(); let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0); (self.easing)(raw) } fn value(&self, now: Instant) -> AnimSnapshot { self.from.lerp(self.to, self.t(now)) } fn done(&self, now: Instant) -> bool { now.saturating_duration_since(self.start) >= self.duration } } /// Cada cuánto se **refresca** la foto de un nodo `exit`/`switch` que sigue /// vivo. /// /// Refrescarla en CADA frame es lo que hacía antes, y es carísimo donde más se /// usa: la captura repinta el subárbol entero con [`crate::paint_range`], por /// fuera del caché de repintado, así que marcar el lienzo de una app con /// `animated_switch` le duplicaba el costo de pintado **para siempre**, no /// durante la transición. Con el cuentagotas la foto queda como mucho /// `REFRESCO_LIVE` atrasada, y eso no se ve: lo único que se hace con ella es /// el fantasma que se desvanece en ~200 ms. /// /// Es el mismo reparo que ya tiene el compositor para la miniatura del cajón /// (`DrmState::recordar_cajon` se autolimita a una copia cada 500 ms). /// /// Un nodo **sin** foto se captura siempre, sin esperar: si no, uno que aparece /// y se va dentro de la misma ventana no dejaría fantasma ninguno. const REFRESCO_LIVE: Duration = Duration::from_millis(120); /// Subescena retenida de un nodo marcado para animar su salida, capturada por /// el runtime el último frame que el nodo vivió. Mientras la key sigue presente /// se refresca con cuentagotas (`REFRESCO_LIVE`); cuando desaparece, se /// promueve a [`Ghost`]. struct LiveExit { scene: Scene, /// Cuándo se tomó esta foto. La captura se **refresca con cuentagotas** /// (ver [`REFRESCO_LIVE`]), así que hace falta saber cuánto tiene. sellado: Instant, duration: Duration, easing: fn(f32) -> f32, /// Centro del rect del nodo al capturarse — origen de la afín de salida. centro: (f32, f32), /// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`). to_xf: Option, /// El fantasma va debajo del contenido nuevo (`Anim::exit_under`). under: bool, /// El fantasma no se desvanece (`Anim::exit_hold`). hold: bool, } /// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida /// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza). struct Ghost { scene: Scene, start: Instant, duration: Duration, easing: fn(f32) -> f32, centro: (f32, f32), to_xf: Option, under: bool, hold: bool, } impl Ghost { /// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con /// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca). fn alpha(&self, now: Instant) -> f32 { if self.hold { return if self.done(now) { 0.0 } else { 1.0 }; } 1.0 - self.t(now) } /// Progreso eased `0 → 1` del fantasma. fn t(&self, now: Instant) -> f32 { if self.duration.is_zero() { return 1.0; } let elapsed = now.saturating_duration_since(self.start).as_secs_f32(); let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0); (self.easing)(raw) } /// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del /// rect capturado (misma convención que `View::transform`, así un scale /// "respira" desde su centro y no desde el origen de la ventana). `None` /// si el fantasma sólo se desvanece en el lugar. fn xf(&self, now: Instant) -> Option { let to = self.to_xf?; let t = self.t(now); let local = lerp_affine(Affine::IDENTITY, to, t); let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64); Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy))) } fn done(&self, now: Instant) -> bool { now.saturating_duration_since(self.start) >= self.duration } } /// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene /// una instancia y llama [`Self::reconcile`] en cada redraw. #[derive(Default)] pub struct AnimRegistry { entries: HashMap, /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el /// runtime tras el paint de cada frame). Membresía = "presente el frame /// anterior". live: HashMap, /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`) /// que se están desvaneciendo. ghosts: HashMap, /// Última variante vista por cada key con `switch` — para detectar el /// cambio de contenido que dispara el cross-fade. variants: HashMap, /// Keys colocadas como underlay ESTE frame (por /// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no /// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`. under_placed: Vec, /// **Instrumento.** Cuántos nodos `switch` conmutaron de variante en el /// último [`Self::reconcile`], y de ésos cuántos tenían foto retenida para /// dejar fantasma. Dos contadores de un `u16` cada uno, sin asignaciones: /// existen porque un `animated_switch` que no se ve tiene **cuatro** causas /// que en el píxel son la misma (el nodo no se marcó, la variante no cambió, /// no había foto, o la afín es la identidad) y ninguna se deduce mirando. /// Los lee el host por [`Self::ultimo_reconcile`]. switches_ultimo: u16, switches_con_foto_ultimo: u16, /// **La quinta causa, y la única muda** (2026-09-15). Cuántos nodos `switch` /// aparecieron este frame con la clave **estrenada** — o sea que no estaba en /// `variants` la vuelta anterior, porque el nodo no existía en el árbol. /// /// Esa primera aparición **re-asienta sin animar** a propósito (una key que /// vuelve no debe conmutar contra la variante que tenía hace media hora), /// pero desde afuera se ve idéntica a «la variante no cambió»: los dos /// contadores dan cero y el píxel no se mueve. Si el nodo sale y vuelve a /// entrar al árbol en el MISMO gesto que cambia la variante, no anima nunca /// y ningún contador lo dice. Éste lo dice. switches_estreno_ultimo: u16, } impl AnimRegistry { pub fn new() -> Self { Self::default() } /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las /// keys que no aparecieron este frame se descartan (un nodo que se va deja /// de animar). Devuelve `true` si alguna animación sigue en curso. /// /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes /// controlados). /// **Instrumento**: `(nodos marcados con switch que conmutaron, de ésos /// cuántos tenían foto, fantasmas vivos, claves estrenadas)` en el último /// [`Self::reconcile`]. Lo consume el diag del host — ver el `diag!` de /// `pata-llimphi::dibujar_panel`. El cuarto es el que habla cuando los dos /// primeros callan: ver [`Self::switches_estreno_ultimo`]. pub fn ultimo_reconcile(&self) -> (u16, u16, usize, u16) { ( self.switches_ultimo, self.switches_con_foto_ultimo, self.ghosts.len(), self.switches_estreno_ultimo, ) } pub fn reconcile(&mut self, mounted: &mut Mounted, now: Instant) -> bool { let mut animating = false; self.switches_ultimo = 0; self.switches_con_foto_ultimo = 0; self.switches_estreno_ultimo = 0; let mut seen: Vec = Vec::new(); // Keys presentes que requieren captura `live` y tracking de vanish // (exit O switch). Membresía = "vive este frame". let mut present_live: Vec = Vec::new(); // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de // `switch` están presentes todos los frames y su ghost (contenido // viejo) NO debe cancelarse por presencia. let mut present_exit_only: Vec = Vec::new(); for node in &mut mounted.nodes { let Some(anim) = node.anim else { continue }; seen.push(anim.key); let target = AnimSnapshot { fill: node.fill, radius: node.radius, alpha: node.alpha, transform: node.transform, }; // Detección de cross-fade (switch) ANTES de tomar prestado // `entries`: si la variante cambió, el contenido viejo retenido en // `live` (del frame anterior) se promueve a fantasma (fade-out) y // el nodo nuevo arranca su fade-in desde alpha 0. let mut switched = false; if anim.exit { present_live.push(anim.key); present_exit_only.push(anim.key); } else if let Some(variant) = anim.switch { present_live.push(anim.key); let anterior = self.variants.insert(anim.key, variant); if anterior.is_none() { // Clave estrenada: el nodo no estaba en el árbol la vuelta // pasada. Se re-asienta sin animar, y eso no se distingue de // «la variante no cambió» mirando los otros dos contadores. self.switches_estreno_ultimo = self.switches_estreno_ultimo.saturating_add(1); } if let Some(prev) = anterior { if prev != variant { switched = true; self.switches_ultimo = self.switches_ultimo.saturating_add(1); if let Some(le) = self.live.remove(&anim.key) { self.switches_con_foto_ultimo = self.switches_con_foto_ultimo.saturating_add(1); // La afín/placement/hold de salida del contenido // VIEJO los declara el frame nuevo (`anim.exit_*`): // sólo quien conmuta sabe hacia dónde debe irse lo // anterior (push ≠ pop). El centro sí es el capturado. self.ghosts.insert( anim.key, Ghost { scene: le.scene, start: now, duration: le.duration, easing: le.easing, centro: le.centro, to_xf: anim.exit_to_xf, under: anim.exit_under, hold: anim.exit_hold, }, ); } } } } let entry = self.entries.entry(anim.key).or_insert_with(|| { // Primera aparición. Con `enter`, arranca un tween de opacidad // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también // arranca de esa transform → target.transform (scale-in/slide-in). if anim.enter { let from = AnimSnapshot { alpha: Some(0.0), transform: anim.enter_from_xf.or(target.transform), ..target }; AnimEntry { from, to: target, start: now, duration: anim.duration, easing: anim.easing, } } else { AnimEntry::settled(target, now) } }); if switched { // Cross-fade: el contenido nuevo entra desde transparente // (el viejo ya quedó como fantasma desvaneciéndose). Con // `enter_fade: false` entra OPACO (push estilo UIKit: la // página nueva tapa, no se funde). Si además declara // `enter_from_xf`, entra desde esa afín (slide/zoom). entry.from = AnimSnapshot { alpha: if anim.enter_fade { Some(0.0) } else { target.alpha }, transform: anim.enter_from_xf.or(target.transform), ..target }; entry.to = target; entry.start = now; entry.duration = anim.duration; entry.easing = anim.easing; } else if entry.to != target { // Cambió el objetivo: congela el valor actual como nuevo origen // y rearranca el reloj hacia el objetivo nuevo. entry.from = entry.value(now); entry.to = target; entry.start = now; entry.duration = anim.duration; entry.easing = anim.easing; } // Al terminar aterriza EXACTO en el objetivo (incluido `alpha: // None` / `transform: None`, que evita capa de opacidad residual o // un push_layer afín espurio frame a frame). let v = if entry.done(now) { entry.to } else { entry.value(now) }; node.fill = v.fill; node.radius = v.radius; node.alpha = v.alpha; node.transform = v.transform; if !entry.done(now) { animating = true; } } if self.entries.len() != seen.len() { self.entries.retain(|k, _| seen.contains(k)); } // Las variantes de keys que ya no aparecen se descartan (si la key // vuelve, su primera aparición re-asienta sin cross-fade). if self.variants.len() != seen.len() { self.variants.retain(|k, _| seen.contains(k)); } // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior // (vive en `live`) que ya no aparece → se promueve a fantasma con su // última subescena retenida. Si una key `exit` con fantasma reaparece, // se cancela el fade (no las de `switch`: su fantasma es contenido viejo // que debe seguir desvaneciéndose aunque la key siga presente). Por // último, descartamos los fantasmas cuyo reloj se agotó. let vanished: Vec = self .live .keys() .filter(|k| !present_live.contains(k)) .copied() .collect(); for key in vanished { if let Some(le) = self.live.remove(&key) { self.ghosts.insert( key, Ghost { scene: le.scene, start: now, duration: le.duration, easing: le.easing, centro: le.centro, to_xf: le.to_xf, under: le.under, hold: le.hold, }, ); } } for key in &present_exit_only { self.ghosts.remove(key); } self.ghosts.retain(|_, g| !g.done(now)); animating || !self.ghosts.is_empty() } /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en /// una subescena con [`crate::paint_range`] y entregarla a /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y /// la geometría ya están firmes). /// /// **No devuelve todos los nodos marcados: sólo los que toca refrescar.** /// Ver `REFRESCO_LIVE` — repintar el subárbol de cada uno en cada frame /// era el precio oculto de `animated_switch`, y lo paga quien lo pone sobre /// el nodo más caro de su app, que es justo para lo que sirve. pub fn live_exit_nodes( &self, mounted: &Mounted, now: Instant, ) -> Vec<(usize, usize, u64)> { mounted .nodes .iter() .enumerate() .filter_map(|(idx, n)| { n.anim .filter(|a| a.exit || a.switch.is_some()) .filter(|a| self.toca_refrescar(a.key, now)) .map(|a| (idx, n.subtree_end, a.key)) }) .collect() } /// ¿La foto de `key` está vencida (o no existe)? fn toca_refrescar(&self, key: u64, now: Instant) -> bool { match self.live.get(&key) { None => true, Some(l) => now.saturating_duration_since(l.sellado) >= REFRESCO_LIVE, } } /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El /// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim` /// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/ /// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en /// un `switch` la salida la re-declara el frame nuevo). `centro` es el /// centro del rect del nodo (origen de la afín de salida). /// /// **Sólo existe con `std`**, porque lee el reloj. Sin sistema operativo no /// hay reloj global al que preguntarle la hora —lo dice el encabezado del /// crate— y el runtime ya trae el instante del fotograma: ahí va /// [`Self::store_live_exit_en`]. La ausencia de `Instant::now()` está puesta /// para que un uso equivocado sea un error de compilación en vez de una /// animación congelada; **este atajo se le había escapado**, y el error salía /// sólo en un perfil que ningún check del árbol compilaba. #[cfg(feature = "std")] pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) { self.store_live_exit_en(key, scene, centro, anim, Instant::now()) } /// Como [`Self::store_live_exit`] con el reloj explícito — lo usa el runtime /// (que ya tiene el `now` del frame) y los tests. pub fn store_live_exit_en( &mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim, now: Instant, ) { self.live.insert( key, LiveExit { scene, sellado: now, duration: anim.duration, easing: anim.easing, centro, to_xf: anim.exit_to_xf, under: anim.exit_under, hold: anim.exit_hold, }, ); } /// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la /// subescena (ya envuelta en su capa de alpha + afín de salida) de cada /// fantasma `under` cuyo `switch` sigue montado — el paint la apendea /// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro /// del clip/transform de sus ancestros). Las keys colocadas se saltean en /// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`] /// y ANTES del paint. pub fn prepare_underlays(&mut self, mounted: &mut Mounted, now: Instant) { self.under_placed.clear(); if self.ghosts.is_empty() { return; } for node in &mut mounted.nodes { let Some(anim) = node.anim else { continue }; if anim.switch.is_none() { continue; } let Some(g) = self.ghosts.get(&anim.key) else { continue }; if !g.under { continue; } let a = g.alpha(now); if a <= 0.0 { continue; } // Envoltorio: capa de alpha (rect generoso — el clip real lo // ponen los ancestros del nodo en el paint) + la afín de salida. let mut wrap = Scene::new(); let big = Rect::new(-1e6, -1e6, 1e6, 1e6); wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big); wrap.append(&g.scene, g.xf(now)); wrap.pop_layer(); node.underlay = Some(wrap); self.under_placed.push(anim.key); } } /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo). /// **Olvidá lo que estaba saliendo**: tira los fantasmas vivos y las fotos /// retenidas de los nodos marcados para animar su salida. /// /// Lo pide el host cuando lo que se estaba desvaneciendo **dejó de existir** /// de un modo que la animación no puede saber. El caso que lo trajo: el /// cajón de shuma se guarda con un enrollado propio (un clip que lo deja /// invisible en 115 ms) y su lienzo, que está marcado con `animated_switch` /// para paginar entre sesiones, desaparece del árbol. Acá eso se lee como /// una salida y se pinta la foto retenida —**el cajón abierto y entero**, /// hasta 120 ms vieja— desvaneciéndose 160 ms más, sin el clip que lo estaba /// enrollando y sobre una surface que todavía mide la pantalla completa. En /// el píxel eso es el cajón volviendo un instante después de guardarlo, y el /// operador lo reportó como *«guarda y luego parpadea para guardarse otra /// vez»* (medido cuadro a cuadro el 2026-09-17). /// /// No es un caso que la animación pueda detectar sola: desde acá, un nodo /// que se va es un nodo que se va. Quien sabe que el cajón se guardó —y que /// por lo tanto no hay nada que despedir— es el host. pub fn clear_exits(&mut self) { self.ghosts.clear(); self.live.clear(); } pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool { if self.ghosts.is_empty() { return false; } let clip = Rect::new(0.0, 0.0, w as f64, h as f64); for (key, g) in &self.ghosts { // Los colocados como underlay este frame ya se pintaron en su // posición z (dentro del árbol) — no duplicarlos encima. if self.under_placed.contains(key) { continue; } let a = g.alpha(now); if a <= 0.0 { continue; } scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip); scene.append(&g.scene, g.xf(now)); scene.pop_layer(); } true } } #[cfg(test)] mod tests { use super::*; use crate::{mount, View}; use llimphi_layout::{LayoutTree, Style}; fn rgba(r: u8, g: u8, b: u8) -> Color { Color::from_rgba8(r, g, b, 255) } /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`. fn one(fill: Color) -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(fill) .animated(1, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } #[test] fn primera_aparicion_no_anima() { let mut reg = AnimRegistry::new(); let mut m = one(rgba(255, 0, 0)); let now = Instant::now(); let animating = reg.reconcile(&mut m, now); assert!(!animating, "la primera vez no debe animar"); assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0))); } #[test] fn cambio_de_color_interpola_y_pide_frames() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: rojo, se asienta. let mut m = one(rgba(255, 0, 0)); reg.reconcile(&mut m, t0); // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo) // pero ya pide frames. let mut m = one(rgba(0, 0, 255)); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100)); assert!(animating, "al detectar el cambio debe pedir frames"); // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado: // ni rojo puro ni azul puro. let mut m = one(rgba(0, 0, 255)); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200)); assert!(animating, "a mitad del tween debe seguir animando"); let c = m.nodes[0].fill.expect("fill").components; assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]); assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]); } #[test] fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = one(rgba(255, 0, 0)); reg.reconcile(&mut m, t0); let mut m = one(rgba(0, 0, 255)); reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca // Pasada la duración, llega exacto al objetivo y no pide más frames. let mut m = one(rgba(0, 0, 255)); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); assert!(!animating); assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255))); } /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`. fn one_alpha(alpha: f32) -> Mounted<()> { let v = View::<()>::new(Style::default()) .alpha(alpha) .animated(1, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } /// Monta un nodo opaco (sin alpha) con animación de ENTRADA. fn one_enter() -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_enter(1, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } #[test] fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Primera aparición de un nodo `enter`: a diferencia de `animated`, // SÍ anima — arranca casi transparente y pide frames. let mut m = one_enter(); let animating = reg.reconcile(&mut m, t0); assert!(animating, "la entrada debe animar desde el primer frame"); assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente"); // A mitad del tween, alpha intermedio. let mut m = one_enter(); reg.reconcile(&mut m, t0 + Duration::from_millis(100)); let a = m.nodes[0].alpha.expect("alpha"); assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}"); // Pasada la duración: opaco exacto (None, sin capa residual) y quieto. let mut m = one_enter(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); assert!(!animating); assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa"); } /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`. fn one_exit() -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_exit(7, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } /// Árbol vacío de nodos animados (la key `exit` ya no aparece). fn empty() -> Mounted<()> { let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } #[test] fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: el nodo exit está presente. No anima por sí solo, y el // runtime captura su subescena (aquí una vacía de prueba). let mut m = one_exit(); let animating = reg.reconcile(&mut m, t0); assert!(!animating, "presente y quieto no anima"); reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 2: la key desaparece → se promueve a fantasma y pide frames. let mut m = empty(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); assert!(animating, "un fantasma vivo mantiene el ticker"); assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0)); // Frame 3: pasada la duración el fantasma se descarta y el loop para. let mut m = empty(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300)); assert!(!animating, "fantasma agotado → sin más frames"); assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0)); } /// Monta un nodo `switch` (key=5) con la variante dada. fn one_switch(variant: u64) -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_switch(5, variant, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } /// **La quinta causa, y la que ningún contador decía** (2026-09-15). Un nodo /// `switch` que sale del árbol y vuelve **con otra variante** no cruza nada: /// su clave se estrenó, así que se re-asienta. Es correcto —una key que /// vuelve no debe conmutar contra la variante que tenía hace media hora— y /// es indistinguible de «no pasó nada» mirando los dos contadores viejos: /// `conmutaron=0 con-foto=0`, igual que en reposo. El tercer contador es el /// que lo nombra. #[test] fn una_clave_que_sale_y_vuelve_no_conmuta_y_el_contador_lo_dice() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: aparece con la variante 1. Estreno. let mut m = one_switch(1); reg.reconcile(&mut m, t0); assert_eq!(reg.ultimo_reconcile().0, 0, "una primera aparición no conmuta"); assert_eq!(reg.ultimo_reconcile().3, 1, "…y se declara estreno"); // Frame 2: el nodo NO está en el árbol (el panel pintó otra cosa). let mut vacio = empty(); reg.reconcile(&mut vacio, t0 + Duration::from_millis(10)); // Frame 3: vuelve, y con OTRA variante. Desde afuera es el mismo gesto // que un cambio de pestaña; desde adentro es un estreno. let mut m = one_switch(2); reg.reconcile(&mut m, t0 + Duration::from_millis(20)); let (conmutaron, con_foto, _, estrenos) = reg.ultimo_reconcile(); assert_eq!(conmutaron, 0, "no cruza: la clave venía de fuera del árbol"); assert_eq!(con_foto, 0); assert_eq!(estrenos, 1, "el instrumento nombra la puerta en vez de callarse"); // Y el brazo de control: quedándose en el árbol, el mismo cambio SÍ cruza. let mut m = one_switch(3); reg.reconcile(&mut m, t0 + Duration::from_millis(30)); let (conmutaron, _, _, estrenos) = reg.ultimo_reconcile(); assert_eq!(conmutaron, 1, "sin salir del árbol, el cambio de variante cruza"); assert_eq!(estrenos, 0); } #[test] fn switch_de_variante_cruza_contenido() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: variante 1, primera aparición → asienta, no cruza. let mut m = one_switch(1); assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza"); // El runtime captura su subescena (de prueba, vacía). reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi // transparente y hay un fantasma del contenido viejo desvaneciéndose. let mut m = one_switch(2); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); assert!(animating, "el cross-fade pide frames"); let a = m.nodes[0].alpha.expect("alpha de fade-in"); assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}"); assert!( reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0), "hay un fantasma del contenido viejo" ); // Re-captura del frame 2 (lo haría el runtime tras el paint). reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 3: misma variante, pasada la duración → asentado y sin fantasma. let mut m = one_switch(2); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); assert!(!animating, "asentado tras la duración"); assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual"); assert!( !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0), "fantasma agotado" ); } #[test] fn switch_misma_variante_no_cruza() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = one_switch(1); reg.reconcile(&mut m, t0); reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Misma variante en el frame siguiente: ni fade-in ni fantasma. let mut m = one_switch(1); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); assert!(!animating, "sin cambio de variante no cruza"); assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco"); assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0)); } #[test] fn reaparecer_cancela_el_fantasma() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = one_exit(); reg.reconcile(&mut m, t0); reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Se va → fantasma. let mut m = empty(); assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10))); // Reaparece a mitad del fade → el fantasma se cancela (no hay doble). let mut m = one_exit(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100)); assert!(!animating, "al reaparecer no queda fantasma"); assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0)); } /// Monta un nodo con un transform afín explícito + anim(key=1). fn one_xf(xf: Affine) -> Mounted<()> { let v = View::<()>::new(Style::default()) .transform(xf) .animated(1, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0). fn one_pop_in() -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(1, 2, 3)) .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5)); let mut layout = LayoutTree::new(); mount(&mut layout, v) } #[test] fn cambio_de_transform_interpola_y_pide_frames() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: identidad → se asienta sin animar. let mut m = one_xf(Affine::IDENTITY); assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima"); // Frame 2: la view ahora pide scale(2.0) → arranca tween. let mut m = one_xf(Affine::scale(2.0)); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50)); assert!(animating, "al cambiar la xf debe pedir frames"); // Frame 3: a mitad, el m00 está entre 1.0 y 2.0. let mut m = one_xf(Affine::scale(2.0)); reg.reconcile(&mut m, t0 + Duration::from_millis(150)); let c = m.nodes[0].transform.expect("transform").as_coeffs(); assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]); assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]); } #[test] fn transform_al_terminar_llega_exacto() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = one_xf(Affine::IDENTITY); reg.reconcile(&mut m, t0); let mut m = one_xf(Affine::translate((10.0, 20.0))); reg.reconcile(&mut m, t0 + Duration::from_millis(50)); // Pasada la duración: aterriza exacto en la xf objetivo. let mut m = one_xf(Affine::translate((10.0, 20.0))); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); assert!(!animating); let c = m.nodes[0].transform.expect("xf").as_coeffs(); assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]); assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]); } #[test] fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller) // y debe pedir frames. let mut m = one_pop_in(); let animating = reg.reconcile(&mut m, t0); assert!(animating, "pop-in anima desde el primer frame"); let c = m.nodes[0].transform.expect("xf inicial").as_coeffs(); assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]); // Frame intermedio: el m00 ya creció hacia 1.0. let mut m = one_pop_in(); reg.reconcile(&mut m, t0 + Duration::from_millis(100)); let c = m.nodes[0].transform.expect("xf medio").as_coeffs(); assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]); // Frame final: aterriza en None (sin xf residual), igual que alpha. let mut m = one_pop_in(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); assert!(!animating, "asentado"); assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse"); } #[test] fn cambio_de_alpha_interpola() { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: alpha 1.0, se asienta (no es `enter`). let mut m = one_alpha(1.0); let animating = reg.reconcile(&mut m, t0); assert!(!animating, "primera aparición sin enter no anima"); // Frame 2: la view baja a 0.0 → arranca tween. let mut m = one_alpha(0.0); reg.reconcile(&mut m, t0 + Duration::from_millis(50)); // Frame 3: a mitad, alpha intermedio. let mut m = one_alpha(0.0); reg.reconcile(&mut m, t0 + Duration::from_millis(150)); let a = m.nodes[0].alpha.expect("alpha"); assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}"); } #[test] fn keys_que_se_van_se_descartan() { let mut reg = AnimRegistry::new(); let now = Instant::now(); let mut m = one(rgba(1, 2, 3)); reg.reconcile(&mut m, now); assert_eq!(reg.entries.len(), 1); // Frame sin ningún nodo animado: la entrada se descarta. let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9)); let mut layout = LayoutTree::new(); let mut m2 = mount(&mut layout, v); reg.reconcile(&mut m2, now); assert_eq!(reg.entries.len(), 0); } // ─── Bloque 15: tests de SizeAnim / animateContentSize ─── fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> { use llimphi_layout::taffy::prelude::{length, Size}; let mut style = Style::default(); style.size = Size { width: length(w), height: length(h) }; View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms)) } #[test] fn size_anim_primera_aparicion_no_anima() { let mut reg = SizeAnimRegistry::new(); let mut v = sized_view(1, 100.0, 80.0, 200); let now = Instant::now(); let animating = reconcile_size_anim(&mut v, &mut reg, now); assert!(!animating, "primera vez: sin animación"); // El style.size queda intacto (length(100, 80)). let (w, h) = (v.style.size.width.value(), v.style.size.height.value()); assert_eq!((w, h), (100.0, 80.0)); } #[test] fn size_anim_cambia_target_interpola() { let mut reg = SizeAnimRegistry::new(); let t0 = Instant::now(); // Frame 1: target = 100×80, se asienta. let mut v = sized_view(1, 100.0, 80.0, 200); reconcile_size_anim(&mut v, &mut reg, t0); // Frame 2: target nuevo = 200×160. En el frame que se detecta el // cambio arranca el reloj — todavía pinta cerca del origen. let mut v = sized_view(1, 200.0, 160.0, 200); let animating = reconcile_size_anim(&mut v, &mut reg, t0); assert!(animating, "cambio de target: pide frames"); let (w, h) = (v.style.size.width.value(), v.style.size.height.value()); assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}"); assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}"); // Frame 3: 100 ms (mitad del tween). let mut v = sized_view(1, 200.0, 160.0, 200); let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100)); assert!(animating, "a mitad del tween sigue animando"); let (w, h) = (v.style.size.width.value(), v.style.size.height.value()); assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}"); assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}"); } #[test] fn size_anim_termina_y_se_detiene() { let mut reg = SizeAnimRegistry::new(); let t0 = Instant::now(); let mut v = sized_view(1, 100.0, 80.0, 200); reconcile_size_anim(&mut v, &mut reg, t0); let mut v = sized_view(1, 200.0, 160.0, 200); reconcile_size_anim(&mut v, &mut reg, t0); // arranca // Pasada la duración: aterriza exacto en el objetivo y no pide más. let mut v = sized_view(1, 200.0, 160.0, 200); let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400)); assert!(!animating); assert_eq!( (v.style.size.width.value(), v.style.size.height.value()), (200.0, 160.0), ); } #[test] fn size_anim_no_animable_si_tamano_no_es_length() { // Si el caller declara percent o auto, el reconciler lo deja pasar // sin tracking — no hay valor en píxeles estable para interpolar. use llimphi_layout::taffy::prelude::{percent, Dimension, Size}; let mut reg = SizeAnimRegistry::new(); let mut style = Style::default(); style.size = Size { width: percent(0.5), height: Dimension::auto() }; let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200)); let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now()); assert!(!animating); // El size no se tocó: width sigue siendo percent (no LENGTH_TAG). use llimphi_layout::taffy::CompactLength; assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG); } #[test] fn size_anim_descarta_keys_no_vistas() { let mut reg = SizeAnimRegistry::new(); let now = Instant::now(); let mut v = sized_view(42, 50.0, 50.0, 200); reconcile_size_anim(&mut v, &mut reg, now); assert_eq!(reg.entries.len(), 1); // Frame sin animated_size: la entrada se descarta. let mut v: View<()> = View::<()>::new(Style::default()); reconcile_size_anim(&mut v, &mut reg, now); assert_eq!(reg.entries.len(), 0); } // ── Salida direccional (exit_to_xf) — base del router de páginas ──────── #[test] fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() { let t0 = Instant::now(); let g = Ghost { scene: Scene::new(), start: t0, duration: Duration::from_millis(200), easing: |t| t, // lineal para aserciones exactas centro: (100.0, 50.0), to_xf: Some(Affine::translate((-200.0, 0.0))), under: false, hold: false, }; // t=0: afín ≈ identidad (el fantasma arranca donde estaba). let xf0 = g.xf(t0).expect("hay afín"); let c0 = xf0.as_coeffs(); assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}"); // t=0.5: mitad del desplazamiento. Una traslación conmuta con la // conjugación por el centro, así que el coef es -100 exacto. let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva"); let c = xf.as_coeffs(); assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}"); // Sin to_xf → None (fade puro, camino histórico). let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g }; assert!(g2.xf(t0 + Duration::from_millis(100)).is_none()); } #[test] fn fantasma_con_scale_respira_desde_su_centro() { let t0 = Instant::now(); let g = Ghost { scene: Scene::new(), start: t0, duration: Duration::from_millis(100), easing: |t| t, centro: (100.0, 100.0), to_xf: Some(Affine::scale(2.0)), under: false, hold: false, }; // Al final del tween (t=1) la afín es scale(2) conjugada por el // centro: el centro queda FIJO (100,100 → 100,100). let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín"); let p = xf * llimphi_text::peniko::kurbo::Point::new(100.0, 100.0); assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6, "el centro no se mueve: {p:?}"); // Un punto a 50px del centro termina a 100px (escala 2). let q = xf * llimphi_text::peniko::kurbo::Point::new(150.0, 100.0); assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}"); } #[test] fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() { let salida = Affine::translate((-300.0, 0.0)); let entrada = Affine::translate((300.0, 0.0)); let one = |variant: u64| -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_switch_from(5, variant, Duration::from_millis(200), Some(entrada), Some(salida)); let mut layout = LayoutTree::new(); mount(&mut layout, v) }; let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1, // que NO es el que debe usarse al conmutar). let mut m = one(1); reg.reconcile(&mut m, t0); let mut senuelo = m.nodes[0].anim.expect("anim"); senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo); // Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia // `entrada` (además de transparente) y el fantasma lleva la `salida` // declarada por el frame nuevo, no el señuelo capturado. let mut m = one(2); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); assert!(animating); let xf = m.nodes[0].transform.expect("transform de entrada"); let c = xf.as_coeffs(); assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}"); let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo"); assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo"); } /// **La captura va con cuentagotas.** Repintar el subárbol de un nodo /// `switch` en cada frame es el costo escondido de la animación, y lo paga /// el nodo más caro de la app. Sin foto se captura ya; con una fresca, no. #[test] fn la_foto_del_nodo_vivo_no_se_refresca_en_cada_frame() { let uno = || -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_switch(7, 1, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) }; let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let m = uno(); // Sin foto: hay que capturar sí o sí — si no, un nodo que aparece y se // va dentro de la ventana no dejaría fantasma ninguno. assert_eq!(reg.live_exit_nodes(&m, t0).len(), 1, "la primera foto no espera"); let anim = m.nodes[0].anim.expect("anim"); reg.store_live_exit_en(7, Scene::new(), (0.0, 0.0), &anim, t0); // Frame siguiente (16 ms): la foto está fresca, no se repinta nada. assert!( reg.live_exit_nodes(&m, t0 + Duration::from_millis(16)).is_empty(), "con foto fresca no se vuelve a pintar el subárbol" ); // Pasado el plazo, sí. assert_eq!( reg.live_exit_nodes(&m, t0 + REFRESCO_LIVE).len(), 1, "vencida, se refresca" ); } /// Y el cuentagotas **no puede** romper la promesa: una key con foto sigue /// contando como presente, así que al desaparecer deja su fantasma igual. #[test] fn la_foto_atrasada_igual_deja_fantasma_al_desaparecer() { let con = || -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_exit(8, Duration::from_millis(200)); let mut layout = LayoutTree::new(); mount(&mut layout, v) }; let sin = || -> Mounted<()> { let mut layout = LayoutTree::new(); mount(&mut layout, View::<()>::new(Style::default())) }; let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = con(); reg.reconcile(&mut m, t0); let anim = m.nodes[0].anim.expect("anim"); reg.store_live_exit_en(8, Scene::new(), (0.0, 0.0), &anim, t0); // Pasan varios frames sin refrescar la foto, y recién ahí se va. let mut m2 = sin(); reg.reconcile(&mut m2, t0 + Duration::from_millis(500)); assert!(reg.ghosts.contains_key(&8), "el fantasma sale de la foto atrasada"); } #[test] fn hold_mantiene_el_alpha_hasta_expirar() { let t0 = Instant::now(); let g = Ghost { scene: Scene::new(), start: t0, duration: Duration::from_millis(200), easing: |t| t, centro: (0.0, 0.0), to_xf: None, under: false, hold: true, }; // A mitad del reloj sigue OPACO (salida sin desvanecer). assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0); // Expirado: desaparece de golpe. assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0); } #[test] fn switch_estilado_entra_opaco_y_coloca_underlay() { use crate::SwitchStyle; let style = SwitchStyle { enter_from_xf: Some(Affine::translate((300.0, 0.0))), enter_fade: false, exit_to_xf: None, exit_under: true, exit_hold: true, }; let one = |variant: u64| -> Mounted<()> { let v = View::<()>::new(Style::default()) .fill(rgba(10, 20, 30)) .animated_switch_styled(5, variant, Duration::from_millis(200), style); let mut layout = LayoutTree::new(); mount(&mut layout, v) }; let mut reg = AnimRegistry::new(); let t0 = Instant::now(); let mut m = one(1); reg.reconcile(&mut m, t0); reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha // intacto — push opaco), y el fantasma queda marcado under+hold. let mut m = one(2); let t1 = t0 + Duration::from_millis(10); assert!(reg.reconcile(&mut m, t1)); assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)"); assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose"); let g = reg.ghosts.get(&5).expect("fantasma"); assert!(g.under && g.hold, "placement y hold del frame nuevo"); // prepare_underlays hornea la subescena en el nodo y la marca para // que replay_ghosts no la duplique encima. reg.prepare_underlays(&mut m, t1); assert!(m.nodes[0].underlay.is_some(), "underlay colocado"); assert_eq!(reg.under_placed, vec![5]); // Un fantasma under colocado no se re-pinta arriba, pero la función // sigue reportando fantasmas vivos (ticker). assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0)); // Si la key deja de estar montada (nodo desapareció), el fantasma // cae al camino histórico (encima): sin underlay que colocar. let mut vacio = empty(); reg.prepare_underlays(&mut vacio, t1); assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay"); } #[test] fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() { let to = Affine::translate((0.0, 40.0)); let v = View::<()>::new(Style::default()) .fill(rgba(9, 9, 9)) .animated_exit_to(7, Duration::from_millis(200), to); let mut layout = LayoutTree::new(); let mut m = mount(&mut layout, v); let mut reg = AnimRegistry::new(); let t0 = Instant::now(); reg.reconcile(&mut m, t0); // El runtime captura con la to_xf del propio nodo (camino exit). reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim")); // Desaparece → el fantasma hereda la afín capturada. let mut m = empty(); assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10))); let g = reg.ghosts.get(&7).expect("fantasma"); assert_eq!(g.to_xf, Some(to)); assert_eq!(g.centro, (20.0, 20.0)); } }