use std::collections::HashMap;
use std::time::{Duration, Instant};
use vello::kurbo::{Affine, Rect};
use vello::peniko::{Color, Fill, Mix};
use vello::Scene;
use crate::Mounted;
#[derive(Clone, Copy, Debug)]
pub struct Anim {
pub key: u64,
pub duration: Duration,
pub easing: fn(f32) -> f32,
pub enter: bool,
pub exit: bool,
pub enter_from_xf: Option<Affine>,
pub switch: Option<u64>,
}
pub fn ease_out_cubic(t: f32) -> f32 {
let u = 1.0 - t.clamp(0.0, 1.0);
1.0 - u * u * u
}
#[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 {
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
}
}
#[derive(Default)]
pub struct SizeAnimRegistry {
entries: HashMap<u64, SizeAnimEntry>,
}
impl SizeAnimRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn is_animating(&self, key: u64, now: Instant) -> bool {
self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false)
}
}
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);
}
pub fn reconcile_size_anim<Msg>(
view: &mut crate::View<Msg>,
reg: &mut SizeAnimRegistry,
now: Instant,
) -> bool {
let mut seen: Vec<u64> = 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<Msg>(
view: &mut crate::View<Msg>,
reg: &mut SizeAnimRegistry,
now: Instant,
seen: &mut Vec<u64>,
) -> bool {
let mut animating = false;
if let Some(sa) = view.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 {
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
}
#[derive(Clone, Copy, PartialEq)]
struct AnimSnapshot {
fill: Option<Color>,
radius: f64,
alpha: Option<f32>,
transform: Option<Affine>,
}
#[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
}
}
#[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 {
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,
};
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)
}
};
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,
}
}
}
struct AnimEntry {
from: AnimSnapshot,
to: AnimSnapshot,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl AnimEntry {
fn settled(snap: AnimSnapshot, now: Instant) -> Self {
Self {
from: snap,
to: snap,
start: now,
duration: Duration::ZERO,
easing: |t| t,
}
}
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
}
}
struct LiveExit {
scene: Scene,
duration: Duration,
easing: fn(f32) -> f32,
}
struct Ghost {
scene: Scene,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl Ghost {
fn alpha(&self, now: Instant) -> f32 {
if self.duration.is_zero() {
return 0.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);
1.0 - (self.easing)(raw)
}
fn done(&self, now: Instant) -> bool {
now.saturating_duration_since(self.start) >= self.duration
}
}
#[derive(Default)]
pub struct AnimRegistry {
entries: HashMap<u64, AnimEntry>,
live: HashMap<u64, LiveExit>,
ghosts: HashMap<u64, Ghost>,
variants: HashMap<u64, u64>,
}
impl AnimRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn reconcile<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) -> bool {
let mut animating = false;
let mut seen: Vec<u64> = Vec::new();
let mut present_live: Vec<u64> = Vec::new();
let mut present_exit_only: Vec<u64> = 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,
};
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);
if let Some(prev) = self.variants.insert(anim.key, variant) {
if prev != variant {
switched = true;
if let Some(le) = self.live.remove(&anim.key) {
self.ghosts.insert(
anim.key,
Ghost {
scene: le.scene,
start: now,
duration: le.duration,
easing: le.easing,
},
);
}
}
}
}
let entry = self.entries.entry(anim.key).or_insert_with(|| {
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 {
entry.from = AnimSnapshot {
alpha: Some(0.0),
..target
};
entry.to = target;
entry.start = now;
entry.duration = anim.duration;
entry.easing = anim.easing;
} else if entry.to != target {
entry.from = entry.value(now);
entry.to = target;
entry.start = now;
entry.duration = anim.duration;
entry.easing = anim.easing;
}
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));
}
if self.variants.len() != seen.len() {
self.variants.retain(|k, _| seen.contains(k));
}
let vanished: Vec<u64> = 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,
},
);
}
}
for key in &present_exit_only {
self.ghosts.remove(key);
}
self.ghosts.retain(|_, g| !g.done(now));
animating || !self.ghosts.is_empty()
}
pub fn live_exit_nodes<Msg>(&self, mounted: &Mounted<Msg>) -> Vec<(usize, usize, u64)> {
mounted
.nodes
.iter()
.enumerate()
.filter_map(|(idx, n)| {
n.anim
.filter(|a| a.exit || a.switch.is_some())
.map(|a| (idx, n.subtree_end, a.key))
})
.collect()
}
pub fn store_live_exit(
&mut self,
key: u64,
scene: Scene,
duration: Duration,
easing: fn(f32) -> f32,
) {
self.live.insert(key, LiveExit { scene, duration, easing });
}
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 g in self.ghosts.values() {
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, None);
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)
}
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();
let mut m = one(rgba(255, 0, 0));
reg.reconcile(&mut m, t0);
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");
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)); 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)));
}
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)
}
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();
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");
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}");
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");
}
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)
}
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();
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(), Duration::from_millis(200), ease_out_cubic);
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));
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));
}
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)
}
#[test]
fn switch_de_variante_cruza_contenido() {
let mut reg = AnimRegistry::new();
let t0 = Instant::now();
let mut m = one_switch(1);
assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
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"
);
reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
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(), Duration::from_millis(200), ease_out_cubic);
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(), Duration::from_millis(200), ease_out_cubic);
let mut m = empty();
assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
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));
}
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)
}
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();
let mut m = one_xf(Affine::IDENTITY);
assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
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");
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));
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();
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]);
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]);
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();
let mut m = one_alpha(1.0);
let animating = reg.reconcile(&mut m, t0);
assert!(!animating, "primera aparición sin enter no anima");
let mut m = one_alpha(0.0);
reg.reconcile(&mut m, t0 + Duration::from_millis(50));
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);
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);
}
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");
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();
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);
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}");
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); 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() {
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);
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);
let mut v: View<()> = View::<()>::new(Style::default());
reconcile_size_anim(&mut v, &mut reg, now);
assert_eq!(reg.entries.len(), 0);
}
}