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 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 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
457pub 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
470pub 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}