Skip to main content

gpui_rhai/
animation.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{Duration, Instant};
3
4use rhai::{
5    CustomType, Engine, EvalAltResult, FLOAT, FuncRegistration, INT, Position, TypeBuilder,
6};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{ComponentInstancePath, UiNode, UiNodeKind};
11
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum AnimationProperty {
15    Opacity,
16    TranslateX,
17    TranslateY,
18    Rotate,
19    Width,
20    Height,
21    ClipHeight,
22}
23
24impl AnimationProperty {
25    #[must_use]
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Opacity => "opacity",
29            Self::TranslateX => "translate_x",
30            Self::TranslateY => "translate_y",
31            Self::Rotate => "rotate",
32            Self::Width => "width",
33            Self::Height => "height",
34            Self::ClipHeight => "clip_height",
35        }
36    }
37
38    fn parse(value: &str) -> Result<Self, AnimationError> {
39        match value {
40            "opacity" => Ok(Self::Opacity),
41            "translate_x" => Ok(Self::TranslateX),
42            "translate_y" => Ok(Self::TranslateY),
43            "rotate" => Ok(Self::Rotate),
44            "width" => Ok(Self::Width),
45            "height" => Ok(Self::Height),
46            "clip_height" => Ok(Self::ClipHeight),
47            _ => Err(AnimationError::UnknownProperty(value.to_owned())),
48        }
49    }
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum Easing {
55    Linear,
56    EaseIn,
57    EaseOut,
58    EaseInOut,
59}
60
61impl Easing {
62    fn parse(value: &str) -> Result<Self, AnimationError> {
63        match value {
64            "linear" => Ok(Self::Linear),
65            "ease_in" => Ok(Self::EaseIn),
66            "ease_out" => Ok(Self::EaseOut),
67            "ease_in_out" => Ok(Self::EaseInOut),
68            _ => Err(AnimationError::UnknownEasing(value.to_owned())),
69        }
70    }
71
72    fn sample(self, progress: f64) -> f64 {
73        match self {
74            Self::Linear => progress,
75            Self::EaseIn => progress * progress,
76            Self::EaseOut => 1.0 - (1.0 - progress) * (1.0 - progress),
77            Self::EaseInOut if progress < 0.5 => 2.0 * progress * progress,
78            Self::EaseInOut => 1.0 - (-2.0 * progress + 2.0).powi(2) / 2.0,
79        }
80    }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
84pub struct TransitionSpec {
85    pub property: AnimationProperty,
86    pub from: f64,
87    pub to: f64,
88    pub duration_ms: u64,
89    pub easing: Easing,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
93pub struct SpringSpec {
94    pub property: AnimationProperty,
95    pub from: f64,
96    pub to: f64,
97    pub initial_velocity: f64,
98    pub stiffness: f64,
99    pub damping: f64,
100    pub mass: f64,
101}
102
103#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum AnimationSpec {
106    Transition(TransitionSpec),
107    LoopingTransition(TransitionSpec),
108    Spring(SpringSpec),
109}
110
111impl AnimationSpec {
112    #[must_use]
113    pub const fn property(self) -> AnimationProperty {
114        match self {
115            Self::Transition(spec) | Self::LoopingTransition(spec) => spec.property,
116            Self::Spring(spec) => spec.property,
117        }
118    }
119
120    #[must_use]
121    pub const fn target(self) -> f64 {
122        match self {
123            Self::Transition(spec) | Self::LoopingTransition(spec) => spec.to,
124            Self::Spring(spec) => spec.to,
125        }
126    }
127
128    #[must_use]
129    pub const fn reduced_value(self) -> f64 {
130        match self {
131            Self::LoopingTransition(spec) => f64::midpoint(spec.from, spec.to),
132            Self::Transition(spec) => spec.to,
133            Self::Spring(spec) => spec.to,
134        }
135    }
136}
137
138impl CustomType for AnimationSpec {
139    fn build(mut builder: TypeBuilder<Self>) {
140        builder.with_name("AnimationSpec");
141    }
142}
143
144#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
145pub struct AnimationKey {
146    pub component: ComponentInstancePath,
147    pub property: AnimationProperty,
148}
149
150impl AnimationKey {
151    #[must_use]
152    pub fn for_node(path: &str, property: AnimationProperty) -> Self {
153        Self {
154            component: ComponentInstancePath::root("UiNode", path),
155            property,
156        }
157    }
158
159    fn in_node_scope(&self, path: &str) -> bool {
160        self.component
161            .single_root_key("UiNode")
162            .is_some_and(|node_path| {
163                node_path == path
164                    || node_path
165                        .strip_prefix(path)
166                        .is_some_and(|suffix| suffix.starts_with('/'))
167            })
168    }
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum MotionPreference {
173    Normal,
174    Reduced,
175}
176
177#[derive(Clone, Debug)]
178enum ActiveAnimation {
179    Transition {
180        from: f64,
181        to: f64,
182        started: Instant,
183        duration: Duration,
184        easing: Easing,
185        repeat: bool,
186    },
187    Spring {
188        position: f64,
189        velocity: f64,
190        target: f64,
191        stiffness: f64,
192        damping: f64,
193        mass: f64,
194        last_tick: Instant,
195    },
196}
197
198#[derive(Clone, Debug, Default)]
199pub struct AnimationRuntime {
200    active: BTreeMap<AnimationKey, ActiveAnimation>,
201    settled: BTreeMap<AnimationKey, f64>,
202    preference: Option<MotionPreference>,
203}
204
205#[derive(Clone, Debug, PartialEq)]
206pub struct AnimationSnapshot {
207    pub key: AnimationKey,
208    pub kind: String,
209    pub value: f64,
210    pub target: f64,
211    pub velocity: Option<f64>,
212    pub elapsed_ms: u64,
213    pub duration_ms: Option<u64>,
214    pub repeating: bool,
215    pub active: bool,
216}
217
218impl AnimationRuntime {
219    #[must_use]
220    pub fn new(preference: MotionPreference) -> Self {
221        Self {
222            preference: Some(preference),
223            ..Self::default()
224        }
225    }
226
227    pub fn set_preference(&mut self, preference: MotionPreference) {
228        self.preference = Some(preference);
229        if preference == MotionPreference::Reduced {
230            let active = std::mem::take(&mut self.active);
231            for (key, animation) in active {
232                self.settled.insert(key, reduced_value(&animation));
233            }
234        }
235    }
236
237    #[must_use]
238    pub const fn preference(&self) -> MotionPreference {
239        match self.preference {
240            Some(preference) => preference,
241            None => MotionPreference::Normal,
242        }
243    }
244
245    /// Start or retarget a keyed animation at the sampled current value.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`AnimationError`] for non-finite values or invalid spring
250    /// parameters.
251    pub fn start(
252        &mut self,
253        component: ComponentInstancePath,
254        spec: AnimationSpec,
255        now: Instant,
256    ) -> Result<AnimationKey, AnimationError> {
257        validate_spec(spec)?;
258        let key = AnimationKey {
259            component,
260            property: spec.property(),
261        };
262        if self
263            .active
264            .get(&key)
265            .is_some_and(|animation| (target(animation) - spec.target()).abs() < f64::EPSILON)
266            || self
267                .settled
268                .get(&key)
269                .is_some_and(|value| (*value - spec.target()).abs() < f64::EPSILON)
270        {
271            return Ok(key);
272        }
273        if self.preference == Some(MotionPreference::Reduced) {
274            self.active.remove(&key);
275            self.settled.insert(key.clone(), spec.reduced_value());
276            return Ok(key);
277        }
278        let current = self.sample(&key, now).unwrap_or(match spec {
279            AnimationSpec::Transition(spec) | AnimationSpec::LoopingTransition(spec) => spec.from,
280            AnimationSpec::Spring(spec) => spec.from,
281        });
282        let animation = match spec {
283            AnimationSpec::Transition(spec) => ActiveAnimation::Transition {
284                from: current,
285                to: spec.to,
286                started: now,
287                duration: Duration::from_millis(spec.duration_ms),
288                easing: spec.easing,
289                repeat: false,
290            },
291            AnimationSpec::LoopingTransition(spec) => ActiveAnimation::Transition {
292                from: current,
293                to: spec.to,
294                started: now,
295                duration: Duration::from_millis(spec.duration_ms),
296                easing: spec.easing,
297                repeat: true,
298            },
299            AnimationSpec::Spring(spec) => ActiveAnimation::Spring {
300                position: current,
301                velocity: spec.initial_velocity,
302                target: spec.to,
303                stiffness: spec.stiffness,
304                damping: spec.damping,
305                mass: spec.mass,
306                last_tick: now,
307            },
308        };
309        self.settled.remove(&key);
310        self.active.insert(key.clone(), animation);
311        Ok(key)
312    }
313
314    pub fn cancel(&mut self, key: &AnimationKey) {
315        self.active.remove(key);
316    }
317
318    pub fn retain_keys(&mut self, keys: &BTreeSet<AnimationKey>) {
319        self.active.retain(|key, _| keys.contains(key));
320        self.settled.retain(|key, _| keys.contains(key));
321    }
322
323    pub fn retain_node_scope(&mut self, path: &str, keys: &BTreeSet<AnimationKey>) {
324        self.active
325            .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
326        self.settled
327            .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
328    }
329
330    pub fn cancel_node_scope(&mut self, path: &str) {
331        self.active.retain(|key, _| !key.in_node_scope(path));
332        self.settled.retain(|key, _| !key.in_node_scope(path));
333    }
334
335    /// Shift one retained node scope forward so elapsed suspended time is not
336    /// sampled as animation progress.
337    pub fn delay_node_scope(&mut self, path: &str, delay: Duration) {
338        if delay.is_zero() {
339            return;
340        }
341        for (key, animation) in &mut self.active {
342            if !key.in_node_scope(path) {
343                continue;
344            }
345            match animation {
346                ActiveAnimation::Transition { started, .. } => {
347                    *started = started.checked_add(delay).unwrap_or(*started);
348                }
349                ActiveAnimation::Spring { last_tick, .. } => {
350                    *last_tick = last_tick.checked_add(delay).unwrap_or(*last_tick);
351                }
352            }
353        }
354    }
355
356    #[must_use]
357    pub fn snapshot(&self, now: Instant) -> BTreeMap<AnimationKey, f64> {
358        self.active
359            .keys()
360            .chain(self.settled.keys())
361            .filter_map(|key| self.sample(key, now).map(|value| (key.clone(), value)))
362            .collect()
363    }
364
365    #[must_use]
366    pub fn inspect(&self, now: Instant) -> Vec<AnimationSnapshot> {
367        let mut snapshots = self
368            .active
369            .iter()
370            .map(|(key, animation)| match animation {
371                ActiveAnimation::Transition {
372                    to,
373                    started,
374                    duration,
375                    repeat,
376                    ..
377                } => AnimationSnapshot {
378                    key: key.clone(),
379                    kind: "transition".to_owned(),
380                    value: sample_animation(animation, now),
381                    target: *to,
382                    velocity: None,
383                    elapsed_ms: duration_ms(now.saturating_duration_since(*started)),
384                    duration_ms: Some(duration_ms(*duration)),
385                    repeating: *repeat,
386                    active: true,
387                },
388                ActiveAnimation::Spring {
389                    velocity,
390                    target,
391                    last_tick,
392                    ..
393                } => AnimationSnapshot {
394                    key: key.clone(),
395                    kind: "spring".to_owned(),
396                    value: sample_animation(animation, now),
397                    target: *target,
398                    velocity: Some(*velocity),
399                    elapsed_ms: duration_ms(now.saturating_duration_since(*last_tick)),
400                    duration_ms: None,
401                    repeating: false,
402                    active: true,
403                },
404            })
405            .collect::<Vec<_>>();
406        snapshots.extend(self.settled.iter().map(|(key, value)| AnimationSnapshot {
407            key: key.clone(),
408            kind: "settled".to_owned(),
409            value: *value,
410            target: *value,
411            velocity: None,
412            elapsed_ms: 0,
413            duration_ms: None,
414            repeating: false,
415            active: false,
416        }));
417        snapshots.sort_by(|left, right| left.key.cmp(&right.key));
418        snapshots
419    }
420
421    #[must_use]
422    pub fn sample(&self, key: &AnimationKey, now: Instant) -> Option<f64> {
423        self.active
424            .get(key)
425            .map(|animation| sample_animation(animation, now))
426            .or_else(|| self.settled.get(key).copied())
427    }
428
429    #[must_use]
430    pub fn tick(&mut self, now: Instant) -> AnimationFrame {
431        let mut values = BTreeMap::new();
432        let mut completed = BTreeSet::new();
433        for (key, animation) in &mut self.active {
434            let (value, done) = advance_animation(animation, now);
435            values.insert(key.clone(), value);
436            if done {
437                completed.insert(key.clone());
438            }
439        }
440        for key in &completed {
441            if let Some(animation) = self.active.remove(key) {
442                self.settled.insert(key.clone(), target(&animation));
443            }
444        }
445        AnimationFrame {
446            values,
447            completed,
448            needs_frame: !self.active.is_empty(),
449        }
450    }
451}
452
453fn duration_ms(duration: Duration) -> u64 {
454    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
455}
456
457/// Reconcile animation declarations from one successfully rendered node tree.
458///
459/// # Errors
460///
461/// Returns invalid-spec or missing-key errors before mutating the runtime.
462pub fn reconcile_node_animations(
463    root: &UiNode,
464    runtime: &mut AnimationRuntime,
465    now: Instant,
466) -> Result<BTreeMap<AnimationKey, f64>, AnimationError> {
467    reconcile_node_animations_scoped(root, runtime, now, "root")
468}
469
470/// Reconcile one window's animation declarations without touching other windows.
471///
472/// # Errors
473///
474/// Returns invalid-spec or missing-key errors before mutating the runtime.
475pub fn reconcile_node_animations_scoped(
476    root: &UiNode,
477    runtime: &mut AnimationRuntime,
478    now: Instant,
479    root_path: &str,
480) -> Result<BTreeMap<AnimationKey, f64>, AnimationError> {
481    let mut declarations = Vec::<(String, AnimationSpec)>::new();
482    collect_node_animations(root, root_path, &mut declarations)?;
483    for (_, spec) in &declarations {
484        validate_spec(*spec)?;
485    }
486    let mut keys = BTreeSet::new();
487    for (path, spec) in declarations {
488        keys.insert(runtime.start(ComponentInstancePath::root("UiNode", path), spec, now)?);
489    }
490    runtime.retain_node_scope(root_path, &keys);
491    Ok(runtime.snapshot(now))
492}
493
494fn collect_node_animations(
495    node: &UiNode,
496    path: &str,
497    output: &mut Vec<(String, AnimationSpec)>,
498) -> Result<(), AnimationError> {
499    if !node.animations().is_empty() && node.key().is_none() {
500        return Err(AnimationError::MissingKey(path.to_owned()));
501    }
502    output.extend(
503        node.animations()
504            .iter()
505            .copied()
506            .map(|animation| (path.to_owned(), animation)),
507    );
508    match node.kind() {
509        UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
510            for (index, child) in children.iter().enumerate() {
511                collect_node_animations(child, &child_path(path, index, child), output)?;
512            }
513        }
514        UiNodeKind::Overlay {
515            trigger, content, ..
516        } => {
517            collect_node_animations(trigger, &format!("{path}/trigger"), output)?;
518            collect_node_animations(content, &format!("{path}/content"), output)?;
519        }
520        UiNodeKind::Layer { content, .. } => {
521            collect_node_animations(content, &format!("{path}/content"), output)?;
522        }
523        UiNodeKind::ErrorBoundary { child, fallback } => {
524            collect_node_animations(child, &format!("{path}/boundary"), output)?;
525            collect_node_animations(fallback, &format!("{path}/fallback"), output)?;
526        }
527        UiNodeKind::VirtualCollection { spec } => {
528            for (index, item) in &spec.realized {
529                collect_node_animations(item, &format!("{path}/item:{index}"), output)?;
530            }
531        }
532        UiNodeKind::Text { .. }
533        | UiNodeKind::RichText { .. }
534        | UiNodeKind::Canvas { .. }
535        | UiNodeKind::Svg { .. }
536        | UiNodeKind::Custom { .. }
537        | UiNodeKind::Image { .. }
538        | UiNodeKind::DirectionalImage { .. } => {}
539    }
540    Ok(())
541}
542
543fn child_path(path: &str, index: usize, child: &UiNode) -> String {
544    child.key().map_or_else(
545        || format!("{path}/{index}"),
546        |key| format!("{path}/{}", key.as_str()),
547    )
548}
549
550fn sample_animation(animation: &ActiveAnimation, now: Instant) -> f64 {
551    match animation {
552        ActiveAnimation::Transition {
553            from,
554            to,
555            started,
556            duration,
557            easing,
558            repeat,
559        } => {
560            if duration.is_zero() {
561                return *to;
562            }
563            let raw_progress = now.duration_since(*started).as_secs_f64() / duration.as_secs_f64();
564            let progress = if *repeat {
565                raw_progress % 1.0
566            } else {
567                raw_progress.clamp(0.0, 1.0)
568            };
569            from + (to - from) * easing.sample(progress)
570        }
571        ActiveAnimation::Spring { position, .. } => *position,
572    }
573}
574
575fn advance_animation(animation: &mut ActiveAnimation, now: Instant) -> (f64, bool) {
576    match animation {
577        ActiveAnimation::Transition {
578            from,
579            to,
580            started,
581            duration,
582            easing,
583            repeat,
584        } => {
585            let progress = if duration.is_zero() {
586                1.0
587            } else {
588                now.duration_since(*started).as_secs_f64() / duration.as_secs_f64()
589            };
590            let sampled_progress = if *repeat {
591                progress % 1.0
592            } else {
593                progress.clamp(0.0, 1.0)
594            };
595            let value = *from + (*to - *from) * easing.sample(sampled_progress);
596            (
597                value,
598                !*repeat && (duration.is_zero() || now.duration_since(*started) >= *duration),
599            )
600        }
601        ActiveAnimation::Spring {
602            position,
603            velocity,
604            target,
605            stiffness,
606            damping,
607            mass,
608            last_tick,
609        } => {
610            let elapsed = now.duration_since(*last_tick).as_secs_f64().min(0.05);
611            *last_tick = now;
612            let displacement = *position - *target;
613            let acceleration = (-*stiffness * displacement - *damping * *velocity) / *mass;
614            *velocity += acceleration * elapsed;
615            *position += *velocity * elapsed;
616            let done = (*position - *target).abs() < 0.001 && velocity.abs() < 0.001;
617            if done {
618                *position = *target;
619                *velocity = 0.0;
620            }
621            (*position, done)
622        }
623    }
624}
625
626fn target(animation: &ActiveAnimation) -> f64 {
627    match animation {
628        ActiveAnimation::Transition { to, .. } | ActiveAnimation::Spring { target: to, .. } => *to,
629    }
630}
631
632fn reduced_value(animation: &ActiveAnimation) -> f64 {
633    match animation {
634        ActiveAnimation::Transition {
635            from,
636            to,
637            repeat: true,
638            ..
639        } => f64::midpoint(*from, *to),
640        _ => target(animation),
641    }
642}
643
644fn validate_spec(spec: AnimationSpec) -> Result<(), AnimationError> {
645    let finite = match spec {
646        AnimationSpec::Transition(spec) | AnimationSpec::LoopingTransition(spec) => {
647            spec.from.is_finite() && spec.to.is_finite() && spec.duration_ms > 0
648        }
649        AnimationSpec::Spring(spec) => {
650            spec.from.is_finite()
651                && spec.to.is_finite()
652                && spec.initial_velocity.is_finite()
653                && spec.stiffness.is_finite()
654                && spec.stiffness > 0.0
655                && spec.damping.is_finite()
656                && spec.damping >= 0.0
657                && spec.mass.is_finite()
658                && spec.mass > 0.0
659        }
660    };
661    if finite {
662        Ok(())
663    } else {
664        Err(AnimationError::InvalidSpec)
665    }
666}
667
668pub(crate) fn register_animation_api(engine: &mut Engine) {
669    engine.build_type::<AnimationSpec>();
670    FuncRegistration::new("transition")
671        .in_global_namespace()
672        .register_into_engine(engine, transition_from_script);
673    FuncRegistration::new("spring")
674        .in_global_namespace()
675        .register_into_engine(engine, spring_from_script);
676    FuncRegistration::new("loop_transition")
677        .in_global_namespace()
678        .register_into_engine(engine, loop_transition_from_script);
679}
680
681fn transition_from_script(
682    property: &str,
683    from: FLOAT,
684    to: FLOAT,
685    duration_ms: INT,
686    easing: &str,
687) -> Result<AnimationSpec, Box<EvalAltResult>> {
688    let duration_ms = u64::try_from(duration_ms)
689        .map_err(|_| Box::new(animation_script_error(&"duration must be non-negative")))?;
690    let spec = AnimationSpec::Transition(TransitionSpec {
691        property: AnimationProperty::parse(property)
692            .map_err(|error| Box::new(animation_script_error(&error)))?,
693        from,
694        to,
695        duration_ms,
696        easing: Easing::parse(easing).map_err(|error| Box::new(animation_script_error(&error)))?,
697    });
698    validate_spec(spec).map_err(|error| Box::new(animation_script_error(&error)))?;
699    Ok(spec)
700}
701
702fn spring_from_script(
703    property: &str,
704    from: FLOAT,
705    to: FLOAT,
706    stiffness: FLOAT,
707    damping: FLOAT,
708) -> Result<AnimationSpec, Box<EvalAltResult>> {
709    let spec = AnimationSpec::Spring(SpringSpec {
710        property: AnimationProperty::parse(property)
711            .map_err(|error| Box::new(animation_script_error(&error)))?,
712        from,
713        to,
714        initial_velocity: 0.0,
715        stiffness,
716        damping,
717        mass: 1.0,
718    });
719    validate_spec(spec).map_err(|error| Box::new(animation_script_error(&error)))?;
720    Ok(spec)
721}
722
723fn loop_transition_from_script(
724    property: &str,
725    from: FLOAT,
726    to: FLOAT,
727    duration_ms: INT,
728    easing: &str,
729) -> Result<AnimationSpec, Box<EvalAltResult>> {
730    let AnimationSpec::Transition(spec) =
731        transition_from_script(property, from, to, duration_ms, easing)?
732    else {
733        unreachable!("transition constructor always returns a transition")
734    };
735    Ok(AnimationSpec::LoopingTransition(spec))
736}
737
738fn animation_script_error(error: &impl ToString) -> EvalAltResult {
739    EvalAltResult::ErrorRuntime(error.to_string().into(), Position::NONE)
740}
741
742#[derive(Clone, Debug, Error, PartialEq)]
743pub enum AnimationError {
744    #[error("animation property `{0}` is unknown")]
745    UnknownProperty(String),
746    #[error("animation easing `{0}` is unknown")]
747    UnknownEasing(String),
748    #[error("animation parameters must be finite and physically valid")]
749    InvalidSpec,
750    #[error("animated node `{0}` requires a stable key")]
751    MissingKey(String),
752}
753
754#[derive(Clone, Debug)]
755pub struct AnimationFrame {
756    pub values: BTreeMap<AnimationKey, f64>,
757    pub completed: BTreeSet<AnimationKey>,
758    pub needs_frame: bool,
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    fn key_path() -> ComponentInstancePath {
766        ComponentInstancePath::root("Accordion", "settings")
767    }
768
769    #[test]
770    fn transition_retargets_from_current_sample() {
771        let start = Instant::now();
772        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
773        let key = runtime
774            .start(
775                key_path(),
776                AnimationSpec::Transition(TransitionSpec {
777                    property: AnimationProperty::Opacity,
778                    from: 0.0,
779                    to: 1.0,
780                    duration_ms: 100,
781                    easing: Easing::Linear,
782                }),
783                start,
784            )
785            .unwrap();
786        let halfway = start + Duration::from_millis(50);
787        assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
788        runtime
789            .start(
790                key_path(),
791                AnimationSpec::Transition(TransitionSpec {
792                    property: AnimationProperty::Opacity,
793                    from: 1.0,
794                    to: 0.0,
795                    duration_ms: 100,
796                    easing: Easing::Linear,
797                }),
798                halfway,
799            )
800            .unwrap();
801        assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
802    }
803
804    #[test]
805    fn reduced_motion_settles_without_frames() {
806        let mut runtime = AnimationRuntime::new(MotionPreference::Reduced);
807        let key = runtime
808            .start(
809                key_path(),
810                AnimationSpec::Transition(TransitionSpec {
811                    property: AnimationProperty::Height,
812                    from: 0.0,
813                    to: 200.0,
814                    duration_ms: 300,
815                    easing: Easing::EaseOut,
816                }),
817                Instant::now(),
818            )
819            .unwrap();
820        assert_eq!(runtime.sample(&key, Instant::now()), Some(200.0));
821        assert!(!runtime.tick(Instant::now()).needs_frame);
822    }
823
824    #[test]
825    fn reduced_motion_keeps_looping_indicators_visible_at_midpoint() {
826        let now = Instant::now();
827        let spec = AnimationSpec::LoopingTransition(TransitionSpec {
828            property: AnimationProperty::TranslateX,
829            from: -72.0,
830            to: 200.0,
831            duration_ms: 900,
832            easing: Easing::EaseInOut,
833        });
834        let mut reduced = AnimationRuntime::new(MotionPreference::Reduced);
835        let reduced_key = reduced.start(key_path(), spec, now).unwrap();
836        assert_eq!(reduced.sample(&reduced_key, now), Some(64.0));
837        assert!(!reduced.tick(now).needs_frame);
838
839        let mut switched = AnimationRuntime::new(MotionPreference::Normal);
840        let switched_key = switched.start(key_path(), spec, now).unwrap();
841        switched.set_preference(MotionPreference::Reduced);
842        assert_eq!(switched.sample(&switched_key, now), Some(64.0));
843        assert!(!switched.tick(now).needs_frame);
844    }
845
846    #[test]
847    fn spring_converges_without_rhai_frame_callbacks() {
848        let start = Instant::now();
849        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
850        let key = runtime
851            .start(
852                key_path(),
853                AnimationSpec::Spring(SpringSpec {
854                    property: AnimationProperty::TranslateY,
855                    from: 0.0,
856                    to: 10.0,
857                    initial_velocity: 0.0,
858                    stiffness: 180.0,
859                    damping: 24.0,
860                    mass: 1.0,
861                }),
862                start,
863            )
864            .unwrap();
865        let mut now = start;
866        for _ in 0..600 {
867            now += Duration::from_millis(16);
868            if !runtime.tick(now).needs_frame {
869                break;
870            }
871        }
872        assert!((runtime.sample(&key, now).unwrap() - 10.0).abs() < 0.001);
873    }
874
875    #[test]
876    fn node_declarations_reconcile_without_restarting_same_target() {
877        let start = Instant::now();
878        let node =
879            UiNode::text("animated")
880                .with_key("status")
881                .with_animation(AnimationSpec::Transition(TransitionSpec {
882                    property: AnimationProperty::Opacity,
883                    from: 0.0,
884                    to: 1.0,
885                    duration_ms: 100,
886                    easing: Easing::Linear,
887                }));
888        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
889        reconcile_node_animations(&node, &mut runtime, start).unwrap();
890        let halfway = start + Duration::from_millis(50);
891        let values = reconcile_node_animations(&node, &mut runtime, halfway).unwrap();
892        let key = AnimationKey::for_node("root", AnimationProperty::Opacity);
893        assert!((values[&key] - 0.5).abs() < 0.01);
894    }
895
896    #[test]
897    fn animated_nodes_require_keys_and_removed_declarations_cancel() {
898        let start = Instant::now();
899        let animation = AnimationSpec::Transition(TransitionSpec {
900            property: AnimationProperty::Height,
901            from: 0.0,
902            to: 40.0,
903            duration_ms: 100,
904            easing: Easing::EaseOut,
905        });
906        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
907        assert!(matches!(
908            reconcile_node_animations(
909                &UiNode::text("missing key").with_animation(animation),
910                &mut runtime,
911                start
912            ),
913            Err(AnimationError::MissingKey(_))
914        ));
915        reconcile_node_animations(
916            &UiNode::text("keyed")
917                .with_key("row")
918                .with_animation(animation),
919            &mut runtime,
920            start,
921        )
922        .unwrap();
923        assert!(!runtime.snapshot(start).is_empty());
924        reconcile_node_animations(&UiNode::text("plain"), &mut runtime, start).unwrap();
925        assert!(runtime.snapshot(start).is_empty());
926    }
927
928    #[test]
929    fn scoped_reconciliation_does_not_remove_other_window_animations() {
930        let now = Instant::now();
931        let node = UiNode::text("loading").with_key("progress").with_animation(
932            AnimationSpec::LoopingTransition(TransitionSpec {
933                property: AnimationProperty::Opacity,
934                from: 0.0,
935                to: 1.0,
936                duration_ms: 100,
937                easing: Easing::Linear,
938            }),
939        );
940        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
941        reconcile_node_animations_scoped(&node, &mut runtime, now, "window:main/root").unwrap();
942        reconcile_node_animations_scoped(&node, &mut runtime, now, "window:settings/root").unwrap();
943        assert_eq!(runtime.snapshot(now).len(), 2);
944        runtime.cancel_node_scope("window:settings/root");
945        assert_eq!(runtime.snapshot(now).len(), 1);
946    }
947
948    #[test]
949    fn looping_transition_repeats_without_completing() {
950        let start = Instant::now();
951        let mut runtime = AnimationRuntime::new(MotionPreference::Normal);
952        let key = runtime
953            .start(
954                key_path(),
955                AnimationSpec::LoopingTransition(TransitionSpec {
956                    property: AnimationProperty::Opacity,
957                    from: 0.2,
958                    to: 0.8,
959                    duration_ms: 100,
960                    easing: Easing::Linear,
961                }),
962                start,
963            )
964            .unwrap();
965        let first = runtime.tick(start + Duration::from_millis(50));
966        assert!(first.needs_frame);
967        assert!((first.values[&key] - 0.5).abs() < 0.01);
968        let repeated = runtime.tick(start + Duration::from_millis(150));
969        assert!(repeated.needs_frame);
970        assert!((repeated.values[&key] - 0.5).abs() < 0.01);
971    }
972}