Skip to main content

orinium_browser/engine/animation/
runtime.rs

1//! Animation runtime state and sampling logic.
2
3use std::collections::HashMap;
4
5use crate::engine::animation::easing::{apply_easing, interpolate_value};
6use crate::engine::animation::types::{
7    AnimationDirection, AnimationFillMode, AnimationPlayState, AnimationSpec, Easing,
8    IterationCount, KeyframesRule, TransitionSpec,
9};
10use crate::engine::css::values::CssValue;
11use crate::engine::layouter::css_resolver::KeyframesRegistry;
12use crate::engine::layouter::dom_snapshot::NodeId;
13use crate::engine::layouter::types::ColorScheme;
14
15/// Base computed values and resolved animation specs for one element.
16#[derive(Debug, Clone, Default)]
17pub struct ElementAnimationState {
18    pub transitions: Vec<TransitionSpec>,
19    pub animations: Vec<AnimationSpec>,
20    /// Current base (non-animated) values for animatable properties.
21    pub base_values: HashMap<String, CssValue>,
22}
23
24pub type AnimationInputs = HashMap<NodeId, ElementAnimationState>;
25
26#[derive(Debug, Clone)]
27struct TransitionInstance {
28    _property: String,
29    from: CssValue,
30    to: CssValue,
31    start_ms: f64,
32    duration_ms: f64,
33    delay_ms: f64,
34    easing: Easing,
35}
36
37#[derive(Debug, Clone)]
38struct AnimationInstance {
39    spec: AnimationSpec,
40    start_ms: f64,
41    /// Paused 時の進捗(0.0〜1.0)。None の場合は start_ms から経過時間で計算。
42    paused_progress: Option<f32>,
43}
44
45#[derive(Debug, Clone, Default)]
46struct ElementRuntimeState {
47    transitions: HashMap<String, TransitionInstance>,
48    animations: Vec<AnimationInstance>,
49    prev_base: HashMap<String, CssValue>,
50    prev_animation_specs: Vec<AnimationSpec>,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct AnimationRuntime {
55    elements: HashMap<NodeId, ElementRuntimeState>,
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct SampleResult {
60    pub overrides: HashMap<NodeId, HashMap<String, CssValue>>,
61    pub active: bool,
62    pub dirty: bool,
63}
64
65impl AnimationRuntime {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    pub fn clear(&mut self) {
71        *self = Self::default();
72    }
73
74    pub fn sample(
75        &mut self,
76        now_ms: f64,
77        visible: bool,
78        inputs: &AnimationInputs,
79        keyframes: &KeyframesRegistry,
80        color_scheme: ColorScheme,
81    ) -> SampleResult {
82        if !visible {
83            // Pause timelines: keep runtime state but report idle/no overrides.
84            // Avoid continuous redraw while hidden.
85            return SampleResult::default();
86        }
87
88        let mut overrides: HashMap<NodeId, HashMap<String, CssValue>> = HashMap::new();
89        let mut active = false;
90
91        // Remove elements no longer present.
92        self.elements.retain(|id, _| inputs.contains_key(id));
93
94        for (node_id, input) in inputs {
95            let state = self.elements.entry(*node_id).or_default();
96            let mut node_overrides: HashMap<String, CssValue> = HashMap::new();
97            let first_seen = state.prev_base.is_empty()
98                && state.transitions.is_empty()
99                && state.animations.is_empty()
100                && state.prev_animation_specs.is_empty();
101
102            // ---- Transitions ----
103            for spec in &input.transitions {
104                if spec.duration_ms <= 0.0 && spec.delay_ms <= 0.0 {
105                    continue;
106                }
107                let raw_prop = spec.property.as_str();
108                if raw_prop == "none" {
109                    continue;
110                }
111
112                let props: Vec<String> = if raw_prop == "all" {
113                    let mut keys: Vec<String> = input.base_values.keys().cloned().collect();
114                    keys.sort();
115                    keys
116                } else {
117                    vec![raw_prop.to_string()]
118                };
119
120                for prop in props {
121                    let Some(to) = input.base_values.get(&prop) else {
122                        continue;
123                    };
124                    let prev = state
125                        .prev_base
126                        .get(&prop)
127                        .or_else(|| state.transitions.get(&prop).map(|t| &t.to));
128                    if let Some(from) = prev {
129                        // Avoid starting a transition on the very first observation
130                        // of an element; establish a baseline instead.
131                        if !first_seen && from != to {
132                            let from_val = state
133                                .transitions
134                                .get(&prop)
135                                .and_then(|inst| {
136                                    sample_transition_value(inst, now_ms, color_scheme)
137                                })
138                                .unwrap_or_else(|| from.clone());
139                            state.transitions.insert(
140                                prop.clone(),
141                                TransitionInstance {
142                                    _property: prop.clone(),
143                                    from: from_val,
144                                    to: to.clone(),
145                                    start_ms: now_ms,
146                                    duration_ms: spec.duration_ms.max(0.0),
147                                    delay_ms: spec.delay_ms,
148                                    easing: spec.easing,
149                                },
150                            );
151                            active = true;
152                        }
153                    }
154                }
155            }
156
157            let mut finished_transitions = Vec::new();
158            for (prop, inst) in &state.transitions {
159                match sample_transition_value(inst, now_ms, color_scheme) {
160                    Some(val) => {
161                        // active while within duration window (including delay)
162                        let end = inst.start_ms + inst.delay_ms + inst.duration_ms.max(0.0);
163                        if now_ms < end {
164                            active = true;
165                        }
166                        // if finished (at end), still emit final value this frame? let caller settle
167                        if now_ms >= end {
168                            finished_transitions.push(prop.clone());
169                        } else {
170                            node_overrides.insert(prop.clone(), val);
171                        }
172                    }
173                    None => {
174                        // in delay phase
175                        let end = inst.start_ms + inst.delay_ms + inst.duration_ms.max(0.0);
176                        if now_ms < end {
177                            active = true;
178                        }
179                    }
180                }
181            }
182            for prop in finished_transitions {
183                state.transitions.remove(&prop);
184            }
185
186            // ---- Animations ----
187            if state.prev_animation_specs != input.animations {
188                state.animations = input
189                    .animations
190                    .iter()
191                    .filter(|spec| spec.name != "none" && !spec.name.is_empty())
192                    .map(|spec| AnimationInstance {
193                        spec: spec.clone(),
194                        start_ms: now_ms,
195                        paused_progress: None,
196                    })
197                    .collect();
198                state.prev_animation_specs.clone_from(&input.animations);
199            }
200
201            for inst in state.animations.iter_mut() {
202                let Some(rule) = keyframes.get(&inst.spec.name) else {
203                    continue;
204                };
205                if let Some((anim_active, anim_vals)) =
206                    sample_animation(inst, rule, now_ms, color_scheme)
207                {
208                    if anim_active {
209                        active = true;
210                    }
211                    for (prop, val) in anim_vals {
212                        // animations override transitions/base for same property in this MVP
213                        node_overrides.insert(prop, val);
214                    }
215                }
216            }
217
218            if !node_overrides.is_empty() {
219                overrides.insert(*node_id, node_overrides);
220            }
221
222            state.prev_base.clone_from(&input.base_values);
223        }
224
225        // Mark dirty when there are overrides or any active timelines; this is
226        // conservative but ensures animated frames relayout/repaint.
227        let dirty = active || !overrides.is_empty();
228
229        SampleResult {
230            overrides,
231            active,
232            dirty,
233        }
234    }
235}
236
237fn sample_transition_value(
238    inst: &TransitionInstance,
239    now_ms: f64,
240    color_scheme: ColorScheme,
241) -> Option<CssValue> {
242    let start = inst.start_ms + inst.delay_ms;
243    if now_ms < start {
244        return None;
245    }
246    let dur = inst.duration_ms;
247    if dur <= 0.0 {
248        return Some(inst.to.clone());
249    }
250    let t = ((now_ms - start) / dur).clamp(0.0, 1.0) as f32;
251    if t >= 1.0 {
252        return Some(inst.to.clone());
253    }
254    let eased = apply_easing(t, inst.easing);
255    interpolate_value(&inst.from, &inst.to, eased, color_scheme).or_else(|| {
256        if eased >= 0.5 {
257            Some(inst.to.clone())
258        } else {
259            Some(inst.from.clone())
260        }
261    })
262}
263
264fn sample_animation(
265    inst: &mut AnimationInstance,
266    rule: &KeyframesRule,
267    now_ms: f64,
268    color_scheme: ColorScheme,
269) -> Option<(bool, HashMap<String, CssValue>)> {
270    let spec = &inst.spec;
271    if spec.play_state == AnimationPlayState::Paused {
272        // Paused 状態では進捗を固定。初回は現在時刻から進捗を計算し保存、
273        // 2回目以降は保存済みの値を使って時間経過を無視する。
274        let progress = match inst.paused_progress {
275            Some(p) => p,
276            None => {
277                let duration = spec.duration_ms.max(0.0);
278                let delay = spec.delay_ms;
279                let local = now_ms - inst.start_ms;
280                if local < delay {
281                    // ディレイ中はまだ開始していない
282                    if matches!(
283                        spec.fill_mode,
284                        AnimationFillMode::Backwards | AnimationFillMode::Both
285                    ) {
286                        let p = iteration_progress(0.0, spec.direction, 0);
287                        let vals =
288                            sample_keyframes(rule, p, spec.direction, 0, spec.easing, color_scheme);
289                        return Some((false, vals));
290                    }
291                    return Some((true, HashMap::new()));
292                }
293                let active_time = local - delay;
294                let iter_progress_total = active_time / duration;
295                let within = match spec.iteration_count {
296                    IterationCount::Infinite => {
297                        (iter_progress_total - iter_progress_total.floor()) as f32
298                    }
299                    IterationCount::Finite(count) => {
300                        if count <= 0.0 {
301                            0.0
302                        } else if iter_progress_total >= count as f64 {
303                            // 完了時は最終イテレーションの進捗
304                            if count.fract() > 0.0 {
305                                count.fract()
306                            } else {
307                                1.0
308                            }
309                        } else {
310                            (iter_progress_total - iter_progress_total.floor()) as f32
311                        }
312                    }
313                };
314                inst.paused_progress = Some(within);
315                within
316            }
317        };
318        let vals = sample_keyframes(rule, progress, spec.direction, 0, spec.easing, color_scheme);
319        return Some((false, vals));
320    }
321
322    // 通常の実行時は paused_progress をリセット
323    inst.paused_progress = None;
324
325    let duration = spec.duration_ms.max(0.0);
326    let delay = spec.delay_ms;
327    let local = now_ms - inst.start_ms;
328
329    if duration <= 0.0 {
330        // Duration 0: apply fill/endpoint as appropriate
331        if local < delay {
332            if matches!(
333                spec.fill_mode,
334                AnimationFillMode::Backwards | AnimationFillMode::Both
335            ) {
336                let vals =
337                    sample_keyframes(rule, 0.0, spec.direction, 0, spec.easing, color_scheme);
338                return Some((false, vals));
339            }
340            return None;
341        } else {
342            if matches!(
343                spec.fill_mode,
344                AnimationFillMode::Forwards | AnimationFillMode::Both
345            ) {
346                let vals =
347                    sample_keyframes(rule, 1.0, spec.direction, 0, spec.easing, color_scheme);
348                return Some((false, vals));
349            }
350            return None;
351        }
352    }
353
354    if local < delay {
355        if matches!(
356            spec.fill_mode,
357            AnimationFillMode::Backwards | AnimationFillMode::Both
358        ) {
359            // progress at start of first iteration considering direction
360            let p = iteration_progress(0.0, spec.direction, 0);
361            let vals = sample_keyframes(rule, p, spec.direction, 0, spec.easing, color_scheme);
362            return Some((false, vals));
363        }
364        return Some((true, HashMap::new())); // waiting in delay
365    }
366
367    let active_time = local - delay;
368    let iter_progress_total = active_time / duration;
369
370    let (iteration_index, within, finished) = match spec.iteration_count {
371        IterationCount::Infinite => {
372            let idx = iter_progress_total.floor() as u32;
373            (idx, (iter_progress_total - idx as f64) as f32, false)
374        }
375        IterationCount::Finite(count) => {
376            if count <= 0.0 {
377                (0u32, 0.0f32, true)
378            } else if iter_progress_total >= count as f64 {
379                // 完了時は正しい最終進捗を保持(整数なら 1.0、小数ならその余り)
380                let within = if count.fract() > 0.0 {
381                    count.fract()
382                } else {
383                    1.0f32
384                };
385                (count.floor() as u32, within, true)
386            } else {
387                let idx = iter_progress_total.floor() as u32;
388                (idx, (iter_progress_total - idx as f64) as f32, false)
389            }
390        }
391    };
392
393    if finished {
394        if matches!(
395            spec.fill_mode,
396            AnimationFillMode::Forwards | AnimationFillMode::Both
397        ) {
398            // final iteration progress
399            let last_iter = match spec.iteration_count {
400                IterationCount::Finite(c) if c > 0.0 => (c.ceil() as u32).saturating_sub(1),
401                _ => 0,
402            };
403            let p = iteration_progress(1.0, spec.direction, last_iter);
404            let vals = sample_keyframes(
405                rule,
406                p,
407                spec.direction,
408                last_iter,
409                spec.easing,
410                color_scheme,
411            );
412            return Some((false, vals));
413        }
414        return None;
415    }
416
417    if spec.play_state == AnimationPlayState::Paused {
418        // MVP: treat as not actively progressing; no time accumulation model yet.
419        let p = iteration_progress(0.0, spec.direction, iteration_index);
420        let vals = sample_keyframes(
421            rule,
422            p,
423            spec.direction,
424            iteration_index,
425            spec.easing,
426            color_scheme,
427        );
428        return Some((false, vals));
429    }
430
431    let directed = iteration_progress(within.clamp(0.0, 1.0), spec.direction, iteration_index);
432    let vals = sample_keyframes(
433        rule,
434        directed,
435        spec.direction,
436        iteration_index,
437        spec.easing,
438        color_scheme,
439    );
440    Some((true, vals))
441}
442
443fn iteration_progress(t: f32, direction: AnimationDirection, iteration: u32) -> f32 {
444    let t = t.clamp(0.0, 1.0);
445    match direction {
446        AnimationDirection::Normal => t,
447        AnimationDirection::Reverse => 1.0 - t,
448        AnimationDirection::Alternate => {
449            if iteration % 2 == 0 {
450                t
451            } else {
452                1.0 - t
453            }
454        }
455        AnimationDirection::AlternateReverse => {
456            if iteration % 2 == 0 {
457                1.0 - t
458            } else {
459                t
460            }
461        }
462    }
463}
464
465fn sample_keyframes(
466    rule: &KeyframesRule,
467    progress: f32,
468    _direction: AnimationDirection,
469    _iteration: u32,
470    easing: Easing,
471    color_scheme: ColorScheme,
472) -> HashMap<String, CssValue> {
473    let mut stops = rule.stops.clone();
474    if stops.is_empty() {
475        return HashMap::new();
476    }
477    stops.sort_by(|a, b| {
478        a.offset
479            .partial_cmp(&b.offset)
480            .unwrap_or(std::cmp::Ordering::Equal)
481    });
482
483    // Collect properties
484    let mut props: Vec<String> = Vec::new();
485    for stop in &stops {
486        for (name, _) in &stop.declarations {
487            if !props.iter().any(|p| p == name) {
488                props.push(name.clone());
489            }
490        }
491    }
492
493    let mut out = HashMap::new();
494    let p = progress.clamp(0.0, 1.0);
495    for prop in props {
496        // Gather stops that define this property, sorted by offset
497        let prop_stops: Vec<(f32, CssValue)> = stops
498            .iter()
499            .filter_map(|s| {
500                s.declarations
501                    .iter()
502                    .find(|(n, _)| n == &prop)
503                    .map(|(_, v)| (s.offset, v.clone()))
504            })
505            .collect();
506        if prop_stops.is_empty() {
507            continue;
508        }
509        if prop_stops.len() == 1 {
510            out.insert(prop, prop_stops[0].1.clone());
511            continue;
512        }
513        // find segment
514        if p <= prop_stops[0].0 {
515            out.insert(prop, prop_stops[0].1.clone());
516            continue;
517        }
518        if p >= prop_stops[prop_stops.len() - 1].0 {
519            out.insert(prop, prop_stops[prop_stops.len() - 1].1.clone());
520            continue;
521        }
522        let mut chosen = None;
523        for w in prop_stops.windows(2) {
524            let (o0, v0) = &w[0];
525            let (o1, v1) = &w[1];
526            if p >= *o0 && p <= *o1 {
527                let span = (*o1 - *o0).max(f32::EPSILON);
528                let local = ((p - *o0) / span).clamp(0.0, 1.0);
529                let eased = apply_easing(local, easing); // keyframe-level easing not modeled in MVP
530                let val = interpolate_value(v0, v1, eased, color_scheme)
531                    .unwrap_or_else(|| if eased >= 0.5 { v1.clone() } else { v0.clone() });
532                chosen = Some(val);
533                break;
534            }
535        }
536        if let Some(val) = chosen.or_else(|| Some(prop_stops[0].1.clone())) {
537            out.insert(prop, val);
538        }
539    }
540
541    out
542}
543
544#[cfg(test)]
545mod tests {
546    use super::{AnimationInputs, AnimationRuntime, ElementAnimationState};
547
548    use std::collections::HashMap;
549
550    use crate::engine::animation::types::{
551        AnimationDirection, AnimationFillMode, AnimationPlayState, AnimationSpec, Easing,
552        IterationCount, KeyframeStop, KeyframesRule, TransitionSpec,
553    };
554    use crate::engine::css::values::{CssValue, Unit};
555    use crate::engine::layouter::css_resolver::KeyframesRegistry;
556    use crate::engine::layouter::dom_snapshot::NodeId;
557    use crate::engine::layouter::types::ColorScheme;
558
559    fn inputs_with_opacity(node: NodeId, opacity: f32, transition: bool) -> AnimationInputs {
560        let mut base = HashMap::new();
561        base.insert("opacity".to_string(), CssValue::Number(opacity));
562        let transitions = if transition {
563            vec![TransitionSpec {
564                property: "opacity".to_string(),
565                duration_ms: 100.0,
566                delay_ms: 0.0,
567                easing: Easing::Linear,
568            }]
569        } else {
570            vec![]
571        };
572        let mut map = HashMap::new();
573        map.insert(
574            node,
575            ElementAnimationState {
576                transitions,
577                animations: vec![],
578                base_values: base,
579            },
580        );
581        map
582    }
583
584    #[test]
585    fn transition_starts_on_value_change_and_interpolates() {
586        let mut rt = AnimationRuntime::new();
587        let node = 1u32;
588        let kf = KeyframesRegistry::new();
589        let _ = rt.sample(
590            0.0,
591            true,
592            &inputs_with_opacity(node, 0.0, true),
593            &kf,
594            ColorScheme::Light,
595        );
596        let res = rt.sample(
597            50.0,
598            true,
599            &inputs_with_opacity(node, 1.0, true),
600            &kf,
601            ColorScheme::Light,
602        );
603        assert!(res.active || res.dirty);
604        if let Some(CssValue::Number(v)) = res.overrides.get(&node).and_then(|m| m.get("opacity")) {
605            assert!(*v >= 0.0 && *v <= 1.0);
606        }
607    }
608
609    #[test]
610    fn hidden_sampling_is_idle() {
611        let mut rt = AnimationRuntime::new();
612        let node = 1u32;
613        let kf = KeyframesRegistry::new();
614        let _ = rt.sample(
615            0.0,
616            true,
617            &inputs_with_opacity(node, 0.0, true),
618            &kf,
619            ColorScheme::Light,
620        );
621        let res = rt.sample(
622            50.0,
623            false,
624            &inputs_with_opacity(node, 1.0, true),
625            &kf,
626            ColorScheme::Light,
627        );
628        assert!(!res.active);
629        assert!(res.overrides.is_empty());
630    }
631
632    #[test]
633    fn keyframes_animation_produces_interpolated_values() {
634        let mut rt = AnimationRuntime::new();
635        let node = 2u32;
636        let mut kf = KeyframesRegistry::new();
637        kf.insert(
638            "fade".to_string(),
639            KeyframesRule {
640                name: "fade".to_string(),
641                stops: vec![
642                    KeyframeStop {
643                        offset: 0.0,
644                        declarations: vec![("opacity".into(), CssValue::Number(0.0))],
645                    },
646                    KeyframeStop {
647                        offset: 1.0,
648                        declarations: vec![("opacity".into(), CssValue::Number(1.0))],
649                    },
650                ],
651            },
652        );
653        let mut map = AnimationInputs::new();
654        map.insert(
655            node,
656            ElementAnimationState {
657                transitions: vec![],
658                animations: vec![AnimationSpec {
659                    name: "fade".into(),
660                    duration_ms: 100.0,
661                    delay_ms: 0.0,
662                    easing: Easing::Linear,
663                    iteration_count: IterationCount::Finite(1.0),
664                    direction: AnimationDirection::Normal,
665                    fill_mode: AnimationFillMode::None,
666                    play_state: AnimationPlayState::Running,
667                }],
668                base_values: HashMap::new(),
669            },
670        );
671        let r0 = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
672        assert!(r0.active);
673        let r1 = rt.sample(50.0, true, &map, &kf, ColorScheme::Light);
674        let v = r1
675            .overrides
676            .get(&node)
677            .and_then(|m| m.get("opacity"))
678            .cloned();
679        match v {
680            Some(CssValue::Number(n)) => assert!(n > 0.0 && n < 1.0, "got {n}"),
681            other => panic!("unexpected {other:?}"),
682        }
683    }
684
685    #[test]
686    fn animation_fill_forwards_holds_end_value() {
687        let mut rt = AnimationRuntime::new();
688        let node = 3u32;
689        let mut kf = KeyframesRegistry::new();
690        kf.insert(
691            "fade".to_string(),
692            KeyframesRule {
693                name: "fade".to_string(),
694                stops: vec![
695                    KeyframeStop {
696                        offset: 0.0,
697                        declarations: vec![("opacity".into(), CssValue::Number(0.0))],
698                    },
699                    KeyframeStop {
700                        offset: 1.0,
701                        declarations: vec![("opacity".into(), CssValue::Number(1.0))],
702                    },
703                ],
704            },
705        );
706        let mut map = AnimationInputs::new();
707        map.insert(
708            node,
709            ElementAnimationState {
710                transitions: vec![],
711                animations: vec![AnimationSpec {
712                    name: "fade".into(),
713                    duration_ms: 100.0,
714                    delay_ms: 0.0,
715                    easing: Easing::Linear,
716                    iteration_count: IterationCount::Finite(1.0),
717                    direction: AnimationDirection::Normal,
718                    fill_mode: AnimationFillMode::Forwards,
719                    play_state: AnimationPlayState::Running,
720                }],
721                base_values: HashMap::new(),
722            },
723        );
724        let _ = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
725        let r_end = rt.sample(150.0, true, &map, &kf, ColorScheme::Light);
726        let v = r_end
727            .overrides
728            .get(&node)
729            .and_then(|m| m.get("opacity"))
730            .cloned();
731        assert_eq!(v, Some(CssValue::Number(1.0)));
732    }
733
734    #[test]
735    fn transition_property_all_animates_tracked_base_changes() {
736        let mut rt = AnimationRuntime::new();
737        let node = 4u32;
738        let kf = KeyframesRegistry::new();
739        let mut base0 = HashMap::new();
740        base0.insert("opacity".to_string(), CssValue::Number(0.0));
741        let mut map0 = AnimationInputs::new();
742        map0.insert(
743            node,
744            ElementAnimationState {
745                transitions: vec![TransitionSpec {
746                    property: "all".to_string(),
747                    duration_ms: 100.0,
748                    delay_ms: 0.0,
749                    easing: Easing::Linear,
750                }],
751                animations: vec![],
752                base_values: base0,
753            },
754        );
755        let _ = rt.sample(0.0, true, &map0, &kf, ColorScheme::Light);
756
757        let mut base1 = HashMap::new();
758        base1.insert("opacity".to_string(), CssValue::Number(1.0));
759        let mut map1 = AnimationInputs::new();
760        map1.insert(
761            node,
762            ElementAnimationState {
763                transitions: vec![TransitionSpec {
764                    property: "all".to_string(),
765                    duration_ms: 100.0,
766                    delay_ms: 0.0,
767                    easing: Easing::Linear,
768                }],
769                animations: vec![],
770                base_values: base1,
771            },
772        );
773        // Change detected at 50ms => transition starts at 50ms; sample mid-way.
774        let _ = rt.sample(50.0, true, &map1, &kf, ColorScheme::Light);
775        let res = rt.sample(100.0, true, &map1, &kf, ColorScheme::Light);
776        assert!(res.active || res.dirty);
777        if let Some(CssValue::Number(v)) = res.overrides.get(&node).and_then(|m| m.get("opacity")) {
778            assert!(*v > 0.0 && *v < 1.0, "expected mid transition, got {v}");
779        } else {
780            panic!("expected opacity override");
781        }
782    }
783
784    #[test]
785    fn keyframes_animation_interpolates_transform_rotate() {
786        let mut rt = AnimationRuntime::new();
787        let node = 6u32;
788        let mut kf = KeyframesRegistry::new();
789        kf.insert(
790            "spin".to_string(),
791            KeyframesRule {
792                name: "spin".to_string(),
793                stops: vec![
794                    KeyframeStop {
795                        offset: 0.0,
796                        declarations: vec![(
797                            "transform".into(),
798                            CssValue::Function(
799                                "rotate".into(),
800                                vec![vec![CssValue::Length(0.0, Unit::Deg)]],
801                            ),
802                        )],
803                    },
804                    KeyframeStop {
805                        offset: 1.0,
806                        declarations: vec![(
807                            "transform".into(),
808                            CssValue::Function(
809                                "rotate".into(),
810                                vec![vec![CssValue::Length(360.0, Unit::Deg)]],
811                            ),
812                        )],
813                    },
814                ],
815            },
816        );
817        let mut map = AnimationInputs::new();
818        map.insert(
819            node,
820            ElementAnimationState {
821                transitions: vec![],
822                animations: vec![AnimationSpec {
823                    name: "spin".into(),
824                    duration_ms: 100.0,
825                    delay_ms: 0.0,
826                    easing: Easing::Linear,
827                    iteration_count: IterationCount::Finite(1.0),
828                    direction: AnimationDirection::Normal,
829                    fill_mode: AnimationFillMode::None,
830                    play_state: AnimationPlayState::Running,
831                }],
832                base_values: HashMap::new(),
833            },
834        );
835        let _ = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
836        let r_mid = rt.sample(50.0, true, &map, &kf, ColorScheme::Light);
837        let v = r_mid
838            .overrides
839            .get(&node)
840            .and_then(|m| m.get("transform"))
841            .cloned();
842        match v {
843            Some(CssValue::Function(name, args)) if name == "rotate" => {
844                let CssValue::Length(deg, Unit::Deg) = args[0][0] else {
845                    panic!("expected Length deg, got {args:?}")
846                };
847                assert!((deg - 180.0).abs() < 1e-4, "expected 180deg, got {deg}");
848            }
849            other => panic!("unexpected {other:?}"),
850        }
851    }
852
853    #[test]
854    fn keyframes_animation_interpolates_transform_translate() {
855        let mut rt = AnimationRuntime::new();
856        let node = 7u32;
857        let mut kf = KeyframesRegistry::new();
858        kf.insert(
859            "slide".to_string(),
860            KeyframesRule {
861                name: "slide".to_string(),
862                stops: vec![
863                    KeyframeStop {
864                        offset: 0.0,
865                        declarations: vec![(
866                            "transform".into(),
867                            CssValue::Function(
868                                "translate".into(),
869                                vec![vec![
870                                    CssValue::Length(0.0, Unit::Px),
871                                    CssValue::Length(0.0, Unit::Px),
872                                ]],
873                            ),
874                        )],
875                    },
876                    KeyframeStop {
877                        offset: 1.0,
878                        declarations: vec![(
879                            "transform".into(),
880                            CssValue::Function(
881                                "translate".into(),
882                                vec![vec![
883                                    CssValue::Length(200.0, Unit::Px),
884                                    CssValue::Length(100.0, Unit::Px),
885                                ]],
886                            ),
887                        )],
888                    },
889                ],
890            },
891        );
892        let mut map = AnimationInputs::new();
893        map.insert(
894            node,
895            ElementAnimationState {
896                transitions: vec![],
897                animations: vec![AnimationSpec {
898                    name: "slide".into(),
899                    duration_ms: 100.0,
900                    delay_ms: 0.0,
901                    easing: Easing::Linear,
902                    iteration_count: IterationCount::Finite(1.0),
903                    direction: AnimationDirection::Normal,
904                    fill_mode: AnimationFillMode::None,
905                    play_state: AnimationPlayState::Running,
906                }],
907                base_values: HashMap::new(),
908            },
909        );
910        let _ = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
911        let r_mid = rt.sample(50.0, true, &map, &kf, ColorScheme::Light);
912        let v = r_mid
913            .overrides
914            .get(&node)
915            .and_then(|m| m.get("transform"))
916            .cloned();
917        match v {
918            Some(CssValue::Function(name, args)) if name == "translate" => {
919                assert_eq!(args.len(), 1);
920                let CssValue::Length(x, Unit::Px) = args[0][0] else {
921                    panic!("expected Length px, got {args:?}")
922                };
923                let CssValue::Length(y, Unit::Px) = args[0][1] else {
924                    panic!("expected Length px, got {args:?}")
925                };
926                assert!((x - 100.0).abs() < 1e-4, "expected 100px, got {x}");
927                assert!((y - 50.0).abs() < 1e-4, "expected 50px, got {y}");
928            }
929            other => panic!("unexpected {other:?}"),
930        }
931    }
932
933    #[test]
934    fn animation_alternate_reverses_second_iteration() {
935        let mut rt = AnimationRuntime::new();
936        let node = 5u32;
937        let mut kf = KeyframesRegistry::new();
938        kf.insert(
939            "fade".to_string(),
940            KeyframesRule {
941                name: "fade".to_string(),
942                stops: vec![
943                    KeyframeStop {
944                        offset: 0.0,
945                        declarations: vec![("opacity".into(), CssValue::Number(0.0))],
946                    },
947                    KeyframeStop {
948                        offset: 1.0,
949                        declarations: vec![("opacity".into(), CssValue::Number(1.0))],
950                    },
951                ],
952            },
953        );
954        let mut map = AnimationInputs::new();
955        map.insert(
956            node,
957            ElementAnimationState {
958                transitions: vec![],
959                animations: vec![AnimationSpec {
960                    name: "fade".into(),
961                    duration_ms: 100.0,
962                    delay_ms: 0.0,
963                    easing: Easing::Linear,
964                    iteration_count: IterationCount::Finite(2.0),
965                    direction: AnimationDirection::Alternate,
966                    fill_mode: AnimationFillMode::None,
967                    play_state: AnimationPlayState::Running,
968                }],
969                base_values: HashMap::new(),
970            },
971        );
972        let _ = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
973        // mid first iteration -> increasing
974        let r1 = rt.sample(50.0, true, &map, &kf, ColorScheme::Light);
975        // mid second iteration (reversed) -> decreasing
976        let r2 = rt.sample(150.0, true, &map, &kf, ColorScheme::Light);
977        let v1 = match r1.overrides.get(&node).and_then(|m| m.get("opacity")) {
978            Some(CssValue::Number(v)) => *v,
979            other => panic!("unexpected {other:?}"),
980        };
981        let v2 = match r2.overrides.get(&node).and_then(|m| m.get("opacity")) {
982            Some(CssValue::Number(v)) => *v,
983            other => panic!("unexpected {other:?}"),
984        };
985        assert!(v1 > 0.3 && v1 < 0.7, "v1={v1}");
986        assert!(v2 > 0.3 && v2 < 0.7, "v2={v2}");
987        // alternate means iteration 2 goes 1->0, so at midpoint value ~0.5 but direction reversed; values should be similar magnitude
988    }
989
990    #[test]
991    fn keyframes_animation_honors_timing_function() {
992        let mut rt = AnimationRuntime::new();
993        let node = 8u32;
994        let mut kf = KeyframesRegistry::new();
995        kf.insert(
996            "fade".to_string(),
997            KeyframesRule {
998                name: "fade".to_string(),
999                stops: vec![
1000                    KeyframeStop {
1001                        offset: 0.0,
1002                        declarations: vec![("opacity".into(), CssValue::Number(0.0))],
1003                    },
1004                    KeyframeStop {
1005                        offset: 1.0,
1006                        declarations: vec![("opacity".into(), CssValue::Number(1.0))],
1007                    },
1008                ],
1009            },
1010        );
1011        let mut map = AnimationInputs::new();
1012        map.insert(
1013            node,
1014            ElementAnimationState {
1015                transitions: vec![],
1016                animations: vec![AnimationSpec {
1017                    name: "fade".into(),
1018                    duration_ms: 100.0,
1019                    delay_ms: 0.0,
1020                    easing: Easing::Ease,
1021                    iteration_count: IterationCount::Finite(1.0),
1022                    direction: AnimationDirection::Normal,
1023                    fill_mode: AnimationFillMode::None,
1024                    play_state: AnimationPlayState::Running,
1025                }],
1026                base_values: HashMap::new(),
1027            },
1028        );
1029        let _ = rt.sample(0.0, true, &map, &kf, ColorScheme::Light);
1030        let mid = rt.sample(50.0, true, &map, &kf, ColorScheme::Light);
1031        match mid.overrides.get(&node).and_then(|m| m.get("opacity")) {
1032            // CSS `ease` is already well past linear halfway through the segment.
1033            Some(CssValue::Number(v)) => assert!(*v > 0.7, "expected eased progress, got {v}"),
1034            other => panic!("unexpected {other:?}"),
1035        }
1036    }
1037
1038    #[test]
1039    fn transform_transition_from_none_interpolates_from_identity() {
1040        let mut rt = AnimationRuntime::new();
1041        let node = 9u32;
1042        let kf = KeyframesRegistry::new();
1043        let transition = vec![TransitionSpec {
1044            property: "transform".to_string(),
1045            duration_ms: 100.0,
1046            delay_ms: 0.0,
1047            easing: Easing::Linear,
1048        }];
1049
1050        let mut base0 = HashMap::new();
1051        base0.insert("transform".to_string(), CssValue::Keyword("none".into()));
1052        let mut map0 = AnimationInputs::new();
1053        map0.insert(
1054            node,
1055            ElementAnimationState {
1056                transitions: transition.clone(),
1057                animations: vec![],
1058                base_values: base0,
1059            },
1060        );
1061        let _ = rt.sample(0.0, true, &map0, &kf, ColorScheme::Light);
1062
1063        let mut base1 = HashMap::new();
1064        base1.insert(
1065            "transform".to_string(),
1066            CssValue::Function(
1067                "translateX".into(),
1068                vec![vec![CssValue::Length(100.0, Unit::Px)]],
1069            ),
1070        );
1071        let mut map1 = AnimationInputs::new();
1072        map1.insert(
1073            node,
1074            ElementAnimationState {
1075                transitions: transition,
1076                animations: vec![],
1077                base_values: base1,
1078            },
1079        );
1080        let _ = rt.sample(50.0, true, &map1, &kf, ColorScheme::Light);
1081        let mid = rt.sample(100.0, true, &map1, &kf, ColorScheme::Light);
1082        match mid.overrides.get(&node).and_then(|m| m.get("transform")) {
1083            Some(CssValue::Function(name, args)) if name == "translate" => {
1084                let CssValue::Length(x, Unit::Px) = args[0][0] else {
1085                    panic!("expected Length px, got {args:?}")
1086                };
1087                assert!((x - 50.0).abs() < 1e-3, "expected 50px, got {x}");
1088            }
1089            other => panic!("unexpected {other:?}"),
1090        }
1091    }
1092}