1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::{Duration, Instant};
4
5use rhai::{
6 Array, CustomType, Dynamic, Engine, EvalAltResult, FLOAT, FnPtr, FuncRegistration, INT,
7 ImmutableString, Map, NativeCallContext, Position, TypeBuilder,
8};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use unicode_segmentation::UnicodeSegmentation;
12
13use crate::{
14 ComponentIncarnation, ComponentInstancePath, EventSchema, ScriptCallback, ScriptGeneration,
15 UiNode, UiNodeKind,
16};
17
18#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum MotionProperty {
21 Opacity,
22 TranslateX,
23 TranslateY,
24 Rotate,
25 ScaleX,
26 ScaleY,
27 SkewX,
28 SkewY,
29 Width,
30 Height,
31 ClipHeight,
32 PathProgress,
33}
34
35impl MotionProperty {
36 #[must_use]
37 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::Opacity => "opacity",
40 Self::TranslateX => "translate_x",
41 Self::TranslateY => "translate_y",
42 Self::Rotate => "rotate",
43 Self::ScaleX => "scale_x",
44 Self::ScaleY => "scale_y",
45 Self::SkewX => "skew_x",
46 Self::SkewY => "skew_y",
47 Self::Width => "width",
48 Self::Height => "height",
49 Self::ClipHeight => "clip_height",
50 Self::PathProgress => "path_progress",
51 }
52 }
53
54 fn parse(value: &str) -> Result<Self, MotionError> {
55 match value {
56 "opacity" => Ok(Self::Opacity),
57 "translate_x" => Ok(Self::TranslateX),
58 "translate_y" => Ok(Self::TranslateY),
59 "rotate" => Ok(Self::Rotate),
60 "scale_x" => Ok(Self::ScaleX),
61 "scale_y" => Ok(Self::ScaleY),
62 "skew_x" => Ok(Self::SkewX),
63 "skew_y" => Ok(Self::SkewY),
64 "width" => Ok(Self::Width),
65 "height" => Ok(Self::Height),
66 "clip_height" => Ok(Self::ClipHeight),
67 "path_progress" => Ok(Self::PathProgress),
68 _ => Err(MotionError::UnknownProperty(value.to_owned())),
69 }
70 }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum MotionEasing {
76 Linear,
77 EaseIn,
78 EaseOut,
79 EaseInOut,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum MotionIntent {
85 Decorative,
86 Feedback,
87 Essential,
88}
89
90impl MotionIntent {
91 fn parse(value: &str) -> Result<Self, MotionError> {
92 match value {
93 "decorative" => Ok(Self::Decorative),
94 "feedback" => Ok(Self::Feedback),
95 "essential" => Ok(Self::Essential),
96 _ => Err(MotionError::UnknownIntent(value.to_owned())),
97 }
98 }
99}
100
101impl MotionEasing {
102 pub fn parse(value: &str) -> Result<Self, MotionError> {
108 match value {
109 "linear" => Ok(Self::Linear),
110 "ease_in" => Ok(Self::EaseIn),
111 "ease_out" => Ok(Self::EaseOut),
112 "ease_in_out" => Ok(Self::EaseInOut),
113 _ => Err(MotionError::UnknownEasing(value.to_owned())),
114 }
115 }
116
117 #[must_use]
118 pub const fn as_str(self) -> &'static str {
119 match self {
120 Self::Linear => "linear",
121 Self::EaseIn => "ease_in",
122 Self::EaseOut => "ease_out",
123 Self::EaseInOut => "ease_in_out",
124 }
125 }
126
127 #[must_use]
129 pub fn sample(self, progress: f64) -> f64 {
130 let progress = progress.clamp(0.0, 1.0);
131 match self {
132 Self::Linear => progress,
133 Self::EaseIn => progress * progress,
134 Self::EaseOut => 1.0 - (1.0 - progress) * (1.0 - progress),
135 Self::EaseInOut if progress < 0.5 => 2.0 * progress * progress,
136 Self::EaseInOut => 1.0 - (-2.0 * progress + 2.0).powi(2) / 2.0,
137 }
138 }
139}
140
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142pub struct MotionTransition {
143 pub property: MotionProperty,
144 pub from: f64,
145 pub to: f64,
146 pub delay_ms: u64,
147 pub duration_ms: u64,
148 pub easing: MotionEasing,
149 pub iterations: Option<u32>,
150 pub autoreverse: bool,
151 pub intent: MotionIntent,
152}
153
154impl MotionTransition {
155 #[must_use]
156 pub const fn new(property: MotionProperty, from: f64, to: f64, duration_ms: u64) -> Self {
157 Self {
158 property,
159 from,
160 to,
161 delay_ms: 0,
162 duration_ms,
163 easing: MotionEasing::EaseOut,
164 iterations: Some(1),
165 autoreverse: false,
166 intent: MotionIntent::Feedback,
167 }
168 }
169}
170
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct MotionSpring {
173 pub property: MotionProperty,
174 pub from: f64,
175 pub to: f64,
176 pub initial_velocity: f64,
177 pub stiffness: f64,
178 pub damping: f64,
179 pub mass: f64,
180 pub intent: MotionIntent,
181}
182
183impl MotionSpring {
184 #[must_use]
185 pub const fn new(property: MotionProperty, from: f64, to: f64) -> Self {
186 Self {
187 property,
188 from,
189 to,
190 initial_velocity: 0.0,
191 stiffness: 180.0,
192 damping: 24.0,
193 mass: 1.0,
194 intent: MotionIntent::Feedback,
195 }
196 }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
200pub struct MotionKeyframe {
201 pub offset: f64,
202 pub value: f64,
203 pub easing: MotionEasing,
204}
205
206#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
207pub struct MotionKeyframes {
208 pub property: MotionProperty,
209 pub frames: Vec<MotionKeyframe>,
210 pub delay_ms: u64,
211 pub duration_ms: u64,
212 pub iterations: Option<u32>,
213 pub autoreverse: bool,
214 pub intent: MotionIntent,
215}
216
217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218pub struct MotionInertia {
219 pub property: MotionProperty,
220 pub from: f64,
221 pub velocity: f64,
222 pub friction: f64,
223 pub min: Option<f64>,
224 pub max: Option<f64>,
225 pub bounce: f64,
226 pub snap_points: Vec<f64>,
227 pub intent: MotionIntent,
228}
229
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum MotionSource {
233 Transition(MotionTransition),
234 Spring(MotionSpring),
235 Keyframes(MotionKeyframes),
236 Inertia(MotionInertia),
237}
238
239impl MotionSource {
240 #[must_use]
241 pub const fn property(&self) -> MotionProperty {
242 match self {
243 Self::Transition(spec) => spec.property,
244 Self::Spring(spec) => spec.property,
245 Self::Keyframes(spec) => spec.property,
246 Self::Inertia(spec) => spec.property,
247 }
248 }
249
250 #[must_use]
251 pub fn target(&self) -> f64 {
252 source_terminal_value(self)
253 }
254
255 #[must_use]
256 pub fn reduced_value(&self) -> f64 {
257 match self {
258 Self::Transition(spec) if spec.iterations.is_none() => {
259 f64::midpoint(spec.from, spec.to)
260 }
261 Self::Transition(spec) => spec.to,
262 Self::Spring(spec) => spec.to,
263 Self::Keyframes(spec) if spec.iterations.is_none() => spec
264 .frames
265 .first()
266 .zip(spec.frames.last())
267 .map_or(0.0, |(first, last)| f64::midpoint(first.value, last.value)),
268 Self::Keyframes(spec) => spec.frames.last().map_or(0.0, |frame| frame.value),
269 Self::Inertia(spec) => inertia_target(spec),
270 }
271 }
272
273 #[must_use]
274 pub const fn intent(&self) -> MotionIntent {
275 match self {
276 Self::Transition(spec) => spec.intent,
277 Self::Spring(spec) => spec.intent,
278 Self::Keyframes(spec) => spec.intent,
279 Self::Inertia(spec) => spec.intent,
280 }
281 }
282}
283
284impl CustomType for MotionSource {
285 fn build(mut builder: TypeBuilder<Self>) {
286 builder.with_name("MotionSource");
287 }
288}
289
290#[derive(Clone, Debug, PartialEq)]
291pub struct MotionTrack {
292 pub target: String,
293 pub source: MotionSource,
294}
295
296#[derive(Clone, Debug, PartialEq)]
297pub enum MotionTimelineStep {
298 Track(MotionTrack),
299 Delay(u64),
300 Sequence(Vec<Self>),
301 Parallel(Vec<Self>),
302 Stagger { interval_ms: u64, steps: Vec<Self> },
303}
304
305impl CustomType for MotionTimelineStep {
306 fn build(mut builder: TypeBuilder<Self>) {
307 builder.with_name("MotionTimelineStep");
308 }
309}
310
311#[derive(Clone, Debug, PartialEq)]
312pub struct MotionTimeline {
313 pub name: String,
314 pub root: MotionTimelineStep,
315 pub autoplay: bool,
316 pub iterations: Option<u32>,
317 pub autoreverse: bool,
318 pub intent: MotionIntent,
319 pub(crate) on_complete: Option<ScriptCallback>,
320 pub(crate) on_cancel: Option<ScriptCallback>,
321}
322
323impl MotionTimeline {
324 #[must_use]
325 pub fn new(name: impl Into<String>, root: MotionTimelineStep) -> Self {
326 Self {
327 name: name.into(),
328 root,
329 autoplay: true,
330 iterations: Some(1),
331 autoreverse: false,
332 intent: MotionIntent::Decorative,
333 on_complete: None,
334 on_cancel: None,
335 }
336 }
337
338 pub(crate) fn bind_generation(&mut self, generation: ScriptGeneration) {
339 for callback in [&mut self.on_complete, &mut self.on_cancel]
340 .into_iter()
341 .flatten()
342 {
343 callback.bind_generation(generation);
344 }
345 }
346
347 pub(crate) fn bind_component_scope(
348 &mut self,
349 component: &ComponentInstancePath,
350 incarnation: ComponentIncarnation,
351 events: &BTreeMap<String, EventSchema>,
352 native_context: Option<&crate::invocation::ScriptInvocationContext>,
353 ) {
354 for callback in [&mut self.on_complete, &mut self.on_cancel]
355 .into_iter()
356 .flatten()
357 {
358 callback.bind_component_scope_if_unset(component, incarnation, events.clone());
359 if let Some(context) = native_context {
360 callback.bind_native_context_if_unset(context.clone());
361 }
362 }
363 }
364}
365
366impl CustomType for MotionTimeline {
367 fn build(mut builder: TypeBuilder<Self>) {
368 builder.with_name("MotionTimeline");
369 }
370}
371
372#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
373pub struct MotionHandle {
374 runtime_id: u64,
375 instance: u64,
376 domain: String,
377 owner: ComponentInstancePath,
378 incarnation: ComponentIncarnation,
379 generation: ScriptGeneration,
380 node_path: String,
381 name: String,
382}
383
384impl MotionHandle {
385 #[must_use]
386 pub fn name(&self) -> &str {
387 &self.name
388 }
389
390 #[must_use]
391 pub fn owner(&self) -> &ComponentInstancePath {
392 &self.owner
393 }
394
395 #[must_use]
396 pub fn domain(&self) -> &str {
397 &self.domain
398 }
399
400 fn in_node_scope(&self, path: &str) -> bool {
401 self.node_path == path
402 || self
403 .node_path
404 .strip_prefix(path)
405 .is_some_and(|suffix| suffix.starts_with('/'))
406 }
407}
408
409impl CustomType for MotionHandle {
410 fn build(mut builder: TypeBuilder<Self>) {
411 builder.with_name("MotionHandle");
412 }
413}
414
415#[derive(Clone, Copy, Debug, Eq, PartialEq)]
416pub enum MotionPlaybackState {
417 Idle,
418 Playing,
419 Paused,
420 Completed,
421 Cancelled,
422}
423
424#[derive(Clone, Copy, Debug, Eq, PartialEq)]
425pub enum MotionTimelineEventKind {
426 Complete,
427 Cancel,
428}
429
430#[derive(Clone, Debug, PartialEq)]
431pub struct MotionTimelineEvent {
432 pub handle: MotionHandle,
433 pub kind: MotionTimelineEventKind,
434 pub callback: Option<ScriptCallback>,
435}
436
437#[derive(Clone, Debug, PartialEq)]
438pub(crate) struct MotionGhost {
439 pub id: String,
440 pub node: UiNode,
441 pub bounds: crate::GeometryBounds,
442 pub path: String,
443 pub domain: String,
444}
445
446#[derive(Clone, Debug)]
447struct ScheduledMotionTrack {
448 target: String,
449 resolved_path: Option<String>,
450 source: MotionSource,
451 start_ms: u64,
452 duration_ms: u64,
453}
454
455#[derive(Clone, Debug)]
456struct ActiveTimeline {
457 spec: MotionTimeline,
458 tracks: Vec<ScheduledMotionTrack>,
459 duration_ms: u64,
460 state: MotionPlaybackState,
461 started: Instant,
462 elapsed_before_play: Duration,
463 policy_settled: Option<MotionPreference>,
464}
465
466#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
467struct TimelineLogicalKey {
468 domain: String,
469 node_path: String,
470 name: String,
471}
472
473#[derive(Clone, Debug)]
474struct TimelineOwner {
475 domain: String,
476 component: ComponentInstancePath,
477 incarnation: ComponentIncarnation,
478 generation: ScriptGeneration,
479 node_path: String,
480 target_root: String,
481}
482
483#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
484pub struct MotionKey {
485 pub component: ComponentInstancePath,
486 pub property: MotionProperty,
487}
488
489impl MotionKey {
490 #[must_use]
491 pub fn for_node(path: &str, property: MotionProperty) -> Self {
492 Self {
493 component: ComponentInstancePath::root("UiNode", path),
494 property,
495 }
496 }
497
498 fn in_node_scope(&self, path: &str) -> bool {
499 self.component
500 .single_root_key("UiNode")
501 .is_some_and(|node_path| {
502 node_path == path
503 || node_path
504 .strip_prefix(path)
505 .is_some_and(|suffix| suffix.starts_with('/'))
506 })
507 }
508}
509
510#[derive(Clone, Copy, Debug, Eq, PartialEq)]
511pub enum MotionPreference {
512 Normal,
513 Reduced,
514 None,
515}
516
517const fn stricter_preference(left: MotionPreference, right: MotionPreference) -> MotionPreference {
518 match (left, right) {
519 (MotionPreference::None, _) | (_, MotionPreference::None) => MotionPreference::None,
520 (MotionPreference::Reduced, _) | (_, MotionPreference::Reduced) => {
521 MotionPreference::Reduced
522 }
523 (MotionPreference::Normal, MotionPreference::Normal) => MotionPreference::Normal,
524 }
525}
526
527fn motion_domain_from_path(path: &str) -> String {
528 path.find("/root")
529 .map_or_else(|| "root".to_owned(), |end| path[..end + 5].to_owned())
530}
531
532fn encode_path_segment(kind: &str, value: &str) -> String {
533 let mut encoded = String::with_capacity(kind.len() + 1 + value.len() * 2);
534 encoded.push_str(kind);
535 encoded.push(':');
536 for byte in value.as_bytes() {
537 use std::fmt::Write as _;
538 let _ = write!(encoded, "{byte:02x}");
539 }
540 encoded
541}
542
543pub(crate) fn retained_node_path(path: &str, node: crate::NodeId) -> String {
544 format!("{}/node:{}", motion_domain_from_path(path), node.get())
545}
546
547pub(crate) fn virtual_item_path(path: &str, key: &str) -> String {
548 format!("{path}/{}", encode_path_segment("item", key))
549}
550
551pub(crate) fn span_motion_path(path: &str, key: &str) -> String {
552 format!("{path}/{}", encode_path_segment("span", key))
553}
554
555fn path_contains_scope(path: &str, scope: &str) -> bool {
556 scope == path
557 || scope
558 .strip_prefix(path)
559 .is_some_and(|suffix| suffix.starts_with('/'))
560}
561
562#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
563#[serde(rename_all = "snake_case")]
564pub enum MotionQuality {
565 Low,
566 Medium,
567 High,
568}
569
570#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
571pub enum MotionProgressDriver {
572 InView,
573 Viewport,
574 ScrollX,
575 ScrollY,
576 Hover,
577 Press,
578 Focus,
579}
580
581#[derive(Clone, Debug, PartialEq)]
582pub struct MotionProgressBinding {
583 pub driver: MotionProgressDriver,
584 pub source: MotionSource,
585}
586
587impl MotionProgressBinding {
588 pub fn new(driver: MotionProgressDriver, source: MotionSource) -> Result<Self, MotionError> {
594 validate_progress_source(&source)?;
595 Ok(Self { driver, source })
596 }
597
598 #[must_use]
599 pub const fn property(&self) -> MotionProperty {
600 self.source.property()
601 }
602}
603
604impl CustomType for MotionProgressBinding {
605 fn build(mut builder: TypeBuilder<Self>) {
606 builder.with_name("MotionProgressBinding");
607 }
608}
609
610#[derive(Clone, Debug)]
611struct ActiveMotion {
612 replay_key: Option<String>,
613 declaration: MotionSource,
614 source: MotionSource,
615 finite_duration_ms: Option<u64>,
616 started: Instant,
617 elapsed_before_play: Duration,
618 retention: MotionRetention,
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
622enum MotionRetention {
623 Host,
624 Live(String),
625 Exit,
626}
627
628#[derive(Debug)]
629pub struct MotionRuntime {
630 runtime_id: u64,
631 next_timeline_instance: u64,
632 active: BTreeMap<MotionKey, ActiveMotion>,
633 settled: BTreeMap<MotionKey, f64>,
634 settled_declaration: BTreeMap<MotionKey, (MotionSource, Option<String>)>,
635 settled_retention: BTreeMap<MotionKey, MotionRetention>,
636 timelines: BTreeMap<MotionHandle, ActiveTimeline>,
637 timeline_index: BTreeMap<TimelineLogicalKey, MotionHandle>,
638 timeline_events: Vec<MotionTimelineEvent>,
639 suspended_scopes: BTreeSet<String>,
640 active_reservations: BTreeMap<String, usize>,
641 live_scene_signatures: BTreeMap<String, String>,
642 active_limit: usize,
643 host_preference: Option<MotionPreference>,
644 preference: Option<MotionPreference>,
645 quality: Option<MotionQuality>,
646}
647
648impl Default for MotionRuntime {
649 fn default() -> Self {
650 static NEXT_RUNTIME_ID: AtomicU64 = AtomicU64::new(1);
651 Self {
652 runtime_id: NEXT_RUNTIME_ID.fetch_add(1, Ordering::Relaxed),
653 next_timeline_instance: 1,
654 active: BTreeMap::new(),
655 settled: BTreeMap::new(),
656 settled_declaration: BTreeMap::new(),
657 settled_retention: BTreeMap::new(),
658 timelines: BTreeMap::new(),
659 timeline_index: BTreeMap::new(),
660 timeline_events: Vec::new(),
661 suspended_scopes: BTreeSet::new(),
662 active_reservations: BTreeMap::new(),
663 live_scene_signatures: BTreeMap::new(),
664 active_limit: usize::MAX,
665 host_preference: None,
666 preference: None,
667 quality: None,
668 }
669 }
670}
671
672#[derive(Clone, Debug, PartialEq)]
673pub struct MotionSnapshot {
674 pub key: MotionKey,
675 pub kind: String,
676 pub value: f64,
677 pub target: f64,
678 pub velocity: Option<f64>,
679 pub elapsed_ms: u64,
680 pub duration_ms: Option<u64>,
681 pub repeating: bool,
682 pub active: bool,
683}
684
685#[derive(Clone, Debug, PartialEq)]
686pub struct MotionTimelineSnapshot {
687 pub handle: MotionHandle,
688 pub state: MotionPlaybackState,
689 pub elapsed_ms: u64,
690 pub duration_ms: Option<u64>,
691 pub iterations: Option<u32>,
692 pub autoreverse: bool,
693 pub track_count: usize,
694}
695
696impl MotionRuntime {
697 pub(crate) fn transaction_snapshot(&self) -> Self {
698 Self {
699 runtime_id: self.runtime_id,
700 next_timeline_instance: self.next_timeline_instance,
701 active: self.active.clone(),
702 settled: self.settled.clone(),
703 settled_declaration: self.settled_declaration.clone(),
704 settled_retention: self.settled_retention.clone(),
705 timelines: self.timelines.clone(),
706 timeline_index: self.timeline_index.clone(),
707 timeline_events: self.timeline_events.clone(),
708 suspended_scopes: self.suspended_scopes.clone(),
709 active_reservations: self.active_reservations.clone(),
710 live_scene_signatures: self.live_scene_signatures.clone(),
711 active_limit: self.active_limit,
712 host_preference: self.host_preference,
713 preference: self.preference,
714 quality: self.quality,
715 }
716 }
717
718 #[must_use]
719 pub fn new(preference: MotionPreference) -> Self {
720 Self {
721 host_preference: Some(preference),
722 preference: Some(preference),
723 quality: Some(MotionQuality::High),
724 ..Self::default()
725 }
726 }
727
728 pub fn set_preference(&mut self, preference: MotionPreference) {
729 self.host_preference = Some(preference);
730 self.apply_preference(preference);
731 }
732
733 pub fn request_preference(&mut self, preference: MotionPreference) {
734 let host = self.host_preference.unwrap_or(MotionPreference::Normal);
735 self.apply_preference(stricter_preference(host, preference));
736 }
737
738 fn apply_preference(&mut self, preference: MotionPreference) {
739 self.preference = Some(preference);
740 if matches!(
741 preference,
742 MotionPreference::Reduced | MotionPreference::None
743 ) {
744 let active = std::mem::take(&mut self.active);
745 for (key, animation) in active {
746 self.settled_declaration.insert(
747 key.clone(),
748 (animation.declaration.clone(), animation.replay_key.clone()),
749 );
750 self.settled.insert(
751 key.clone(),
752 if preference == MotionPreference::None {
753 source_terminal_value(&animation.declaration)
754 } else {
755 reduced_value(&animation)
756 },
757 );
758 self.settled_retention.insert(key, animation.retention);
759 }
760 for (key, (declaration, _)) in &self.settled_declaration {
761 self.settled.insert(
762 key.clone(),
763 if preference == MotionPreference::None {
764 source_terminal_value(declaration)
765 } else {
766 declaration.reduced_value()
767 },
768 );
769 }
770 let handles = self
771 .timelines
772 .iter()
773 .filter(|(_, timeline)| {
774 !matches!(
775 timeline.state,
776 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
777 )
778 })
779 .map(|(handle, _)| handle.clone())
780 .collect::<Vec<_>>();
781 for handle in handles {
782 let callback = self.timelines.get_mut(&handle).map_or_else(
783 || None,
784 |timeline| {
785 timeline.state = MotionPlaybackState::Completed;
786 timeline.elapsed_before_play = Duration::from_millis(
787 timeline_total_duration(timeline).unwrap_or(timeline.duration_ms),
788 );
789 timeline.policy_settled = Some(preference);
790 timeline.spec.on_complete.clone()
791 },
792 );
793 self.timeline_events.push(MotionTimelineEvent {
794 handle,
795 kind: MotionTimelineEventKind::Complete,
796 callback,
797 });
798 }
799 for timeline in self
800 .timelines
801 .values_mut()
802 .filter(|timeline| timeline.state == MotionPlaybackState::Completed)
803 {
804 timeline.elapsed_before_play = Duration::from_millis(
805 timeline_total_duration(timeline).unwrap_or(timeline.duration_ms),
806 );
807 timeline.policy_settled = Some(preference);
808 }
809 }
810 }
811
812 #[must_use]
813 pub const fn preference(&self) -> MotionPreference {
814 match self.preference {
815 Some(preference) => preference,
816 None => MotionPreference::Normal,
817 }
818 }
819
820 pub fn set_quality(&mut self, quality: MotionQuality) {
821 self.quality = Some(quality);
822 }
823
824 #[must_use]
825 pub const fn quality(&self) -> MotionQuality {
826 match self.quality {
827 Some(quality) => quality,
828 None => MotionQuality::High,
829 }
830 }
831
832 pub fn start(
839 &mut self,
840 component: ComponentInstancePath,
841 source: MotionSource,
842 now: Instant,
843 ) -> Result<MotionKey, MotionError> {
844 self.start_with_replay(component, source, None, now)
845 }
846
847 pub(crate) fn start_exit(
848 &mut self,
849 component: ComponentInstancePath,
850 source: MotionSource,
851 now: Instant,
852 ) -> Result<MotionKey, MotionError> {
853 self.start_with_replay_retained(component, source, None, now, MotionRetention::Exit)
854 }
855
856 #[allow(clippy::too_many_lines)]
862 pub fn start_with_replay(
863 &mut self,
864 component: ComponentInstancePath,
865 declaration: MotionSource,
866 replay_key: Option<String>,
867 now: Instant,
868 ) -> Result<MotionKey, MotionError> {
869 self.start_with_replay_retained(
870 component,
871 declaration,
872 replay_key,
873 now,
874 MotionRetention::Host,
875 )
876 }
877
878 fn start_with_replay_retained(
879 &mut self,
880 component: ComponentInstancePath,
881 declaration: MotionSource,
882 replay_key: Option<String>,
883 now: Instant,
884 retention: MotionRetention,
885 ) -> Result<MotionKey, MotionError> {
886 validate_source(&declaration)?;
887 let key = MotionKey {
888 component,
889 property: declaration.property(),
890 };
891 let previous_replay = self
892 .active
893 .get(&key)
894 .map(|motion| &motion.replay_key)
895 .or_else(|| self.settled_declaration.get(&key).map(|(_, replay)| replay));
896 let replaying = replay_key.is_some() && previous_replay != Some(&replay_key);
897 if self.active.get(&key).is_some_and(|motion| {
898 motion.declaration == declaration && motion.replay_key == replay_key
899 }) || self
900 .settled_declaration
901 .get(&key)
902 .is_some_and(|settled| settled == &(declaration.clone(), replay_key.clone()))
903 {
904 if let Some(motion) = self.active.get_mut(&key) {
905 motion.retention = retention;
906 } else {
907 self.settled_retention.insert(key.clone(), retention);
908 }
909 return Ok(key);
910 }
911 if matches!(
912 self.preference,
913 Some(MotionPreference::Reduced | MotionPreference::None)
914 ) {
915 self.active.remove(&key);
916 self.settled_declaration
917 .insert(key.clone(), (declaration.clone(), replay_key));
918 self.settled_retention.insert(key.clone(), retention);
919 self.settled.insert(
920 key.clone(),
921 if self.preference == Some(MotionPreference::None) {
922 source_terminal_value(&declaration)
923 } else {
924 declaration.reduced_value()
925 },
926 );
927 return Ok(key);
928 }
929 let declared_from = source_initial_value(&declaration);
930 let previous = if replaying {
931 None
932 } else {
933 self.sample_with_velocity(&key, now)
934 };
935 let current = previous.map_or(declared_from, |sample| sample.value);
936 let source = retarget_source(
937 &declaration,
938 current,
939 previous.and_then(|sample| sample.velocity),
940 );
941 validate_source(&source)?;
942 let finite_duration_ms = source_duration_ms(&source).ok();
943 let active_increase = usize::from(!self.active.contains_key(&key));
944 self.ensure_active_capacity(active_increase)?;
945 self.settled.remove(&key);
946 self.settled_declaration.remove(&key);
947 self.settled_retention.remove(&key);
948 self.active.insert(
949 key.clone(),
950 ActiveMotion {
951 replay_key,
952 declaration,
953 source,
954 finite_duration_ms,
955 started: now,
956 elapsed_before_play: Duration::ZERO,
957 retention,
958 },
959 );
960 Ok(key)
961 }
962
963 pub fn cancel(&mut self, key: &MotionKey) {
964 self.active.remove(key);
965 self.settled.remove(key);
966 self.settled_declaration.remove(key);
967 self.settled_retention.remove(key);
968 }
969
970 pub fn start_timeline(
976 &mut self,
977 component: ComponentInstancePath,
978 spec: MotionTimeline,
979 now: Instant,
980 ) -> Result<MotionHandle, MotionError> {
981 let node_path = component
982 .single_root_key("UiNode")
983 .unwrap_or("root")
984 .to_owned();
985 let owner = TimelineOwner {
986 domain: motion_domain_from_path(&node_path),
987 component,
988 incarnation: ComponentIncarnation::unscoped(),
989 generation: ScriptGeneration::default(),
990 target_root: node_path.clone(),
991 node_path,
992 };
993 let mut candidate = self.transaction_snapshot();
994 let handle = candidate.start_timeline_owned(&owner, spec, now, None)?;
995 *self = candidate;
996 Ok(handle)
997 }
998
999 fn start_timeline_owned(
1000 &mut self,
1001 owner: &TimelineOwner,
1002 spec: MotionTimeline,
1003 now: Instant,
1004 identities: Option<&BTreeMap<String, String>>,
1005 ) -> Result<MotionHandle, MotionError> {
1006 validate_timeline_name(&spec.name)?;
1007 if spec.iterations == Some(0) {
1008 return Err(MotionError::InvalidTimeline(
1009 "timeline iterations must be greater than zero".to_owned(),
1010 ));
1011 }
1012 let (mut tracks, duration_ms) = compile_timeline(&spec.root)?;
1013 for track in &mut tracks {
1014 let target = resolve_timeline_target(&owner.target_root, &track.target);
1015 track.resolved_path = Some(
1016 identities
1017 .and_then(|identities| identities.get(&target))
1018 .cloned()
1019 .unwrap_or(target),
1020 );
1021 }
1022 let logical = TimelineLogicalKey {
1023 domain: owner.domain.clone(),
1024 node_path: owner.node_path.clone(),
1025 name: spec.name.clone(),
1026 };
1027 if let Some(previous_handle) = self.timeline_index.get(&logical).cloned()
1028 && let Some(mut active) = self.timelines.remove(&previous_handle)
1029 {
1030 if timeline_compatible(&active.spec, &spec)
1031 && previous_handle.owner == owner.component
1032 && previous_handle.incarnation == owner.incarnation
1033 {
1034 active.tracks = tracks;
1035 active.duration_ms = duration_ms;
1036 active.spec.on_complete = spec.on_complete;
1037 active.spec.on_cancel = spec.on_cancel;
1038 if previous_handle.generation == owner.generation {
1039 self.timelines.insert(previous_handle.clone(), active);
1040 return Ok(previous_handle);
1041 }
1042 let handle = self.allocate_timeline_handle(owner, spec.name.clone());
1043 self.timelines.insert(handle.clone(), active);
1044 self.timeline_index.insert(logical, handle.clone());
1045 return Ok(handle);
1046 }
1047 if !matches!(
1048 active.state,
1049 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
1050 ) {
1051 self.timeline_events.push(MotionTimelineEvent {
1052 handle: previous_handle,
1053 kind: MotionTimelineEventKind::Cancel,
1054 callback: active.spec.on_cancel,
1055 });
1056 }
1057 }
1058 let handle = self.allocate_timeline_handle(owner, spec.name.clone());
1059 let state = if spec.autoplay {
1060 MotionPlaybackState::Playing
1061 } else {
1062 MotionPlaybackState::Idle
1063 };
1064 if matches!(
1065 self.preference,
1066 Some(MotionPreference::Reduced | MotionPreference::None)
1067 ) {
1068 let callback = spec.on_complete.clone();
1069 self.timelines.insert(
1070 handle.clone(),
1071 ActiveTimeline {
1072 spec,
1073 tracks,
1074 duration_ms,
1075 state: MotionPlaybackState::Completed,
1076 started: now,
1077 elapsed_before_play: Duration::from_millis(duration_ms),
1078 policy_settled: self.preference,
1079 },
1080 );
1081 self.timeline_events.push(MotionTimelineEvent {
1082 handle: handle.clone(),
1083 kind: MotionTimelineEventKind::Complete,
1084 callback,
1085 });
1086 self.timeline_index.insert(logical, handle.clone());
1087 return Ok(handle);
1088 }
1089 if state == MotionPlaybackState::Playing {
1090 self.ensure_active_capacity(tracks.len())?;
1091 }
1092 self.timelines.insert(
1093 handle.clone(),
1094 ActiveTimeline {
1095 spec,
1096 tracks,
1097 duration_ms,
1098 state,
1099 started: now,
1100 elapsed_before_play: Duration::ZERO,
1101 policy_settled: None,
1102 },
1103 );
1104 self.timeline_index.insert(logical, handle.clone());
1105 Ok(handle)
1106 }
1107
1108 fn allocate_timeline_handle(&mut self, owner: &TimelineOwner, name: String) -> MotionHandle {
1109 let instance = self.next_timeline_instance.max(1);
1110 self.next_timeline_instance = instance.saturating_add(1);
1111 MotionHandle {
1112 runtime_id: self.runtime_id,
1113 instance,
1114 domain: owner.domain.clone(),
1115 owner: owner.component.clone(),
1116 incarnation: owner.incarnation,
1117 generation: owner.generation,
1118 node_path: owner.node_path.clone(),
1119 name,
1120 }
1121 }
1122
1123 pub fn play_timeline(
1129 &mut self,
1130 handle: &MotionHandle,
1131 now: Instant,
1132 ) -> Result<(), MotionError> {
1133 self.validate_handle(handle)?;
1134 let state = self
1135 .timelines
1136 .get(handle)
1137 .map(|timeline| timeline.state)
1138 .ok_or_else(|| MotionError::StaleTimelineHandle(handle.name.clone()))?;
1139 match state {
1140 MotionPlaybackState::Playing | MotionPlaybackState::Completed => return Ok(()),
1141 MotionPlaybackState::Cancelled => {
1142 return Err(MotionError::StaleTimelineHandle(handle.name.clone()));
1143 }
1144 MotionPlaybackState::Idle | MotionPlaybackState::Paused => {}
1145 }
1146 if matches!(
1147 self.preference,
1148 Some(MotionPreference::Reduced | MotionPreference::None)
1149 ) {
1150 let preference = self.preference;
1151 let timeline = self.timeline_mut(handle)?;
1152 timeline.state = MotionPlaybackState::Completed;
1153 timeline.elapsed_before_play = Duration::from_millis(
1154 timeline_total_duration(timeline).unwrap_or(timeline.duration_ms),
1155 );
1156 timeline.policy_settled = preference;
1157 return Ok(());
1158 }
1159 let tracks = self
1160 .timelines
1161 .get(handle)
1162 .map_or(0, |timeline| timeline.tracks.len());
1163 self.ensure_active_capacity(tracks)?;
1164 let timeline = self.timeline_mut(handle)?;
1165 timeline.started = now;
1166 timeline.state = MotionPlaybackState::Playing;
1167 timeline.policy_settled = None;
1168 Ok(())
1169 }
1170
1171 pub fn pause_timeline(
1177 &mut self,
1178 handle: &MotionHandle,
1179 now: Instant,
1180 ) -> Result<(), MotionError> {
1181 self.validate_handle(handle)?;
1182 let suspended = self.handle_suspended(handle);
1183 let timeline = self.timeline_mut(handle)?;
1184 if timeline.state == MotionPlaybackState::Playing {
1185 if !suspended {
1186 timeline.elapsed_before_play = timeline_position(timeline, now);
1187 }
1188 timeline.state = MotionPlaybackState::Paused;
1189 }
1190 Ok(())
1191 }
1192
1193 pub fn seek_timeline(
1199 &mut self,
1200 handle: &MotionHandle,
1201 position_ms: u64,
1202 now: Instant,
1203 ) -> Result<(), MotionError> {
1204 self.validate_handle(handle)?;
1205 let timeline = self.timeline_mut(handle)?;
1206 let total = timeline_total_duration(timeline);
1207 if total.is_some_and(|total| position_ms > total) {
1208 return Err(MotionError::InvalidTimeline(format!(
1209 "seek position {position_ms}ms exceeds timeline duration {}ms",
1210 total.unwrap_or_default()
1211 )));
1212 }
1213 timeline.elapsed_before_play = Duration::from_millis(position_ms);
1214 timeline.started = now;
1215 if matches!(
1216 timeline.state,
1217 MotionPlaybackState::Completed | MotionPlaybackState::Idle
1218 ) {
1219 timeline.state = MotionPlaybackState::Paused;
1220 }
1221 Ok(())
1222 }
1223
1224 pub fn restart_timeline(
1230 &mut self,
1231 handle: &MotionHandle,
1232 now: Instant,
1233 ) -> Result<(), MotionError> {
1234 self.validate_handle(handle)?;
1235 if matches!(
1236 self.preference,
1237 Some(MotionPreference::Reduced | MotionPreference::None)
1238 ) {
1239 let preference = self.preference;
1240 let timeline = self.timeline_mut(handle)?;
1241 timeline.elapsed_before_play = Duration::from_millis(
1242 timeline_total_duration(timeline).unwrap_or(timeline.duration_ms),
1243 );
1244 timeline.state = MotionPlaybackState::Completed;
1245 timeline.policy_settled = preference;
1246 return Ok(());
1247 }
1248 let tracks = self
1249 .timelines
1250 .get(handle)
1251 .map_or(0, |timeline| timeline.tracks.len());
1252 let already_active = self
1253 .timelines
1254 .get(handle)
1255 .is_some_and(|timeline| timeline.state == MotionPlaybackState::Playing);
1256 if !already_active {
1257 self.ensure_active_capacity(tracks)?;
1258 }
1259 let timeline = self.timeline_mut(handle)?;
1260 timeline.elapsed_before_play = Duration::ZERO;
1261 timeline.started = now;
1262 timeline.state = MotionPlaybackState::Playing;
1263 timeline.policy_settled = None;
1264 Ok(())
1265 }
1266
1267 pub fn cancel_timeline(&mut self, handle: &MotionHandle) -> Result<(), MotionError> {
1273 self.validate_handle(handle)?;
1274 let callback = {
1275 let timeline = self.timeline_mut(handle)?;
1276 if matches!(
1277 timeline.state,
1278 MotionPlaybackState::Cancelled | MotionPlaybackState::Completed
1279 ) {
1280 return Ok(());
1281 }
1282 timeline.state = MotionPlaybackState::Cancelled;
1283 timeline.spec.on_cancel.clone()
1284 };
1285 self.timeline_events.push(MotionTimelineEvent {
1286 handle: handle.clone(),
1287 kind: MotionTimelineEventKind::Cancel,
1288 callback,
1289 });
1290 Ok(())
1291 }
1292
1293 #[must_use]
1294 pub fn timeline_state(&self, handle: &MotionHandle) -> Option<MotionPlaybackState> {
1295 (handle.runtime_id == self.runtime_id)
1296 .then(|| self.timelines.get(handle).map(|timeline| timeline.state))
1297 .flatten()
1298 }
1299
1300 pub fn timeline_handle_for_owner(
1306 &self,
1307 domain: &str,
1308 owner: &ComponentInstancePath,
1309 incarnation: ComponentIncarnation,
1310 generation: ScriptGeneration,
1311 name: &str,
1312 ) -> Result<MotionHandle, MotionError> {
1313 let mut matches = self.timelines.keys().filter(|handle| {
1314 handle.name == name
1315 && handle.domain == domain
1316 && &handle.owner == owner
1317 && handle.incarnation == incarnation
1318 && handle.generation == generation
1319 });
1320 let handle = matches
1321 .next()
1322 .cloned()
1323 .ok_or_else(|| MotionError::UnknownTimeline(name.to_owned()))?;
1324 if matches.next().is_some() {
1325 return Err(MotionError::InvalidTimeline(format!(
1326 "timeline name `{name}` is duplicated for component `{owner}`"
1327 )));
1328 }
1329 Ok(handle)
1330 }
1331
1332 pub fn drain_timeline_events(&mut self) -> Vec<MotionTimelineEvent> {
1333 std::mem::take(&mut self.timeline_events)
1334 }
1335
1336 pub fn drain_timeline_events_for_domain(&mut self, domain: &str) -> Vec<MotionTimelineEvent> {
1337 let events = std::mem::take(&mut self.timeline_events);
1338 let (matching, retained) = events
1339 .into_iter()
1340 .partition(|event| event.handle.domain == domain);
1341 self.timeline_events = retained;
1342 matching
1343 }
1344
1345 pub(crate) fn discard_timeline_events_in_scope(&mut self, path: &str) {
1346 self.timeline_events
1347 .retain(|event| !event.handle.in_node_scope(path));
1348 }
1349
1350 pub(crate) fn prepend_timeline_events(&mut self, mut events: Vec<MotionTimelineEvent>) {
1351 events.append(&mut self.timeline_events);
1352 self.timeline_events = events;
1353 }
1354
1355 #[must_use]
1356 pub fn resource_usage(&self) -> MotionResourceUsage {
1357 let geometry_slots = self.active_reservations.values().copied().sum::<usize>();
1358 MotionResourceUsage {
1359 particles: 0,
1360 shared_snapshots: 0,
1361 geometry_slots,
1362 active: self.active.len().saturating_add(
1363 self.timelines
1364 .values()
1365 .filter(|timeline| timeline.state == MotionPlaybackState::Playing)
1366 .map(|timeline| timeline.tracks.len())
1367 .sum::<usize>(),
1368 ),
1369 timelines: self.timelines.len(),
1370 timeline_steps: self
1371 .timelines
1372 .values()
1373 .map(|timeline| timeline_step_count(&timeline.spec.root))
1374 .sum(),
1375 keyframes: self
1376 .active
1377 .values()
1378 .map(|motion| source_keyframe_count(&motion.source))
1379 .chain(self.timelines.values().flat_map(|timeline| {
1380 timeline
1381 .tracks
1382 .iter()
1383 .map(|track| source_keyframe_count(&track.source))
1384 }))
1385 .sum(),
1386 declarations: self.active.len(),
1387 }
1388 }
1389
1390 fn timeline_mut(&mut self, handle: &MotionHandle) -> Result<&mut ActiveTimeline, MotionError> {
1391 self.timelines
1392 .get_mut(handle)
1393 .ok_or_else(|| MotionError::StaleTimelineHandle(handle.name.clone()))
1394 }
1395
1396 fn validate_handle(&self, handle: &MotionHandle) -> Result<(), MotionError> {
1397 if handle.runtime_id == self.runtime_id && self.timelines.contains_key(handle) {
1398 Ok(())
1399 } else {
1400 Err(MotionError::StaleTimelineHandle(handle.name.clone()))
1401 }
1402 }
1403
1404 pub(crate) fn validate_handle_owner(
1405 &self,
1406 handle: &MotionHandle,
1407 domain: &str,
1408 owner: &ComponentInstancePath,
1409 incarnation: ComponentIncarnation,
1410 generation: ScriptGeneration,
1411 ) -> Result<(), MotionError> {
1412 self.validate_handle(handle)?;
1413 if handle.domain == domain
1414 && &handle.owner == owner
1415 && handle.incarnation == incarnation
1416 && handle.generation == generation
1417 {
1418 Ok(())
1419 } else {
1420 Err(MotionError::ForeignTimelineHandle(handle.name.clone()))
1421 }
1422 }
1423
1424 fn ensure_active_capacity(&self, additional: usize) -> Result<(), MotionError> {
1425 let usage = self.resource_usage();
1426 let actual = usage
1427 .active
1428 .saturating_add(usage.geometry_slots)
1429 .saturating_add(additional);
1430 if actual <= self.active_limit {
1431 Ok(())
1432 } else {
1433 Err(MotionError::ActiveBudget {
1434 actual,
1435 limit: self.active_limit,
1436 })
1437 }
1438 }
1439
1440 pub(crate) fn set_active_limit(&mut self, limit: usize) {
1441 self.active_limit = limit;
1442 }
1443
1444 pub(crate) fn set_active_reservation(&mut self, path: &str, slots: usize) {
1445 if slots == 0 {
1446 self.active_reservations.remove(path);
1447 } else {
1448 self.active_reservations.insert(path.to_owned(), slots);
1449 }
1450 }
1451
1452 pub fn retain_keys(&mut self, keys: &BTreeSet<MotionKey>) {
1453 self.active.retain(|key, _| keys.contains(key));
1454 self.settled.retain(|key, _| keys.contains(key));
1455 self.settled_declaration.retain(|key, _| keys.contains(key));
1456 self.settled_retention.retain(|key, _| keys.contains(key));
1457 }
1458
1459 pub fn retain_node_scope(&mut self, path: &str, keys: &BTreeSet<MotionKey>) {
1460 self.active
1461 .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
1462 self.settled
1463 .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
1464 self.settled_declaration
1465 .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
1466 self.settled_retention
1467 .retain(|key, _| !key.in_node_scope(path) || keys.contains(key));
1468 }
1469
1470 fn retain_live_scope(&mut self, domain: &str, keys: &BTreeSet<MotionKey>) {
1471 self.active.retain(|key, motion| {
1472 !matches!(&motion.retention, MotionRetention::Live(owner) if owner == domain)
1473 || keys.contains(key)
1474 });
1475 let removed = self
1476 .settled_retention
1477 .iter()
1478 .filter(|(_, retention)| {
1479 matches!(retention, MotionRetention::Live(owner) if owner == domain)
1480 })
1481 .map(|(key, _)| key.clone())
1482 .filter(|key| !keys.contains(key))
1483 .collect::<Vec<_>>();
1484 for key in removed {
1485 self.settled.remove(&key);
1486 self.settled_declaration.remove(&key);
1487 self.settled_retention.remove(&key);
1488 }
1489 }
1490
1491 fn reset_live_scope(&mut self, domain: &str) {
1492 self.active.retain(
1493 |_, motion| !matches!(&motion.retention, MotionRetention::Live(owner) if owner == domain),
1494 );
1495 let keys = self
1496 .settled_retention
1497 .iter()
1498 .filter(|(_, retention)| {
1499 matches!(retention, MotionRetention::Live(owner) if owner == domain)
1500 })
1501 .map(|(key, _)| key.clone())
1502 .collect::<Vec<_>>();
1503 for key in keys {
1504 self.settled.remove(&key);
1505 self.settled_declaration.remove(&key);
1506 self.settled_retention.remove(&key);
1507 }
1508 let handles = self
1509 .timelines
1510 .keys()
1511 .filter(|handle| handle.domain == domain)
1512 .cloned()
1513 .collect::<Vec<_>>();
1514 for handle in handles {
1515 if let Some(timeline) = self.timelines.remove(&handle)
1516 && !matches!(
1517 timeline.state,
1518 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
1519 )
1520 {
1521 self.timeline_events.push(MotionTimelineEvent {
1522 handle: handle.clone(),
1523 kind: MotionTimelineEventKind::Cancel,
1524 callback: timeline.spec.on_cancel,
1525 });
1526 }
1527 self.timeline_index.retain(|_, active| active != &handle);
1528 }
1529 }
1530
1531 pub fn retain_timeline_scope(&mut self, path: &str, handles: &BTreeSet<MotionHandle>) {
1532 let removed = self
1533 .timelines
1534 .keys()
1535 .filter(|handle| handle.in_node_scope(path) && !handles.contains(*handle))
1536 .cloned()
1537 .collect::<Vec<_>>();
1538 for handle in removed {
1539 if let Some(timeline) = self.timelines.remove(&handle)
1540 && !matches!(
1541 timeline.state,
1542 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
1543 )
1544 {
1545 self.timeline_events.push(MotionTimelineEvent {
1546 handle: handle.clone(),
1547 kind: MotionTimelineEventKind::Cancel,
1548 callback: timeline.spec.on_cancel,
1549 });
1550 }
1551 self.timeline_index.retain(|_, active| active != &handle);
1552 }
1553 }
1554
1555 fn retain_timeline_plan_scope(&mut self, path: &str, logical: &BTreeSet<(String, String)>) {
1556 let removed = self
1557 .timelines
1558 .keys()
1559 .filter(|handle| {
1560 handle.in_node_scope(path)
1561 && !logical.contains(&(handle.node_path.clone(), handle.name.clone()))
1562 })
1563 .cloned()
1564 .collect::<Vec<_>>();
1565 for handle in removed {
1566 if let Some(timeline) = self.timelines.remove(&handle)
1567 && !matches!(
1568 timeline.state,
1569 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
1570 )
1571 {
1572 self.timeline_events.push(MotionTimelineEvent {
1573 handle: handle.clone(),
1574 kind: MotionTimelineEventKind::Cancel,
1575 callback: timeline.spec.on_cancel,
1576 });
1577 }
1578 self.timeline_index.retain(|_, active| active != &handle);
1579 }
1580 }
1581
1582 pub fn cancel_node_scope(&mut self, path: &str) {
1583 self.active.retain(|key, _| !key.in_node_scope(path));
1584 self.settled.retain(|key, _| !key.in_node_scope(path));
1585 self.settled_declaration
1586 .retain(|key, _| !key.in_node_scope(path));
1587 self.settled_retention
1588 .retain(|key, _| !key.in_node_scope(path));
1589 self.active_reservations
1590 .retain(|scope, _| !path_contains_scope(path, scope));
1591 self.live_scene_signatures
1592 .retain(|scope, _| !path_contains_scope(path, scope));
1593 let removed = self
1594 .timelines
1595 .keys()
1596 .filter(|handle| handle.in_node_scope(path))
1597 .cloned()
1598 .collect::<Vec<_>>();
1599 for handle in removed {
1600 if let Some(timeline) = self.timelines.remove(&handle)
1601 && !matches!(
1602 timeline.state,
1603 MotionPlaybackState::Completed | MotionPlaybackState::Cancelled
1604 )
1605 {
1606 self.timeline_events.push(MotionTimelineEvent {
1607 handle: handle.clone(),
1608 kind: MotionTimelineEventKind::Cancel,
1609 callback: timeline.spec.on_cancel,
1610 });
1611 }
1612 self.timeline_index.retain(|_, active| active != &handle);
1613 }
1614 }
1615
1616 pub(crate) fn release_node_scope(&mut self, path: &str) {
1617 self.cancel_node_scope(path);
1618 self.suspended_scopes
1619 .retain(|scope| !path_contains_scope(path, scope));
1620 self.discard_timeline_events_in_scope(path);
1621 }
1622
1623 #[must_use]
1624 pub fn is_node_scope_active(&self, path: &str) -> bool {
1625 self.active.keys().any(|key| key.in_node_scope(path))
1626 || self.timelines.keys().any(|handle| {
1627 handle.in_node_scope(path)
1628 && self
1629 .timelines
1630 .get(handle)
1631 .is_some_and(|timeline| timeline.state == MotionPlaybackState::Playing)
1632 })
1633 }
1634
1635 fn key_suspended(&self, key: &MotionKey) -> bool {
1636 self.suspended_scopes
1637 .iter()
1638 .any(|scope| key.in_node_scope(scope))
1639 }
1640
1641 fn handle_suspended(&self, handle: &MotionHandle) -> bool {
1642 self.suspended_scopes
1643 .iter()
1644 .any(|scope| handle.in_node_scope(scope))
1645 }
1646
1647 pub fn suspend_node_scope(&mut self, path: &str, now: Instant) {
1648 if !self.suspended_scopes.insert(path.to_owned()) {
1649 return;
1650 }
1651 for (key, motion) in &mut self.active {
1652 if !key.in_node_scope(path) {
1653 continue;
1654 }
1655 motion.elapsed_before_play = motion_elapsed(motion, now);
1656 }
1657 for (handle, timeline) in &mut self.timelines {
1658 if handle.in_node_scope(path) && timeline.state == MotionPlaybackState::Playing {
1659 timeline.elapsed_before_play = timeline_position(timeline, now);
1660 }
1661 }
1662 }
1663
1664 pub fn resume_node_scope(&mut self, path: &str, now: Instant) {
1665 if !self.suspended_scopes.remove(path) {
1666 return;
1667 }
1668 for (key, motion) in &mut self.active {
1669 if key.in_node_scope(path) {
1670 motion.started = now;
1671 }
1672 }
1673 for (handle, timeline) in &mut self.timelines {
1674 if handle.in_node_scope(path) && timeline.state == MotionPlaybackState::Playing {
1675 timeline.started = now;
1676 }
1677 }
1678 }
1679
1680 pub fn delay_node_scope(&mut self, path: &str, delay: Duration) {
1682 if delay.is_zero() {
1683 return;
1684 }
1685 for (key, motion) in &mut self.active {
1686 if key.in_node_scope(path) {
1687 motion.started = motion.started.checked_add(delay).unwrap_or(motion.started);
1688 }
1689 }
1690 for (handle, timeline) in &mut self.timelines {
1691 if handle.in_node_scope(path) {
1692 timeline.started = timeline
1693 .started
1694 .checked_add(delay)
1695 .unwrap_or(timeline.started);
1696 }
1697 }
1698 }
1699
1700 #[must_use]
1701 pub fn snapshot(&self, now: Instant) -> BTreeMap<MotionKey, f64> {
1702 let mut values = self
1703 .active
1704 .keys()
1705 .chain(self.settled.keys())
1706 .filter_map(|key| self.sample(key, now).map(|value| (key.clone(), value)))
1707 .collect::<BTreeMap<_, _>>();
1708 for (handle, timeline) in &self.timelines {
1709 values.extend(
1710 sample_timeline(handle, timeline, now, self.handle_suspended(handle)).values,
1711 );
1712 }
1713 values
1714 }
1715
1716 #[must_use]
1717 pub fn inspect(&self, now: Instant) -> Vec<MotionSnapshot> {
1718 let mut snapshots = self
1719 .active
1720 .iter()
1721 .map(|(key, motion)| {
1722 let sample = sample_active_motion(motion, now, self.key_suspended(key));
1723 let (kind, duration, repeating) = match &motion.source {
1724 MotionSource::Transition(spec) => (
1725 "transition",
1726 Some(spec.duration_ms),
1727 spec.iterations.is_none_or(|count| count > 1),
1728 ),
1729 MotionSource::Spring(_) => ("spring", None, false),
1730 MotionSource::Keyframes(spec) => (
1731 "keyframes",
1732 Some(spec.duration_ms),
1733 spec.iterations.is_none_or(|count| count > 1),
1734 ),
1735 MotionSource::Inertia(_) => ("inertia", None, false),
1736 };
1737 MotionSnapshot {
1738 key: key.clone(),
1739 kind: kind.to_owned(),
1740 value: sample.value,
1741 target: source_terminal_value(&motion.source),
1742 velocity: sample.velocity,
1743 elapsed_ms: duration_ms(if self.key_suspended(key) {
1744 motion.elapsed_before_play
1745 } else {
1746 motion_elapsed(motion, now)
1747 }),
1748 duration_ms: duration,
1749 repeating,
1750 active: true,
1751 }
1752 })
1753 .collect::<Vec<_>>();
1754 snapshots.extend(self.settled.iter().map(|(key, value)| MotionSnapshot {
1755 key: key.clone(),
1756 kind: "settled".to_owned(),
1757 value: *value,
1758 target: *value,
1759 velocity: None,
1760 elapsed_ms: 0,
1761 duration_ms: None,
1762 repeating: false,
1763 active: false,
1764 }));
1765 snapshots.sort_by(|left, right| left.key.cmp(&right.key));
1766 snapshots
1767 }
1768
1769 #[must_use]
1770 pub fn inspect_timelines(&self, now: Instant) -> Vec<MotionTimelineSnapshot> {
1771 self.timelines
1772 .iter()
1773 .map(|(handle, timeline)| {
1774 let active_elapsed = if timeline.state == MotionPlaybackState::Playing
1775 && !self.handle_suspended(handle)
1776 {
1777 now.saturating_duration_since(timeline.started)
1778 } else {
1779 Duration::ZERO
1780 };
1781 let elapsed = timeline.elapsed_before_play.saturating_add(active_elapsed);
1782 MotionTimelineSnapshot {
1783 handle: handle.clone(),
1784 state: timeline.state,
1785 elapsed_ms: duration_ms(elapsed),
1786 duration_ms: timeline_total_duration(timeline),
1787 iterations: timeline.spec.iterations,
1788 autoreverse: timeline.spec.autoreverse,
1789 track_count: timeline.tracks.len(),
1790 }
1791 })
1792 .collect()
1793 }
1794
1795 #[must_use]
1796 pub fn sample(&self, key: &MotionKey, now: Instant) -> Option<f64> {
1797 self.sample_with_velocity(key, now)
1798 .map(|sample| sample.value)
1799 }
1800
1801 fn sample_with_velocity(&self, key: &MotionKey, now: Instant) -> Option<SourceSample> {
1802 self.active
1803 .get(key)
1804 .map(|motion| sample_active_motion(motion, now, self.key_suspended(key)))
1805 .or_else(|| {
1806 self.settled.get(key).copied().map(|value| SourceSample {
1807 value,
1808 velocity: None,
1809 done: true,
1810 })
1811 })
1812 }
1813
1814 #[must_use]
1815 pub fn tick(&mut self, now: Instant) -> MotionFrame {
1816 self.tick_filtered(now, None)
1817 }
1818
1819 #[must_use]
1820 pub fn tick_scope(&mut self, now: Instant, path: &str) -> MotionFrame {
1821 self.tick_filtered(now, Some(path))
1822 }
1823
1824 fn tick_filtered(&mut self, now: Instant, scope: Option<&str>) -> MotionFrame {
1825 let mut values = BTreeMap::new();
1826 let mut completed = BTreeSet::new();
1827 for (key, animation) in &mut self.active {
1828 if scope.is_some_and(|scope| !key.in_node_scope(scope)) {
1829 continue;
1830 }
1831 if self
1832 .suspended_scopes
1833 .iter()
1834 .any(|scope| key.in_node_scope(scope))
1835 {
1836 values.insert(
1837 key.clone(),
1838 sample_active_motion(animation, now, true).value,
1839 );
1840 continue;
1841 }
1842 let sample = sample_active_motion(animation, now, false);
1843 values.insert(key.clone(), sample.value);
1844 if sample.done {
1845 completed.insert(key.clone());
1846 }
1847 }
1848 for key in &completed {
1849 if let Some(animation) = self.active.remove(key) {
1850 let sample = sample_active_motion(&animation, now, false);
1851 self.settled_declaration
1852 .insert(key.clone(), (animation.declaration, animation.replay_key));
1853 self.settled_retention
1854 .insert(key.clone(), animation.retention);
1855 self.settled.insert(key.clone(), sample.value);
1856 }
1857 }
1858 let mut completed_timelines = Vec::new();
1859 let suspended_scopes = &self.suspended_scopes;
1860 for (handle, timeline) in &mut self.timelines {
1861 if scope.is_some_and(|scope| !handle.in_node_scope(scope)) {
1862 continue;
1863 }
1864 let suspended = suspended_scopes
1865 .iter()
1866 .any(|scope| handle.in_node_scope(scope));
1867 let sample = sample_timeline(handle, timeline, now, suspended);
1868 values.extend(sample.values);
1869 if sample.done && !suspended && timeline.state == MotionPlaybackState::Playing {
1870 completed_timelines.push(handle.clone());
1871 }
1872 }
1873 for handle in completed_timelines {
1874 let callback = self.timelines.get_mut(&handle).and_then(|timeline| {
1875 timeline.elapsed_before_play = Duration::from_millis(
1876 timeline_total_duration(timeline).unwrap_or(timeline.duration_ms),
1877 );
1878 timeline.state = MotionPlaybackState::Completed;
1879 timeline.spec.on_complete.clone()
1880 });
1881 self.timeline_events.push(MotionTimelineEvent {
1882 handle,
1883 kind: MotionTimelineEventKind::Complete,
1884 callback,
1885 });
1886 }
1887 MotionFrame {
1888 values,
1889 completed,
1890 needs_frame: self.active.keys().any(|key| {
1891 scope.is_none_or(|scope| key.in_node_scope(scope))
1892 && !self
1893 .suspended_scopes
1894 .iter()
1895 .any(|suspended| key.in_node_scope(suspended))
1896 }) || self.timelines.iter().any(|(handle, timeline)| {
1897 timeline.state == MotionPlaybackState::Playing
1898 && scope.is_none_or(|scope| handle.in_node_scope(scope))
1899 && !self
1900 .suspended_scopes
1901 .iter()
1902 .any(|suspended| handle.in_node_scope(suspended))
1903 }),
1904 }
1905 }
1906}
1907
1908fn timeline_compatible(previous: &MotionTimeline, next: &MotionTimeline) -> bool {
1909 previous.name == next.name
1910 && previous.root == next.root
1911 && previous.autoplay == next.autoplay
1912 && previous.iterations == next.iterations
1913 && previous.autoreverse == next.autoreverse
1914 && previous.intent == next.intent
1915}
1916
1917struct TimelineSample {
1918 values: BTreeMap<MotionKey, f64>,
1919 done: bool,
1920}
1921
1922fn sample_timeline(
1923 handle: &MotionHandle,
1924 timeline: &ActiveTimeline,
1925 now: Instant,
1926 suspended: bool,
1927) -> TimelineSample {
1928 if timeline.state == MotionPlaybackState::Cancelled {
1929 return TimelineSample {
1930 values: BTreeMap::new(),
1931 done: false,
1932 };
1933 }
1934 if let Some(preference) = timeline.policy_settled {
1935 if preference == MotionPreference::None
1936 && let Some(total) = timeline_total_duration(timeline)
1937 {
1938 return sample_timeline_position(handle, timeline, Duration::from_millis(total));
1939 }
1940 let values = timeline
1941 .tracks
1942 .iter()
1943 .map(|track| {
1944 let key = timeline_track_key(handle, track);
1945 let value = track.source.reduced_value();
1946 (key, value)
1947 })
1948 .collect();
1949 return TimelineSample { values, done: true };
1950 }
1951 let elapsed = if suspended {
1952 timeline.elapsed_before_play
1953 } else {
1954 timeline_position(timeline, now)
1955 };
1956 sample_timeline_position(handle, timeline, elapsed)
1957}
1958
1959fn sample_timeline_position(
1960 handle: &MotionHandle,
1961 timeline: &ActiveTimeline,
1962 elapsed: Duration,
1963) -> TimelineSample {
1964 let elapsed_ms = duration_ms(elapsed);
1965 let iteration_duration = timeline.duration_ms.max(1);
1966 let raw_iteration = elapsed_ms / iteration_duration;
1967 let done = timeline
1968 .spec
1969 .iterations
1970 .is_some_and(|iterations| raw_iteration >= u64::from(iterations));
1971 let iteration = if done {
1972 u64::from(timeline.spec.iterations.unwrap_or(1).saturating_sub(1))
1973 } else {
1974 raw_iteration
1975 };
1976 let mut local_ms = if done {
1977 iteration_duration
1978 } else {
1979 elapsed_ms % iteration_duration
1980 };
1981 if timeline.spec.autoreverse && iteration % 2 == 1 {
1982 local_ms = iteration_duration.saturating_sub(local_ms);
1983 }
1984 let mut values = BTreeMap::new();
1985 for track in &timeline.tracks {
1986 let key = timeline_track_key(handle, track);
1987 if local_ms < track.start_ms {
1988 values
1989 .entry(key)
1990 .or_insert_with(|| source_initial_value(&track.source));
1991 } else {
1992 let track_elapsed = local_ms.saturating_sub(track.start_ms);
1993 values.insert(
1994 key,
1995 sample_source_with_duration(
1996 &track.source,
1997 Duration::from_millis(track_elapsed),
1998 Some(track.duration_ms),
1999 )
2000 .value,
2001 );
2002 }
2003 }
2004 TimelineSample { values, done }
2005}
2006
2007fn timeline_position(timeline: &ActiveTimeline, now: Instant) -> Duration {
2008 if timeline.state == MotionPlaybackState::Playing {
2009 timeline
2010 .elapsed_before_play
2011 .saturating_add(now.saturating_duration_since(timeline.started))
2012 } else {
2013 timeline.elapsed_before_play
2014 }
2015}
2016
2017fn timeline_track_key(handle: &MotionHandle, track: &ScheduledMotionTrack) -> MotionKey {
2018 let root = handle.node_path.as_str();
2019 let path = track
2020 .resolved_path
2021 .clone()
2022 .unwrap_or_else(|| match track.target.as_str() {
2023 "." | "" => root.to_owned(),
2024 target if target.starts_with('/') => target.trim_start_matches('/').to_owned(),
2025 target => format!("{root}/{target}"),
2026 });
2027 MotionKey::for_node(&path, track.source.property())
2028}
2029
2030fn timeline_total_duration(timeline: &ActiveTimeline) -> Option<u64> {
2031 timeline
2032 .spec
2033 .iterations
2034 .map(|iterations| timeline.duration_ms.saturating_mul(u64::from(iterations)))
2035}
2036
2037fn compile_timeline(
2038 root: &MotionTimelineStep,
2039) -> Result<(Vec<ScheduledMotionTrack>, u64), MotionError> {
2040 let mut tracks = Vec::new();
2041 let duration_ms = flatten_timeline(root, 0, &mut tracks)?;
2042 if tracks.is_empty() {
2043 return Err(MotionError::InvalidTimeline(
2044 "timeline must contain at least one motion track".to_owned(),
2045 ));
2046 }
2047 let mut occupied = BTreeMap::<(String, MotionProperty), Vec<(u64, u64)>>::new();
2048 for track in &tracks {
2049 let key = (track.target.clone(), track.source.property());
2050 let end = track.start_ms.saturating_add(track.duration_ms);
2051 let intervals = occupied.entry(key.clone()).or_default();
2052 if intervals
2053 .iter()
2054 .any(|(start, existing_end)| track.start_ms < *existing_end && *start < end)
2055 {
2056 return Err(MotionError::InvalidTimeline(format!(
2057 "overlapping tracks target `{}` property `{}`",
2058 key.0,
2059 key.1.as_str()
2060 )));
2061 }
2062 intervals.push((track.start_ms, end));
2063 }
2064 Ok((tracks, duration_ms.max(1)))
2065}
2066
2067fn flatten_timeline(
2068 step: &MotionTimelineStep,
2069 start_ms: u64,
2070 output: &mut Vec<ScheduledMotionTrack>,
2071) -> Result<u64, MotionError> {
2072 match step {
2073 MotionTimelineStep::Track(track) => {
2074 validate_source(&track.source)?;
2075 validate_timeline_target(&track.target)?;
2076 let duration_ms = source_duration_ms(&track.source)?;
2077 output.push(ScheduledMotionTrack {
2078 target: track.target.clone(),
2079 resolved_path: None,
2080 source: track.source.clone(),
2081 start_ms,
2082 duration_ms,
2083 });
2084 Ok(start_ms.saturating_add(duration_ms))
2085 }
2086 MotionTimelineStep::Delay(duration_ms) => Ok(start_ms.saturating_add(*duration_ms)),
2087 MotionTimelineStep::Sequence(steps) => {
2088 let mut cursor = start_ms;
2089 for step in steps {
2090 cursor = flatten_timeline(step, cursor, output)?;
2091 }
2092 Ok(cursor)
2093 }
2094 MotionTimelineStep::Parallel(steps) => {
2095 let mut end = start_ms;
2096 for step in steps {
2097 end = end.max(flatten_timeline(step, start_ms, output)?);
2098 }
2099 Ok(end)
2100 }
2101 MotionTimelineStep::Stagger { interval_ms, steps } => {
2102 let mut end = start_ms;
2103 for (index, step) in steps.iter().enumerate() {
2104 let offset = interval_ms.saturating_mul(u64::try_from(index).unwrap_or(u64::MAX));
2105 end = end.max(flatten_timeline(
2106 step,
2107 start_ms.saturating_add(offset),
2108 output,
2109 )?);
2110 }
2111 Ok(end)
2112 }
2113 }
2114}
2115
2116fn source_duration_ms(source: &MotionSource) -> Result<u64, MotionError> {
2117 match source {
2118 MotionSource::Transition(spec) => spec
2119 .iterations
2120 .map(|iterations| {
2121 spec.delay_ms
2122 .saturating_add(spec.duration_ms.saturating_mul(u64::from(iterations)))
2123 })
2124 .ok_or_else(|| {
2125 MotionError::InvalidTimeline(
2126 "an infinite transition cannot be nested in a timeline".to_owned(),
2127 )
2128 }),
2129 MotionSource::Keyframes(spec) => spec
2130 .iterations
2131 .map(|iterations| {
2132 spec.delay_ms
2133 .saturating_add(spec.duration_ms.saturating_mul(u64::from(iterations)))
2134 })
2135 .ok_or_else(|| {
2136 MotionError::InvalidTimeline(
2137 "infinite keyframes cannot be nested in a timeline".to_owned(),
2138 )
2139 }),
2140 MotionSource::Spring(spec) => Ok(spring_settle_ms(spec)),
2141 MotionSource::Inertia(spec) => Ok(inertia_settle_ms(spec)),
2142 }
2143}
2144
2145pub(crate) fn sample_progress_source(source: &MotionSource, progress: f64) -> f64 {
2146 let progress = progress.clamp(0.0, 1.0);
2147 match source {
2148 MotionSource::Transition(spec) => {
2149 spec.from + (spec.to - spec.from) * spec.easing.sample(progress)
2150 }
2151 MotionSource::Keyframes(spec) => sample_keyframes(&spec.frames, progress),
2152 MotionSource::Spring(spec) => spec.from + (spec.to - spec.from) * progress,
2153 MotionSource::Inertia(spec) => spec.from + (inertia_target(spec) - spec.from) * progress,
2154 }
2155}
2156
2157pub(crate) fn progress_source_duration(source: &MotionSource) -> Duration {
2158 Duration::from_millis(match source {
2159 MotionSource::Transition(spec) => spec.duration_ms,
2160 MotionSource::Keyframes(spec) => spec.duration_ms,
2161 MotionSource::Spring(_) | MotionSource::Inertia(_) => 1,
2162 })
2163}
2164
2165fn validate_progress_source(source: &MotionSource) -> Result<(), MotionError> {
2166 validate_source(source)?;
2167 match source {
2168 MotionSource::Transition(spec)
2169 if spec.delay_ms == 0 && spec.iterations == Some(1) && !spec.autoreverse =>
2170 {
2171 Ok(())
2172 }
2173 MotionSource::Keyframes(spec)
2174 if spec.delay_ms == 0 && spec.iterations == Some(1) && !spec.autoreverse =>
2175 {
2176 Ok(())
2177 }
2178 MotionSource::Transition(_) | MotionSource::Keyframes(_) => {
2179 Err(MotionError::InvalidProgressSource(
2180 "progress mappings cannot use delay, repeat, or autoreverse".to_owned(),
2181 ))
2182 }
2183 MotionSource::Spring(_) | MotionSource::Inertia(_) => {
2184 Err(MotionError::InvalidProgressSource(
2185 "progress mappings must use transition or keyframes".to_owned(),
2186 ))
2187 }
2188 }
2189}
2190
2191fn sample_spring_at(spec: &MotionSpring, seconds: f64) -> (f64, f64) {
2192 let omega = (spec.stiffness / spec.mass).sqrt();
2193 let zeta = spec.damping / (2.0 * (spec.stiffness * spec.mass).sqrt());
2194 let displacement = spec.from - spec.to;
2195 if zeta < 1.0 - 1.0e-6 {
2196 let damped = omega * (1.0 - zeta * zeta).sqrt();
2197 let coefficient = (spec.initial_velocity + zeta * omega * displacement) / damped;
2198 let decay = (-zeta * omega * seconds).exp();
2199 let cos = (damped * seconds).cos();
2200 let sin = (damped * seconds).sin();
2201 let y = decay * (displacement * cos + coefficient * sin);
2202 let velocity = decay
2203 * (-zeta * omega * (displacement * cos + coefficient * sin)
2204 + (-displacement * damped * sin + coefficient * damped * cos));
2205 (spec.to + y, velocity)
2206 } else if (zeta - 1.0).abs() <= 1.0e-6 {
2207 let coefficient = spec.initial_velocity + omega * displacement;
2208 let decay = (-omega * seconds).exp();
2209 let y = decay * (displacement + coefficient * seconds);
2210 let velocity = decay * (coefficient - omega * (displacement + coefficient * seconds));
2211 (spec.to + y, velocity)
2212 } else {
2213 let root = (zeta * zeta - 1.0).sqrt();
2214 let first = -omega * (zeta - root);
2215 let second = -omega * (zeta + root);
2216 let a = (spec.initial_velocity - second * displacement) / (first - second);
2217 let b = displacement - a;
2218 let a_term = a * (first * seconds).exp();
2219 let b_term = b * (second * seconds).exp();
2220 (spec.to + a_term + b_term, first * a_term + second * b_term)
2221 }
2222}
2223
2224fn spring_settle_ms(spec: &MotionSpring) -> u64 {
2225 (1..=1_200)
2226 .map(|step| step * 8)
2227 .find(|milliseconds| {
2228 let (position, velocity) =
2229 sample_spring_at(spec, Duration::from_millis(*milliseconds).as_secs_f64());
2230 (position - spec.to).abs() < 0.001 && velocity.abs() < 0.001
2231 })
2232 .unwrap_or(9_600)
2233}
2234
2235fn sample_inertia_at(spec: &MotionInertia, elapsed: Duration) -> (f64, f64) {
2236 if spec.min.is_none() && spec.max.is_none() {
2237 let seconds = elapsed.as_secs_f64().min(10.0);
2238 let decay = (-spec.friction * seconds).exp();
2239 let mut position = spec.from + spec.velocity * (1.0 - decay) / spec.friction;
2240 let mut velocity = spec.velocity * decay;
2241 if elapsed >= Duration::from_millis(inertia_settle_ms(spec)) {
2242 position = nearest_snap(position, &spec.snap_points).unwrap_or(position);
2243 velocity = 0.0;
2244 }
2245 return (position, velocity);
2246 }
2247 if spec.bounce <= f64::EPSILON {
2248 let seconds = elapsed.as_secs_f64().min(10.0);
2249 let decay = (-spec.friction * seconds).exp();
2250 let projected = spec.from + spec.velocity * (1.0 - decay) / spec.friction;
2251 let mut position = projected.clamp(
2252 spec.min.unwrap_or(f64::NEG_INFINITY),
2253 spec.max.unwrap_or(f64::INFINITY),
2254 );
2255 let mut velocity = if (position - projected).abs() <= f64::EPSILON {
2256 spec.velocity * decay
2257 } else {
2258 0.0
2259 };
2260 if elapsed >= Duration::from_millis(inertia_settle_ms(spec)) {
2261 position = nearest_snap(position, &spec.snap_points).unwrap_or(position);
2262 velocity = 0.0;
2263 }
2264 return (position, velocity);
2265 }
2266 let mut remaining = elapsed.as_secs_f64().min(10.0);
2267 let mut position = spec.from.clamp(
2268 spec.min.unwrap_or(f64::NEG_INFINITY),
2269 spec.max.unwrap_or(f64::INFINITY),
2270 );
2271 let mut velocity = spec.velocity;
2272 if spec.min.is_some_and(|minimum| spec.from < minimum) {
2273 velocity = velocity.abs() * spec.bounce;
2274 } else if spec.max.is_some_and(|maximum| spec.from > maximum) {
2275 velocity = -velocity.abs() * spec.bounce;
2276 }
2277 for _ in 0..4_096 {
2278 if remaining <= f64::EPSILON || velocity.abs() <= 0.01 {
2279 break;
2280 }
2281 let boundary = if velocity > 0.0 { spec.max } else { spec.min };
2282 let Some(boundary) = boundary else {
2283 let decay = (-spec.friction * remaining).exp();
2284 position += velocity * (1.0 - decay) / spec.friction;
2285 velocity *= decay;
2286 remaining = 0.0;
2287 break;
2288 };
2289 if (position - boundary).abs() <= f64::EPSILON {
2290 velocity = -velocity * spec.bounce;
2291 continue;
2292 }
2293 let distance = boundary - position;
2294 let ratio = distance * spec.friction / velocity;
2295 if !(0.0..1.0).contains(&ratio) {
2296 let decay = (-spec.friction * remaining).exp();
2297 position += velocity * (1.0 - decay) / spec.friction;
2298 velocity *= decay;
2299 remaining = 0.0;
2300 break;
2301 }
2302 let collision_time = -(1.0 - ratio).ln() / spec.friction;
2303 if collision_time >= remaining {
2304 let decay = (-spec.friction * remaining).exp();
2305 position += velocity * (1.0 - decay) / spec.friction;
2306 velocity *= decay;
2307 remaining = 0.0;
2308 break;
2309 }
2310 velocity *= (-spec.friction * collision_time).exp();
2311 position = boundary;
2312 velocity = -velocity * spec.bounce;
2313 remaining -= collision_time;
2314 }
2315 if remaining > f64::EPSILON {
2316 velocity = 0.0;
2317 }
2318 if elapsed >= Duration::from_millis(inertia_settle_ms(spec)) {
2319 position = nearest_snap(position, &spec.snap_points).unwrap_or(position);
2320 velocity = 0.0;
2321 }
2322 (position, velocity)
2323}
2324
2325fn inertia_settle_ms(spec: &MotionInertia) -> u64 {
2326 if spec.velocity.abs() <= 0.01 {
2327 return 1;
2328 }
2329 let seconds = (spec.velocity.abs() / 0.01).ln() / spec.friction;
2330 u64::try_from(Duration::from_secs_f64(seconds.clamp(0.001, 10.0)).as_millis()).unwrap_or(10_000)
2331}
2332
2333fn validate_timeline_name(name: &str) -> Result<(), MotionError> {
2334 if !name.is_empty()
2335 && name
2336 .chars()
2337 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
2338 {
2339 Ok(())
2340 } else {
2341 Err(MotionError::InvalidTimeline(format!(
2342 "timeline name `{name}` must use ASCII letters, digits, `_`, or `-`"
2343 )))
2344 }
2345}
2346
2347fn validate_timeline_target(target: &str) -> Result<(), MotionError> {
2348 if target == "."
2349 || (!target.is_empty()
2350 && target.split('/').all(|segment| {
2351 !segment.is_empty()
2352 && segment.chars().all(|character| {
2353 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':')
2354 })
2355 }))
2356 {
2357 Ok(())
2358 } else {
2359 Err(MotionError::InvalidTimeline(format!(
2360 "timeline target `{target}` is not a stable relative node path"
2361 )))
2362 }
2363}
2364
2365fn duration_ms(duration: Duration) -> u64 {
2366 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
2367}
2368
2369pub fn reconcile_node_motion(
2375 root: &UiNode,
2376 runtime: &mut MotionRuntime,
2377 now: Instant,
2378) -> Result<BTreeMap<MotionKey, f64>, MotionError> {
2379 reconcile_node_motion_scoped(root, runtime, now, "root")
2380}
2381
2382#[derive(Clone, Copy)]
2383pub(crate) struct MotionReconcileContext<'a> {
2384 pub domain: &'a str,
2385 pub root_component: &'a ComponentInstancePath,
2386 pub root_incarnation: ComponentIncarnation,
2387 pub generation: ScriptGeneration,
2388 pub incarnations: &'a BTreeMap<ComponentInstancePath, ComponentIncarnation>,
2389 pub identities: Option<&'a BTreeMap<String, String>>,
2390}
2391
2392pub fn reconcile_node_motion_scoped(
2398 root: &UiNode,
2399 runtime: &mut MotionRuntime,
2400 now: Instant,
2401 root_path: &str,
2402) -> Result<BTreeMap<MotionKey, f64>, MotionError> {
2403 let root_component = ComponentInstancePath::root("UiNode", root_path);
2404 let incarnations = BTreeMap::new();
2405 reconcile_node_motion_scoped_owned(
2406 root,
2407 runtime,
2408 now,
2409 root_path,
2410 MotionReconcileContext {
2411 domain: root_path,
2412 root_component: &root_component,
2413 root_incarnation: ComponentIncarnation::unscoped(),
2414 generation: ScriptGeneration::default(),
2415 incarnations: &incarnations,
2416 identities: None,
2417 },
2418 )
2419}
2420
2421#[allow(clippy::too_many_lines)]
2422pub(crate) fn reconcile_node_motion_scoped_owned(
2423 root: &UiNode,
2424 runtime: &mut MotionRuntime,
2425 now: Instant,
2426 root_path: &str,
2427 context: MotionReconcileContext<'_>,
2428) -> Result<BTreeMap<MotionKey, f64>, MotionError> {
2429 validate_shared_layout_ids(root)?;
2430 let mut declarations = Vec::<(String, MotionSource, Option<String>)>::new();
2431 let mut timelines = Vec::<(String, ComponentInstancePath, MotionTimeline)>::new();
2432 collect_node_motion(
2433 root,
2434 root_path,
2435 context.root_component,
2436 &mut declarations,
2437 &mut timelines,
2438 )?;
2439 validate_motion_plan(root, root_path, &declarations, &timelines)?;
2440 for (_, spec, _) in &declarations {
2441 validate_source(spec)?;
2442 }
2443 let mut candidate = runtime.transaction_snapshot();
2444 let active_limit = candidate.active_limit;
2445 candidate.active_limit = usize::MAX;
2446 if context.identities.is_none() {
2447 let signature = format!(
2448 "{:?}:{}",
2449 root.kind_tag(),
2450 root.key().map_or("<unkeyed>", crate::NodeKey::as_str)
2451 );
2452 if candidate
2453 .live_scene_signatures
2454 .get(context.domain)
2455 .is_some_and(|previous| previous != &signature)
2456 {
2457 candidate.reset_live_scope(context.domain);
2458 }
2459 candidate
2460 .live_scene_signatures
2461 .insert(context.domain.to_owned(), signature);
2462 }
2463 let keys = declarations
2464 .iter()
2465 .map(|(path, source, _)| {
2466 MotionKey::for_node(
2467 context
2468 .identities
2469 .and_then(|identities| identities.get(path))
2470 .map_or(path.as_str(), String::as_str),
2471 source.property(),
2472 )
2473 })
2474 .collect::<BTreeSet<_>>();
2475 candidate.retain_live_scope(context.domain, &keys);
2476 let logical_timelines = timelines
2477 .iter()
2478 .map(|(path, _, timeline)| {
2479 (
2480 context
2481 .identities
2482 .and_then(|identities| identities.get(path))
2483 .cloned()
2484 .unwrap_or_else(|| path.clone()),
2485 timeline.name.clone(),
2486 )
2487 })
2488 .collect::<BTreeSet<_>>();
2489 candidate.retain_timeline_plan_scope(root_path, &logical_timelines);
2490 let mut handles = BTreeSet::new();
2491 for (path, owner, timeline) in timelines {
2492 let incarnation = context
2493 .incarnations
2494 .get(&owner)
2495 .copied()
2496 .unwrap_or(context.root_incarnation);
2497 handles.insert(
2498 candidate.start_timeline_owned(
2499 &TimelineOwner {
2500 domain: context.domain.to_owned(),
2501 component: owner,
2502 incarnation,
2503 generation: context.generation,
2504 node_path: context
2505 .identities
2506 .and_then(|identities| identities.get(&path))
2507 .cloned()
2508 .unwrap_or_else(|| path.clone()),
2509 target_root: path,
2510 },
2511 timeline,
2512 now,
2513 context.identities,
2514 )?,
2515 );
2516 }
2517 for (path, spec, replay_key) in declarations {
2518 let identity = context
2519 .identities
2520 .and_then(|identities| identities.get(&path))
2521 .cloned()
2522 .unwrap_or(path);
2523 candidate.start_with_replay_retained(
2524 ComponentInstancePath::root("UiNode", identity),
2525 spec,
2526 replay_key,
2527 now,
2528 MotionRetention::Live(context.domain.to_owned()),
2529 )?;
2530 }
2531 candidate.retain_timeline_scope(root_path, &handles);
2532 let usage = candidate.resource_usage();
2533 let active = usage.active.saturating_add(usage.geometry_slots);
2534 if active > active_limit {
2535 return Err(MotionError::ActiveBudget {
2536 actual: active,
2537 limit: active_limit,
2538 });
2539 }
2540 candidate.active_limit = active_limit;
2541 let values = candidate.snapshot(now);
2542 *runtime = candidate;
2543 Ok(values)
2544}
2545
2546pub(crate) fn scoped_shared_layout_group(node: &UiNode) -> Option<String> {
2549 let crate::UiValue::String(group) = node.attributes().get("shared_layout_group")? else {
2550 return None;
2551 };
2552 Some(match node.attributes().get("shared_layout_scope") {
2553 Some(crate::UiValue::String(scope)) => format!("{scope}::{group}"),
2554 _ => group.clone(),
2555 })
2556}
2557
2558fn validate_shared_layout_ids(root: &UiNode) -> Result<(), MotionError> {
2559 shared_layout_ids(root).map(|_| ())
2560}
2561
2562pub(crate) fn shared_layout_ids(root: &UiNode) -> Result<BTreeSet<(String, String)>, MotionError> {
2563 fn visit(node: &UiNode, seen: &mut BTreeSet<(String, String)>) -> Result<(), MotionError> {
2564 let group = node.attributes().get("shared_layout_group");
2565 let id = node.attributes().get("shared_layout_id");
2566 match (group, id) {
2567 (Some(crate::UiValue::String(group)), Some(crate::UiValue::String(id))) => {
2568 if group.is_empty() || id.is_empty() {
2569 return Err(MotionError::InvalidSharedLayout(
2570 "shared layout group and id must be non-empty".to_owned(),
2571 ));
2572 }
2573 let scoped = scoped_shared_layout_group(node).unwrap_or_else(|| group.clone());
2574 if !seen.insert((scoped.clone(), id.clone())) {
2575 return Err(MotionError::DuplicateSharedLayout {
2576 group: scoped,
2577 id: id.clone(),
2578 });
2579 }
2580 }
2581 (None, None) => {}
2582 _ => {
2583 return Err(MotionError::InvalidSharedLayout(
2584 "shared layout group and id must be declared together".to_owned(),
2585 ));
2586 }
2587 }
2588 match node.kind() {
2589 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2590 for child in children {
2591 visit(child, seen)?;
2592 }
2593 }
2594 UiNodeKind::Overlay {
2595 trigger, content, ..
2596 } => {
2597 visit(trigger, seen)?;
2598 visit(content, seen)?;
2599 }
2600 UiNodeKind::Layer { content, .. } => visit(content, seen)?,
2601 UiNodeKind::ErrorBoundary { child, fallback } => {
2602 visit(child, seen)?;
2603 visit(fallback, seen)?;
2604 }
2605 UiNodeKind::VirtualCollection { spec } => {
2606 for child in spec.realized.values() {
2607 visit(child, seen)?;
2608 }
2609 }
2610 UiNodeKind::Text { .. }
2611 | UiNodeKind::RichText { .. }
2612 | UiNodeKind::Canvas { .. }
2613 | UiNodeKind::Svg { .. }
2614 | UiNodeKind::Custom { .. }
2615 | UiNodeKind::Image { .. }
2616 | UiNodeKind::DirectionalImage { .. } => {}
2617 }
2618 Ok(())
2619 }
2620 let mut seen = BTreeSet::new();
2621 visit(root, &mut seen)?;
2622 Ok(seen)
2623}
2624
2625#[allow(clippy::too_many_lines)]
2626pub(crate) fn retained_motion_identities(
2627 root: &UiNode,
2628 retained: &crate::RetainedUiTree,
2629 domain: &str,
2630) -> Result<BTreeMap<String, String>, MotionError> {
2631 fn group_ids(
2632 retained: &crate::RetainedUiTree,
2633 parent: crate::NodeId,
2634 group: &str,
2635 ) -> Result<Vec<crate::NodeId>, MotionError> {
2636 let node = retained
2637 .node(parent)
2638 .ok_or_else(|| MotionError::MissingRetainedIdentity(parent.get().to_string()))?;
2639 Ok(node
2640 .children()
2641 .filter(|child| child.group() == group)
2642 .map(crate::RetainedChildLink::node)
2643 .collect())
2644 }
2645
2646 #[allow(clippy::too_many_lines)]
2647 fn visit(
2648 node: &UiNode,
2649 retained: &crate::RetainedUiTree,
2650 id: crate::NodeId,
2651 path: &str,
2652 domain: &str,
2653 identities: &mut BTreeMap<String, String>,
2654 ) -> Result<(), MotionError> {
2655 let identity = retained_node_path(domain, id);
2656 if identities
2657 .insert(path.to_owned(), identity.clone())
2658 .is_some()
2659 {
2660 return Err(MotionError::DuplicateScenePath(path.to_owned()));
2661 }
2662 if let UiNodeKind::RichText { spans, .. } = node.kind() {
2663 for span in spans {
2664 if let Some(key) = span.key() {
2665 identities.insert(
2666 span_motion_path(path, key),
2667 span_motion_path(&identity, key),
2668 );
2669 }
2670 }
2671 }
2672 match node.kind() {
2673 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2674 let ids = group_ids(retained, id, "children")?;
2675 if ids.len() != children.len() {
2676 return Err(MotionError::MissingRetainedIdentity(path.to_owned()));
2677 }
2678 for (index, (child, child_id)) in children.iter().zip(ids).enumerate() {
2679 visit(
2680 child,
2681 retained,
2682 child_id,
2683 &child_path(path, index, child),
2684 domain,
2685 identities,
2686 )?;
2687 }
2688 }
2689 UiNodeKind::Overlay {
2690 trigger, content, ..
2691 } => {
2692 let trigger_id = group_ids(retained, id, "trigger")?
2693 .into_iter()
2694 .next()
2695 .ok_or_else(|| {
2696 MotionError::MissingRetainedIdentity(format!("{path}/trigger"))
2697 })?;
2698 let content_id = group_ids(retained, id, "content")?
2699 .into_iter()
2700 .next()
2701 .ok_or_else(|| {
2702 MotionError::MissingRetainedIdentity(format!("{path}/content"))
2703 })?;
2704 visit(
2705 trigger,
2706 retained,
2707 trigger_id,
2708 &format!("{path}/trigger"),
2709 domain,
2710 identities,
2711 )?;
2712 visit(
2713 content,
2714 retained,
2715 content_id,
2716 &format!("{path}/content"),
2717 domain,
2718 identities,
2719 )?;
2720 }
2721 UiNodeKind::Layer { content, .. } => {
2722 let child_id = group_ids(retained, id, "content")?
2723 .into_iter()
2724 .next()
2725 .ok_or_else(|| {
2726 MotionError::MissingRetainedIdentity(format!("{path}/content"))
2727 })?;
2728 visit(
2729 content,
2730 retained,
2731 child_id,
2732 &format!("{path}/content"),
2733 domain,
2734 identities,
2735 )?;
2736 }
2737 UiNodeKind::ErrorBoundary { child, fallback } => {
2738 let child_id = group_ids(retained, id, "child")?
2739 .into_iter()
2740 .next()
2741 .ok_or_else(|| {
2742 MotionError::MissingRetainedIdentity(format!("{path}/boundary"))
2743 })?;
2744 let fallback_id = group_ids(retained, id, "fallback")?
2745 .into_iter()
2746 .next()
2747 .ok_or_else(|| {
2748 MotionError::MissingRetainedIdentity(format!("{path}/fallback"))
2749 })?;
2750 visit(
2751 child,
2752 retained,
2753 child_id,
2754 &format!("{path}/boundary"),
2755 domain,
2756 identities,
2757 )?;
2758 visit(
2759 fallback,
2760 retained,
2761 fallback_id,
2762 &format!("{path}/fallback"),
2763 domain,
2764 identities,
2765 )?;
2766 }
2767 UiNodeKind::VirtualCollection { spec } => {
2768 let ids = group_ids(retained, id, "items")?;
2769 if ids.len() != spec.realized.len() {
2770 return Err(MotionError::MissingRetainedIdentity(path.to_owned()));
2771 }
2772 for ((index, item), item_id) in spec.realized.iter().zip(ids) {
2773 let key = crate::virtual_list_element::collection_item_key(spec, *index)
2774 .ok_or_else(|| MotionError::MissingVirtualItemKey {
2775 path: path.to_owned(),
2776 index: *index,
2777 })?;
2778 visit(
2779 item,
2780 retained,
2781 item_id,
2782 &virtual_item_path(path, key),
2783 domain,
2784 identities,
2785 )?;
2786 }
2787 }
2788 UiNodeKind::Text { .. }
2789 | UiNodeKind::RichText { .. }
2790 | UiNodeKind::Canvas { .. }
2791 | UiNodeKind::Svg { .. }
2792 | UiNodeKind::Custom { .. }
2793 | UiNodeKind::Image { .. }
2794 | UiNodeKind::DirectionalImage { .. } => {}
2795 }
2796 Ok(())
2797 }
2798
2799 let root_id = retained
2800 .root_id()
2801 .ok_or_else(|| MotionError::MissingRetainedIdentity(domain.to_owned()))?;
2802 let mut identities = BTreeMap::new();
2803 visit(root, retained, root_id, domain, domain, &mut identities)?;
2804 Ok(identities)
2805}
2806
2807#[must_use]
2808pub fn node_motion_resource_usage(root: &UiNode) -> MotionResourceUsage {
2809 fn visit(node: &UiNode, usage: &mut MotionResourceUsage) {
2810 if let Some(crate::UiValue::Integer(count)) = node.attributes().get("motion_particle_count")
2811 {
2812 usage.particles = usage
2813 .particles
2814 .saturating_add(usize::try_from(*count).unwrap_or(usize::MAX));
2815 }
2816 if node.attributes().contains_key("shared_layout_id") {
2817 usage.shared_snapshots = usage.shared_snapshots.saturating_add(1);
2818 }
2819 if node.attributes().contains_key("layout_motion_duration_ms") {
2820 usage.geometry_slots = usage.geometry_slots.saturating_add(1);
2821 usage.declarations = usage.declarations.saturating_add(1);
2822 }
2823 usage.geometry_slots = usage
2824 .geometry_slots
2825 .saturating_add(node.progress_motions().len());
2826 usage.declarations = usage.declarations.saturating_add(node.motions().len());
2827 usage.declarations = usage.declarations.saturating_add(node.exit_motions().len());
2828 usage.declarations = usage
2829 .declarations
2830 .saturating_add(node.progress_motions().len());
2831 usage.keyframes = usage.keyframes.saturating_add(
2832 node.motions()
2833 .iter()
2834 .map(source_keyframe_count)
2835 .sum::<usize>(),
2836 );
2837 usage.keyframes = usage.keyframes.saturating_add(
2838 node.exit_motions()
2839 .iter()
2840 .map(source_keyframe_count)
2841 .sum::<usize>(),
2842 );
2843 usage.keyframes = usage.keyframes.saturating_add(
2844 node.progress_motions()
2845 .iter()
2846 .map(|binding| source_keyframe_count(&binding.source))
2847 .sum::<usize>(),
2848 );
2849 usage.timelines = usage.timelines.saturating_add(node.timelines().len());
2850 for timeline in node.timelines() {
2851 usage.timeline_steps = usage
2852 .timeline_steps
2853 .saturating_add(timeline_step_count(&timeline.root));
2854 visit_timeline_sources(&timeline.root, &mut |source| {
2855 usage.declarations = usage.declarations.saturating_add(1);
2856 usage.keyframes = usage
2857 .keyframes
2858 .saturating_add(source_keyframe_count(source));
2859 });
2860 }
2861 match node.kind() {
2862 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2863 for child in children {
2864 visit(child, usage);
2865 }
2866 }
2867 UiNodeKind::Overlay {
2868 trigger, content, ..
2869 } => {
2870 visit(trigger, usage);
2871 visit(content, usage);
2872 }
2873 UiNodeKind::Layer { content, .. } => visit(content, usage),
2874 UiNodeKind::ErrorBoundary { child, fallback } => {
2875 visit(child, usage);
2876 visit(fallback, usage);
2877 }
2878 UiNodeKind::VirtualCollection { spec } => {
2879 for child in spec.realized.values() {
2880 visit(child, usage);
2881 }
2882 }
2883 UiNodeKind::RichText { spans, .. } => {
2884 for span in spans {
2885 usage.declarations = usage.declarations.saturating_add(span.motions().len());
2886 usage.keyframes = usage.keyframes.saturating_add(
2887 span.motions()
2888 .iter()
2889 .map(source_keyframe_count)
2890 .sum::<usize>(),
2891 );
2892 }
2893 }
2894 UiNodeKind::Text { .. }
2895 | UiNodeKind::Canvas { .. }
2896 | UiNodeKind::Svg { .. }
2897 | UiNodeKind::Custom { .. }
2898 | UiNodeKind::Image { .. }
2899 | UiNodeKind::DirectionalImage { .. } => {}
2900 }
2901 }
2902
2903 let mut usage = MotionResourceUsage::default();
2904 visit(root, &mut usage);
2905 usage
2906}
2907
2908fn source_keyframe_count(source: &MotionSource) -> usize {
2909 match source {
2910 MotionSource::Keyframes(spec) => spec.frames.len(),
2911 MotionSource::Transition(_) | MotionSource::Spring(_) | MotionSource::Inertia(_) => 0,
2912 }
2913}
2914
2915fn timeline_step_count(step: &MotionTimelineStep) -> usize {
2916 1usize.saturating_add(match step {
2917 MotionTimelineStep::Track(_) | MotionTimelineStep::Delay(_) => 0,
2918 MotionTimelineStep::Sequence(steps)
2919 | MotionTimelineStep::Parallel(steps)
2920 | MotionTimelineStep::Stagger { steps, .. } => steps.iter().map(timeline_step_count).sum(),
2921 })
2922}
2923
2924fn visit_timeline_sources(step: &MotionTimelineStep, visit: &mut impl FnMut(&MotionSource)) {
2925 match step {
2926 MotionTimelineStep::Track(track) => visit(&track.source),
2927 MotionTimelineStep::Delay(_) => {}
2928 MotionTimelineStep::Sequence(steps)
2929 | MotionTimelineStep::Parallel(steps)
2930 | MotionTimelineStep::Stagger { steps, .. } => {
2931 for step in steps {
2932 visit_timeline_sources(step, visit);
2933 }
2934 }
2935 }
2936}
2937
2938#[allow(clippy::too_many_lines)]
2939fn collect_node_motion(
2940 node: &UiNode,
2941 path: &str,
2942 inherited_owner: &ComponentInstancePath,
2943 output: &mut Vec<(String, MotionSource, Option<String>)>,
2944 timelines: &mut Vec<(String, ComponentInstancePath, MotionTimeline)>,
2945) -> Result<(), MotionError> {
2946 let owner = node.component_root().unwrap_or(inherited_owner);
2947 if (!node.motions().is_empty()
2948 || !node.exit_motions().is_empty()
2949 || !node.progress_motions().is_empty()
2950 || !node.timelines().is_empty())
2951 && node.key().is_none()
2952 {
2953 return Err(MotionError::MissingKey(path.to_owned()));
2954 }
2955 if !node.exit_motions().is_empty() && node.motion_ghost().is_none() {
2956 return Err(MotionError::UnsupportedExitGhost(format!(
2957 "{:?}",
2958 node.kind_tag()
2959 )));
2960 }
2961 for source in node.exit_motions() {
2962 validate_source(source)?;
2963 validate_node_property(node, source.property(), path)?;
2964 }
2965 for binding in node.progress_motions() {
2966 validate_progress_source(&binding.source)?;
2967 validate_node_property(node, binding.property(), path)?;
2968 if node
2969 .motions()
2970 .iter()
2971 .any(|source| source.property() == binding.property())
2972 {
2973 return Err(MotionError::DuplicatePropertySource {
2974 path: path.to_owned(),
2975 property: binding.property(),
2976 });
2977 }
2978 }
2979 for source in node.motions() {
2980 validate_node_property(node, source.property(), path)?;
2981 }
2982 let replay_key = match node.attributes().get("motion_replay_key") {
2983 Some(crate::UiValue::String(key)) => Some(key.clone()),
2984 _ => None,
2985 };
2986 output.extend(
2987 node.motions()
2988 .iter()
2989 .cloned()
2990 .map(|animation| (path.to_owned(), animation, replay_key.clone())),
2991 );
2992 timelines.extend(
2993 node.timelines()
2994 .iter()
2995 .cloned()
2996 .map(|timeline| (path.to_owned(), owner.clone(), timeline)),
2997 );
2998 match node.kind() {
2999 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
3000 for (index, child) in children.iter().enumerate() {
3001 collect_node_motion(
3002 child,
3003 &child_path(path, index, child),
3004 owner,
3005 output,
3006 timelines,
3007 )?;
3008 }
3009 }
3010 UiNodeKind::Overlay {
3011 trigger, content, ..
3012 } => {
3013 collect_node_motion(
3014 trigger,
3015 &format!("{path}/trigger"),
3016 owner,
3017 output,
3018 timelines,
3019 )?;
3020 collect_node_motion(
3021 content,
3022 &format!("{path}/content"),
3023 owner,
3024 output,
3025 timelines,
3026 )?;
3027 }
3028 UiNodeKind::Layer { content, .. } => {
3029 collect_node_motion(
3030 content,
3031 &format!("{path}/content"),
3032 owner,
3033 output,
3034 timelines,
3035 )?;
3036 }
3037 UiNodeKind::ErrorBoundary { child, fallback } => {
3038 collect_node_motion(child, &format!("{path}/boundary"), owner, output, timelines)?;
3039 collect_node_motion(
3040 fallback,
3041 &format!("{path}/fallback"),
3042 owner,
3043 output,
3044 timelines,
3045 )?;
3046 }
3047 UiNodeKind::VirtualCollection { spec } => {
3048 for (index, item) in &spec.realized {
3049 let key = crate::virtual_list_element::collection_item_key(spec, *index)
3050 .ok_or_else(|| MotionError::MissingVirtualItemKey {
3051 path: path.to_owned(),
3052 index: *index,
3053 })?;
3054 collect_node_motion(
3055 item,
3056 &virtual_item_path(path, key),
3057 owner,
3058 output,
3059 timelines,
3060 )?;
3061 }
3062 }
3063 UiNodeKind::RichText { spans, .. } => {
3064 for (index, span) in spans.iter().enumerate() {
3065 if span.motions().is_empty() {
3066 continue;
3067 }
3068 let key = span
3069 .key()
3070 .ok_or_else(|| MotionError::MissingKey(format!("{path}/span-index:{index}")))?;
3071 let span_path = span_motion_path(path, key);
3072 if span
3073 .motions()
3074 .iter()
3075 .any(|source| source.property() != MotionProperty::Opacity)
3076 {
3077 return Err(MotionError::UnsupportedProperty {
3078 path: span_path,
3079 property: span
3080 .motions()
3081 .iter()
3082 .find(|source| source.property() != MotionProperty::Opacity)
3083 .map_or(MotionProperty::Opacity, MotionSource::property),
3084 node: "rich_text_span",
3085 });
3086 }
3087 output.extend(
3088 span.motions()
3089 .iter()
3090 .cloned()
3091 .map(|source| (span_path.clone(), source, replay_key.clone())),
3092 );
3093 }
3094 }
3095 UiNodeKind::Text { .. }
3096 | UiNodeKind::Canvas { .. }
3097 | UiNodeKind::Svg { .. }
3098 | UiNodeKind::Custom { .. }
3099 | UiNodeKind::Image { .. }
3100 | UiNodeKind::DirectionalImage { .. } => {}
3101 }
3102 Ok(())
3103}
3104
3105enum MotionSceneTarget<'a> {
3106 Node(&'a UiNode),
3107 Span,
3108}
3109
3110fn validate_motion_plan(
3111 root: &UiNode,
3112 root_path: &str,
3113 declarations: &[(String, MotionSource, Option<String>)],
3114 timelines: &[(String, ComponentInstancePath, MotionTimeline)],
3115) -> Result<(), MotionError> {
3116 let mut scene = BTreeMap::new();
3117 collect_motion_scene(root, root_path, &mut scene)?;
3118 let mut owners = BTreeMap::<(String, MotionProperty), String>::new();
3119 for (path, source, _) in declarations {
3120 insert_property_owner(&mut owners, path, source.property(), "motion declaration")?;
3121 }
3122 collect_non_timeline_owners(root, root_path, &mut owners)?;
3123 for (base, _, timeline) in timelines {
3124 let (tracks, _) = compile_timeline(&timeline.root)?;
3125 let mut timeline_properties = BTreeSet::new();
3126 for track in tracks {
3127 let target_path = resolve_timeline_target(base, &track.target);
3128 let target =
3129 scene
3130 .get(&target_path)
3131 .ok_or_else(|| MotionError::UnknownTimelineTarget {
3132 timeline: timeline.name.clone(),
3133 target: target_path.clone(),
3134 })?;
3135 validate_scene_target_property(target, track.source.property(), &target_path)?;
3136 let key = (target_path.clone(), track.source.property());
3137 if timeline_properties.insert(key.clone()) {
3138 insert_property_owner(
3139 &mut owners,
3140 &target_path,
3141 track.source.property(),
3142 &format!("timeline `{}`", timeline.name),
3143 )?;
3144 }
3145 }
3146 }
3147 Ok(())
3148}
3149
3150fn insert_property_owner(
3151 owners: &mut BTreeMap<(String, MotionProperty), String>,
3152 path: &str,
3153 property: MotionProperty,
3154 owner: &str,
3155) -> Result<(), MotionError> {
3156 let key = (path.to_owned(), property);
3157 if let Some(previous) = owners.insert(key, owner.to_owned()) {
3158 Err(MotionError::PropertyOwnerConflict {
3159 path: path.to_owned(),
3160 property,
3161 first: previous,
3162 second: owner.to_owned(),
3163 })
3164 } else {
3165 Ok(())
3166 }
3167}
3168
3169fn resolve_timeline_target(base: &str, target: &str) -> String {
3170 match target {
3171 "." | "" => base.to_owned(),
3172 target => target.split('/').fold(base.to_owned(), |path, segment| {
3173 format!("{path}/{}", encode_path_segment("key", segment))
3174 }),
3175 }
3176}
3177
3178fn validate_scene_target_property(
3179 target: &MotionSceneTarget<'_>,
3180 property: MotionProperty,
3181 path: &str,
3182) -> Result<(), MotionError> {
3183 match target {
3184 MotionSceneTarget::Node(node) => validate_node_property(node, property, path),
3185 MotionSceneTarget::Span if property == MotionProperty::Opacity => Ok(()),
3186 MotionSceneTarget::Span => Err(MotionError::UnsupportedProperty {
3187 path: path.to_owned(),
3188 property,
3189 node: "rich_text_span",
3190 }),
3191 }
3192}
3193
3194fn collect_non_timeline_owners(
3195 node: &UiNode,
3196 path: &str,
3197 owners: &mut BTreeMap<(String, MotionProperty), String>,
3198) -> Result<(), MotionError> {
3199 for binding in node.progress_motions() {
3200 insert_property_owner(owners, path, binding.property(), "progress binding")?;
3201 }
3202 for (property, _) in node.signal_bindings() {
3203 let property = match property {
3204 crate::SignalProperty::Opacity => Some(MotionProperty::Opacity),
3205 crate::SignalProperty::TranslateX | crate::SignalProperty::TranslateXOverride => {
3206 Some(MotionProperty::TranslateX)
3207 }
3208 crate::SignalProperty::TranslateY | crate::SignalProperty::TranslateYOverride => {
3209 Some(MotionProperty::TranslateY)
3210 }
3211 crate::SignalProperty::Rotate | crate::SignalProperty::RotateOverride => {
3212 Some(MotionProperty::Rotate)
3213 }
3214 crate::SignalProperty::ScaleX | crate::SignalProperty::ScaleXOverride => {
3215 Some(MotionProperty::ScaleX)
3216 }
3217 crate::SignalProperty::ScaleY | crate::SignalProperty::ScaleYOverride => {
3218 Some(MotionProperty::ScaleY)
3219 }
3220 crate::SignalProperty::Width | crate::SignalProperty::WidthOverride => {
3221 Some(MotionProperty::Width)
3222 }
3223 crate::SignalProperty::Height | crate::SignalProperty::HeightOverride => {
3224 Some(MotionProperty::Height)
3225 }
3226 crate::SignalProperty::Background
3227 | crate::SignalProperty::TextColor
3228 | crate::SignalProperty::BorderColor => None,
3229 };
3230 if let Some(property) = property {
3231 insert_property_owner(owners, path, property, "native signal")?;
3232 }
3233 }
3234 visit_motion_children(node, path, |child, child_path| {
3235 collect_non_timeline_owners(child, child_path, owners)
3236 })
3237}
3238
3239fn collect_motion_scene<'a>(
3240 node: &'a UiNode,
3241 path: &str,
3242 scene: &mut BTreeMap<String, MotionSceneTarget<'a>>,
3243) -> Result<(), MotionError> {
3244 if scene
3245 .insert(path.to_owned(), MotionSceneTarget::Node(node))
3246 .is_some()
3247 {
3248 return Err(MotionError::DuplicateScenePath(path.to_owned()));
3249 }
3250 if let UiNodeKind::RichText { spans, .. } = node.kind() {
3251 for span in spans {
3252 if let Some(key) = span.key() {
3253 let span_path = span_motion_path(path, key);
3254 if scene
3255 .insert(span_path.clone(), MotionSceneTarget::Span)
3256 .is_some()
3257 {
3258 return Err(MotionError::DuplicateScenePath(span_path));
3259 }
3260 }
3261 }
3262 }
3263 visit_motion_children(node, path, |child, child_path| {
3264 collect_motion_scene(child, child_path, scene)
3265 })
3266}
3267
3268fn visit_motion_children<'a>(
3269 node: &'a UiNode,
3270 path: &str,
3271 mut visit: impl FnMut(&'a UiNode, &str) -> Result<(), MotionError>,
3272) -> Result<(), MotionError> {
3273 match node.kind() {
3274 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
3275 for (index, child) in children.iter().enumerate() {
3276 visit(child, &child_path(path, index, child))?;
3277 }
3278 }
3279 UiNodeKind::Overlay {
3280 trigger, content, ..
3281 } => {
3282 visit(trigger, &format!("{path}/trigger"))?;
3283 visit(content, &format!("{path}/content"))?;
3284 }
3285 UiNodeKind::Layer { content, .. } => visit(content, &format!("{path}/content"))?,
3286 UiNodeKind::ErrorBoundary { child, fallback } => {
3287 visit(child, &format!("{path}/boundary"))?;
3288 visit(fallback, &format!("{path}/fallback"))?;
3289 }
3290 UiNodeKind::VirtualCollection { spec } => {
3291 for (index, item) in &spec.realized {
3292 let key = crate::virtual_list_element::collection_item_key(spec, *index)
3293 .ok_or_else(|| MotionError::MissingVirtualItemKey {
3294 path: path.to_owned(),
3295 index: *index,
3296 })?;
3297 visit(item, &virtual_item_path(path, key))?;
3298 }
3299 }
3300 UiNodeKind::Text { .. }
3301 | UiNodeKind::RichText { .. }
3302 | UiNodeKind::Canvas { .. }
3303 | UiNodeKind::Svg { .. }
3304 | UiNodeKind::Custom { .. }
3305 | UiNodeKind::Image { .. }
3306 | UiNodeKind::DirectionalImage { .. } => {}
3307 }
3308 Ok(())
3309}
3310
3311fn validate_node_property(
3312 node: &UiNode,
3313 property: MotionProperty,
3314 path: &str,
3315) -> Result<(), MotionError> {
3316 if matches!(
3317 property,
3318 MotionProperty::Rotate
3319 | MotionProperty::ScaleX
3320 | MotionProperty::ScaleY
3321 | MotionProperty::SkewX
3322 | MotionProperty::SkewY
3323 | MotionProperty::PathProgress
3324 ) && !matches!(node.kind(), UiNodeKind::Canvas { .. })
3325 {
3326 Err(MotionError::UnsupportedProperty {
3327 path: path.to_owned(),
3328 property,
3329 node: "non_canvas",
3330 })
3331 } else if matches!(
3332 property,
3333 MotionProperty::Rotate
3334 | MotionProperty::ScaleX
3335 | MotionProperty::ScaleY
3336 | MotionProperty::SkewX
3337 | MotionProperty::SkewY
3338 ) && matches!(node.kind(), UiNodeKind::Canvas { scene } if scene.commands().iter().any(|command| {
3339 matches!(
3340 command,
3341 crate::CanvasCommand::Path { clip: Some(_), .. }
3342 | crate::CanvasCommand::MorphPath { clip: Some(_), .. }
3343 )
3344 })) {
3345 Err(MotionError::UnsupportedProperty {
3346 path: path.to_owned(),
3347 property,
3348 node: "canvas_with_clipped_path",
3349 })
3350 } else {
3351 Ok(())
3352 }
3353}
3354
3355pub(crate) fn child_path(path: &str, index: usize, child: &UiNode) -> String {
3356 child.key().map_or_else(
3357 || format!("{path}/index:{index}"),
3358 |key| format!("{path}/{}", encode_path_segment("key", key.as_str())),
3359 )
3360}
3361
3362#[derive(Clone, Copy, Debug)]
3363struct SourceSample {
3364 value: f64,
3365 velocity: Option<f64>,
3366 done: bool,
3367}
3368
3369fn motion_elapsed(motion: &ActiveMotion, now: Instant) -> Duration {
3370 motion
3371 .elapsed_before_play
3372 .saturating_add(now.saturating_duration_since(motion.started))
3373}
3374
3375fn sample_active_motion(motion: &ActiveMotion, now: Instant, suspended: bool) -> SourceSample {
3376 let elapsed = if suspended {
3377 motion.elapsed_before_play
3378 } else {
3379 motion_elapsed(motion, now)
3380 };
3381 sample_source_with_duration(&motion.source, elapsed, motion.finite_duration_ms)
3382}
3383
3384fn source_initial_value(source: &MotionSource) -> f64 {
3385 match source {
3386 MotionSource::Transition(spec) => spec.from,
3387 MotionSource::Spring(spec) => spec.from,
3388 MotionSource::Keyframes(spec) => spec.frames.first().map_or(0.0, |frame| frame.value),
3389 MotionSource::Inertia(spec) => spec.from,
3390 }
3391}
3392
3393fn source_terminal_value(source: &MotionSource) -> f64 {
3394 source_duration_ms(source).map_or_else(
3395 |_| source.reduced_value(),
3396 |duration| {
3397 sample_source_with_duration(source, Duration::from_millis(duration), Some(duration))
3398 .value
3399 },
3400 )
3401}
3402
3403fn retarget_source(
3404 declaration: &MotionSource,
3405 current: f64,
3406 inherited_velocity: Option<f64>,
3407) -> MotionSource {
3408 match declaration {
3409 MotionSource::Transition(spec) => {
3410 let mut spec = spec.clone();
3411 spec.from = current;
3412 MotionSource::Transition(spec)
3413 }
3414 MotionSource::Spring(spec) => {
3415 let mut spec = spec.clone();
3416 spec.from = current;
3417 spec.initial_velocity = inherited_velocity.unwrap_or(spec.initial_velocity);
3418 MotionSource::Spring(spec)
3419 }
3420 MotionSource::Keyframes(spec) => {
3421 let mut spec = spec.clone();
3422 if let Some(first) = spec.frames.first_mut() {
3423 first.value = current;
3424 }
3425 MotionSource::Keyframes(spec)
3426 }
3427 MotionSource::Inertia(spec) => {
3428 let mut spec = spec.clone();
3429 spec.from = current;
3430 MotionSource::Inertia(spec)
3431 }
3432 }
3433}
3434
3435#[cfg(test)]
3436fn sample_source(source: &MotionSource, elapsed: Duration) -> SourceSample {
3437 sample_source_with_duration(source, elapsed, source_duration_ms(source).ok())
3438}
3439
3440fn sample_source_with_duration(
3441 source: &MotionSource,
3442 elapsed: Duration,
3443 finite_duration_ms: Option<u64>,
3444) -> SourceSample {
3445 let elapsed_ms = duration_ms(elapsed);
3446 let sample = match source {
3447 MotionSource::Transition(spec) => {
3448 let (progress, done) = timed_progress(
3449 elapsed_ms,
3450 spec.delay_ms,
3451 spec.duration_ms,
3452 spec.iterations,
3453 spec.autoreverse,
3454 );
3455 SourceSample {
3456 value: spec.from + (spec.to - spec.from) * spec.easing.sample(progress),
3457 velocity: None,
3458 done,
3459 }
3460 }
3461 MotionSource::Keyframes(spec) => {
3462 let (progress, done) = timed_progress(
3463 elapsed_ms,
3464 spec.delay_ms,
3465 spec.duration_ms,
3466 spec.iterations,
3467 spec.autoreverse,
3468 );
3469 SourceSample {
3470 value: sample_keyframes(&spec.frames, progress),
3471 velocity: None,
3472 done,
3473 }
3474 }
3475 MotionSource::Spring(spec) => {
3476 let settle_ms = finite_duration_ms.unwrap_or_else(|| spring_settle_ms(spec));
3477 let done = elapsed_ms >= settle_ms;
3478 let (value, velocity) = if done {
3479 (spec.to, 0.0)
3480 } else {
3481 sample_spring_at(spec, elapsed.as_secs_f64())
3482 };
3483 SourceSample {
3484 value,
3485 velocity: Some(velocity),
3486 done,
3487 }
3488 }
3489 MotionSource::Inertia(spec) => {
3490 let settle_ms = finite_duration_ms.unwrap_or_else(|| inertia_settle_ms(spec));
3491 let done = elapsed_ms >= settle_ms;
3492 let (value, velocity) =
3493 sample_inertia_at(spec, elapsed.min(Duration::from_millis(settle_ms)));
3494 SourceSample {
3495 value,
3496 velocity: Some(if done { 0.0 } else { velocity }),
3497 done,
3498 }
3499 }
3500 };
3501 if sample.value.is_finite() && sample.velocity.is_none_or(f64::is_finite) {
3502 sample
3503 } else {
3504 SourceSample {
3505 value: source_initial_value(source),
3506 velocity: Some(0.0),
3507 done: true,
3508 }
3509 }
3510}
3511
3512fn timed_progress(
3513 elapsed_ms: u64,
3514 delay_ms: u64,
3515 duration_ms: u64,
3516 iterations: Option<u32>,
3517 autoreverse: bool,
3518) -> (f64, bool) {
3519 if elapsed_ms < delay_ms {
3520 return (0.0, false);
3521 }
3522 let elapsed = elapsed_ms.saturating_sub(delay_ms);
3523 let duration = duration_ms.max(1);
3524 let raw_iteration = elapsed / duration;
3525 let done = iterations.is_some_and(|count| raw_iteration >= u64::from(count));
3526 let cycle = if done {
3527 u64::from(iterations.unwrap_or(1).saturating_sub(1))
3528 } else {
3529 raw_iteration
3530 };
3531 let mut progress = if done {
3532 1.0
3533 } else {
3534 Duration::from_millis(elapsed % duration).as_secs_f64()
3535 / Duration::from_millis(duration).as_secs_f64()
3536 };
3537 if autoreverse && cycle % 2 == 1 {
3538 progress = 1.0 - progress;
3539 }
3540 (progress, done)
3541}
3542
3543fn reduced_value(animation: &ActiveMotion) -> f64 {
3544 animation.declaration.reduced_value()
3545}
3546
3547fn validate_source(spec: &MotionSource) -> Result<(), MotionError> {
3548 let finite = match spec {
3549 MotionSource::Transition(spec) => {
3550 spec.from.is_finite()
3551 && spec.to.is_finite()
3552 && spec.duration_ms > 0
3553 && spec.iterations != Some(0)
3554 }
3555 MotionSource::Spring(spec) => valid_spring(spec),
3556 MotionSource::Keyframes(spec) => {
3557 spec.duration_ms > 0 && spec.iterations != Some(0) && valid_keyframes(&spec.frames)
3558 }
3559 MotionSource::Inertia(spec) => {
3560 spec.from.is_finite()
3561 && spec.velocity.is_finite()
3562 && spec.friction.is_finite()
3563 && (1.0e-9..=1.0e9).contains(&spec.friction)
3564 && spec.from.abs() <= 1.0e12
3565 && spec.velocity.abs() <= 1.0e12
3566 && spec.min.is_none_or(f64::is_finite)
3567 && spec.max.is_none_or(f64::is_finite)
3568 && spec.min.zip(spec.max).is_none_or(|(min, max)| min <= max)
3569 && spec.bounce.is_finite()
3570 && (0.0..=1.0).contains(&spec.bounce)
3571 && spec.snap_points.len() <= 1_024
3572 && spec.snap_points.iter().all(|point| point.is_finite())
3573 }
3574 };
3575 if finite {
3576 Ok(())
3577 } else {
3578 Err(MotionError::InvalidSpec)
3579 }
3580}
3581
3582fn valid_spring(spec: &MotionSpring) -> bool {
3583 if !spec.from.is_finite()
3584 || !spec.to.is_finite()
3585 || !spec.initial_velocity.is_finite()
3586 || spec.from.abs() > 1.0e12
3587 || spec.to.abs() > 1.0e12
3588 || spec.initial_velocity.abs() > 1.0e12
3589 || !(1.0e-9..=1.0e12).contains(&spec.stiffness)
3590 || !(0.0..=1.0e12).contains(&spec.damping)
3591 || !(1.0e-9..=1.0e12).contains(&spec.mass)
3592 {
3593 return false;
3594 }
3595 let ratio = spec.stiffness / spec.mass;
3596 let product = spec.stiffness * spec.mass;
3597 ratio.is_finite()
3598 && product.is_finite()
3599 && ratio.sqrt().is_finite()
3600 && product.sqrt().is_finite()
3601}
3602
3603fn sample_keyframes(frames: &[MotionKeyframe], progress: f64) -> f64 {
3604 let Some(first) = frames.first() else {
3605 return 0.0;
3606 };
3607 if progress <= first.offset {
3608 return first.value;
3609 }
3610 for pair in frames.windows(2) {
3611 let [left, right] = pair else { continue };
3612 if progress <= right.offset {
3613 let width = right.offset - left.offset;
3614 let local = if width <= f64::EPSILON {
3615 1.0
3616 } else {
3617 (progress - left.offset) / width
3618 };
3619 return left.value + (right.value - left.value) * right.easing.sample(local);
3620 }
3621 }
3622 frames.last().map_or(first.value, |frame| frame.value)
3623}
3624
3625fn valid_keyframes(frames: &[MotionKeyframe]) -> bool {
3626 frames.len() >= 2
3627 && frames.len() <= 4_096
3628 && frames
3629 .first()
3630 .is_some_and(|frame| frame.offset.abs() < f64::EPSILON)
3631 && frames
3632 .last()
3633 .is_some_and(|frame| (frame.offset - 1.0).abs() < f64::EPSILON)
3634 && frames.iter().all(|frame| {
3635 frame.offset.is_finite()
3636 && (0.0..=1.0).contains(&frame.offset)
3637 && frame.value.is_finite()
3638 })
3639 && frames
3640 .windows(2)
3641 .all(|pair| pair[0].offset < pair[1].offset)
3642}
3643
3644fn inertia_target(spec: &MotionInertia) -> f64 {
3645 let projected = spec.from + spec.velocity / spec.friction.max(f64::EPSILON);
3646 let clamped = projected.clamp(
3647 spec.min.unwrap_or(f64::NEG_INFINITY),
3648 spec.max.unwrap_or(f64::INFINITY),
3649 );
3650 nearest_snap(clamped, &spec.snap_points).unwrap_or(clamped)
3651}
3652
3653fn nearest_snap(value: f64, points: &[f64]) -> Option<f64> {
3654 points
3655 .iter()
3656 .copied()
3657 .min_by(|left, right| (value - *left).abs().total_cmp(&(value - *right).abs()))
3658}
3659
3660pub(crate) fn register_motion_api(engine: &mut Engine) {
3661 engine.build_type::<MotionSource>();
3662 engine.build_type::<MotionTimelineStep>();
3663 engine.build_type::<MotionTimeline>();
3664 engine.build_type::<MotionHandle>();
3665 engine.build_type::<MotionProgressBinding>();
3666 FuncRegistration::new("motion_transition")
3667 .in_global_namespace()
3668 .register_into_engine(engine, motion_transition_from_script);
3669 FuncRegistration::new("motion_spring")
3670 .in_global_namespace()
3671 .register_into_engine(engine, motion_spring_from_script);
3672 FuncRegistration::new("motion_keyframes")
3673 .in_global_namespace()
3674 .register_into_engine(engine, motion_keyframes_from_script);
3675 FuncRegistration::new("motion_inertia")
3676 .in_global_namespace()
3677 .register_into_engine(engine, motion_inertia_from_script);
3678 register_mixed_number_motion_api(engine);
3679 FuncRegistration::new("motion_path_follow")
3680 .in_global_namespace()
3681 .register_into_engine(engine, motion_path_follow_from_script);
3682 FuncRegistration::new("motion_in_view")
3683 .in_global_namespace()
3684 .register_into_engine(engine, |source: MotionSource| {
3685 MotionProgressBinding::new(MotionProgressDriver::InView, source)
3686 .map_err(script_boxed_error)
3687 });
3688 FuncRegistration::new("motion_viewport")
3689 .in_global_namespace()
3690 .register_into_engine(engine, |source: MotionSource| {
3691 MotionProgressBinding::new(MotionProgressDriver::Viewport, source)
3692 .map_err(script_boxed_error)
3693 });
3694 FuncRegistration::new("motion_scroll")
3695 .in_global_namespace()
3696 .register_into_engine(engine, motion_scroll_from_script);
3697 for (name, driver) in [
3698 ("motion_hover", MotionProgressDriver::Hover),
3699 ("motion_press", MotionProgressDriver::Press),
3700 ("motion_focus", MotionProgressDriver::Focus),
3701 ] {
3702 FuncRegistration::new(name)
3703 .in_global_namespace()
3704 .register_into_engine(engine, move |source: MotionSource| {
3705 MotionProgressBinding::new(driver, source).map_err(script_boxed_error)
3706 });
3707 }
3708 FuncRegistration::new("motion_text_spans")
3709 .in_global_namespace()
3710 .register_into_engine(engine, motion_text_spans_from_script);
3711 FuncRegistration::new("motion_track")
3712 .in_global_namespace()
3713 .register_into_engine(engine, |target: &str, source: MotionSource| {
3714 MotionTimelineStep::Track(MotionTrack {
3715 target: target.to_owned(),
3716 source,
3717 })
3718 });
3719 FuncRegistration::new("motion_delay")
3720 .in_global_namespace()
3721 .register_into_engine(engine, motion_delay_from_script);
3722 FuncRegistration::new("motion_sequence")
3723 .in_global_namespace()
3724 .register_into_engine(engine, motion_sequence_from_script);
3725 FuncRegistration::new("motion_parallel")
3726 .in_global_namespace()
3727 .register_into_engine(engine, motion_parallel_from_script);
3728 FuncRegistration::new("motion_stagger")
3729 .in_global_namespace()
3730 .register_into_engine(engine, motion_stagger_from_script);
3731 FuncRegistration::new("motion_timeline")
3732 .in_global_namespace()
3733 .register_into_engine(engine, motion_timeline_from_script);
3734}
3735
3736fn register_mixed_number_motion_api(engine: &mut Engine) {
3740 FuncRegistration::new("motion_transition")
3741 .in_global_namespace()
3742 .register_into_engine(
3743 engine,
3744 |property: &str,
3745 from: Dynamic,
3746 to: Dynamic,
3747 config: Map|
3748 -> Result<MotionSource, Box<EvalAltResult>> {
3749 motion_transition_from_script(
3750 property,
3751 motion_number("from", &from)?,
3752 motion_number("to", &to)?,
3753 config,
3754 )
3755 },
3756 );
3757 FuncRegistration::new("motion_spring")
3758 .in_global_namespace()
3759 .register_into_engine(
3760 engine,
3761 |property: &str,
3762 from: Dynamic,
3763 to: Dynamic,
3764 config: Map|
3765 -> Result<MotionSource, Box<EvalAltResult>> {
3766 motion_spring_from_script(
3767 property,
3768 motion_number("from", &from)?,
3769 motion_number("to", &to)?,
3770 config,
3771 )
3772 },
3773 );
3774 FuncRegistration::new("motion_inertia")
3775 .in_global_namespace()
3776 .register_into_engine(
3777 engine,
3778 |property: &str,
3779 from: Dynamic,
3780 velocity: Dynamic,
3781 config: Map|
3782 -> Result<MotionSource, Box<EvalAltResult>> {
3783 motion_inertia_from_script(
3784 property,
3785 motion_number("from", &from)?,
3786 motion_number("velocity", &velocity)?,
3787 config,
3788 )
3789 },
3790 );
3791}
3792
3793fn motion_number(name: &str, value: &Dynamic) -> Result<f64, Box<EvalAltResult>> {
3794 crate::value::script_number(value).ok_or_else(|| {
3795 script_boxed_error(format!(
3796 "motion `{name}` must be a number, got {}",
3797 value.type_name()
3798 ))
3799 })
3800}
3801
3802fn motion_text_spans_from_script(text: &str, mut config: Map) -> Result<Array, Box<EvalAltResult>> {
3803 let duration_ms = take_u64(&mut config, "duration_ms")?.unwrap_or(180);
3804 let stagger_ms = take_u64(&mut config, "stagger_ms")?.unwrap_or(24);
3805 let easing = take_string(&mut config, "easing")?
3806 .map_or(Ok(MotionEasing::EaseOut), |value| {
3807 MotionEasing::parse(&value)
3808 })
3809 .map_err(script_boxed_error)?;
3810 let intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative);
3811 reject_unknown_config(&config)?;
3812 Ok(text
3813 .graphemes(true)
3814 .enumerate()
3815 .map(|(index, grapheme)| {
3816 let delay_ms = stagger_ms.saturating_mul(u64::try_from(index).unwrap_or(u64::MAX));
3817 let mut transition =
3818 MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, duration_ms);
3819 transition.delay_ms = delay_ms;
3820 transition.easing = easing;
3821 transition.intent = intent;
3822 Dynamic::from(
3823 crate::Span::new(grapheme)
3824 .with_key(format!("grapheme-{index}"))
3825 .motion(MotionSource::Transition(transition)),
3826 )
3827 })
3828 .collect::<Array>())
3829}
3830
3831fn motion_scroll_from_script(
3832 axis: &str,
3833 source: MotionSource,
3834) -> Result<MotionProgressBinding, Box<EvalAltResult>> {
3835 let driver = match axis {
3836 "x" | "horizontal" => MotionProgressDriver::ScrollX,
3837 "y" | "vertical" => MotionProgressDriver::ScrollY,
3838 _ => return Err(script_boxed_error("motion_scroll axis must be `x` or `y`")),
3839 };
3840 MotionProgressBinding::new(driver, source).map_err(script_boxed_error)
3841}
3842
3843#[allow(clippy::needless_pass_by_value)]
3844fn motion_path_follow_from_script(
3845 scene: crate::CanvasScene,
3846 key: &str,
3847 property: &str,
3848 mut config: Map,
3849) -> Result<MotionSource, Box<EvalAltResult>> {
3850 let property = MotionProperty::parse(property).map_err(script_boxed_error)?;
3851 if !matches!(
3852 property,
3853 MotionProperty::TranslateX | MotionProperty::TranslateY | MotionProperty::Rotate
3854 ) {
3855 return Err(script_boxed_error(
3856 "motion_path_follow property must be `translate_x`, `translate_y`, or `rotate`",
3857 ));
3858 }
3859 let samples = take_u64(&mut config, "samples")?.unwrap_or(128);
3860 if !(16..=512).contains(&samples) {
3861 return Err(script_boxed_error(
3862 "motion_path_follow samples must be between 16 and 512",
3863 ));
3864 }
3865 let mut frames = Vec::with_capacity(usize::try_from(samples + 1).unwrap_or(513));
3866 let samples_u32 = u32::try_from(samples).unwrap_or(512);
3867 for index in 0..=samples_u32 {
3868 let offset = f64::from(index) / f64::from(samples_u32);
3869 let sample = scene.sample_path(key, offset).map_err(script_boxed_error)?;
3870 let value = match property {
3871 MotionProperty::TranslateX => sample.x,
3872 MotionProperty::TranslateY => sample.y,
3873 MotionProperty::Rotate => sample.tangent_degrees,
3874 _ => unreachable!(),
3875 };
3876 frames.push(MotionKeyframe {
3877 offset,
3878 value,
3879 easing: MotionEasing::Linear,
3880 });
3881 }
3882 let source = MotionSource::Keyframes(MotionKeyframes {
3883 property,
3884 frames,
3885 delay_ms: take_u64(&mut config, "delay_ms")?.unwrap_or(0),
3886 duration_ms: take_u64(&mut config, "duration_ms")?.unwrap_or(1_000),
3887 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
3888 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
3889 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
3890 });
3891 reject_unknown_config(&config)?;
3892 checked_source(source)
3893}
3894
3895fn motion_delay_from_script(duration_ms: INT) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3896 let duration_ms = u64::try_from(duration_ms)
3897 .map_err(|_| script_boxed_error("motion delay must be non-negative"))?;
3898 Ok(MotionTimelineStep::Delay(duration_ms))
3899}
3900
3901fn timeline_steps(
3902 values: Array,
3903 kind: &str,
3904) -> Result<Vec<MotionTimelineStep>, Box<EvalAltResult>> {
3905 values
3906 .into_iter()
3907 .enumerate()
3908 .map(|(index, value)| {
3909 value.try_cast::<MotionTimelineStep>().ok_or_else(|| {
3910 script_boxed_error(format!(
3911 "{kind} item {index} must be a motion timeline step"
3912 ))
3913 })
3914 })
3915 .collect()
3916}
3917
3918fn motion_sequence_from_script(values: Array) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3919 Ok(MotionTimelineStep::Sequence(timeline_steps(
3920 values,
3921 "motion_sequence",
3922 )?))
3923}
3924
3925fn motion_parallel_from_script(values: Array) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3926 Ok(MotionTimelineStep::Parallel(timeline_steps(
3927 values,
3928 "motion_parallel",
3929 )?))
3930}
3931
3932fn motion_stagger_from_script(
3933 values: Array,
3934 interval_ms: INT,
3935) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3936 let interval_ms = u64::try_from(interval_ms)
3937 .map_err(|_| script_boxed_error("motion stagger interval must be non-negative"))?;
3938 Ok(MotionTimelineStep::Stagger {
3939 interval_ms,
3940 steps: timeline_steps(values, "motion_stagger")?,
3941 })
3942}
3943
3944#[allow(clippy::needless_pass_by_value)]
3945fn motion_timeline_from_script(
3946 call: NativeCallContext<'_>,
3947 name: &str,
3948 root: MotionTimelineStep,
3949 mut config: Map,
3950) -> Result<MotionTimeline, Box<EvalAltResult>> {
3951 validate_timeline_name(name).map_err(script_boxed_error)?;
3952 let timeline = MotionTimeline {
3953 name: name.to_owned(),
3954 root,
3955 autoplay: take_bool(&mut config, "autoplay")?.unwrap_or(true),
3956 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
3957 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
3958 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
3959 on_complete: take_callback(&call, &mut config, "on_complete")?,
3960 on_cancel: take_callback(&call, &mut config, "on_cancel")?,
3961 };
3962 reject_unknown_config(&config)?;
3963 compile_timeline(&timeline.root).map_err(script_boxed_error)?;
3964 Ok(timeline)
3965}
3966
3967fn take_callback(
3968 call: &NativeCallContext<'_>,
3969 config: &mut Map,
3970 key: &str,
3971) -> Result<Option<ScriptCallback>, Box<EvalAltResult>> {
3972 let Some(value) = take_dynamic(config, key) else {
3973 return Ok(None);
3974 };
3975 let function = value.try_cast::<FnPtr>().ok_or_else(|| {
3976 script_boxed_error(format!("motion option `{key}` must be a function pointer"))
3977 })?;
3978 let mut callback = ScriptCallback::try_from_fn_ptr(function, ScriptGeneration::default())
3979 .map_err(script_boxed_error)?;
3980 callback.bind_native_context_if_unset(
3981 crate::invocation::ScriptInvocationContext::capture_retained(call),
3982 );
3983 Ok(Some(callback))
3984}
3985
3986fn motion_transition_from_script(
3987 property: &str,
3988 from: FLOAT,
3989 to: FLOAT,
3990 mut config: Map,
3991) -> Result<MotionSource, Box<EvalAltResult>> {
3992 let property = script_property(property)?;
3993 let mut transition = MotionTransition::new(property, from, to, 180);
3994 transition.delay_ms = take_u64(&mut config, "delay_ms")?.unwrap_or(0);
3995 transition.duration_ms = take_u64(&mut config, "duration_ms")?.unwrap_or(180);
3996 transition.easing = take_string(&mut config, "easing")?
3997 .map_or(Ok(MotionEasing::EaseOut), |value| {
3998 MotionEasing::parse(&value)
3999 })
4000 .map_err(script_boxed_error)?;
4001 transition.iterations = take_iterations(&mut config)?.unwrap_or(Some(1));
4002 transition.autoreverse = take_bool(&mut config, "autoreverse")?.unwrap_or(false);
4003 transition.intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback);
4004 reject_unknown_config(&config)?;
4005 checked_source(MotionSource::Transition(transition))
4006}
4007
4008fn motion_spring_from_script(
4009 property: &str,
4010 from: FLOAT,
4011 to: FLOAT,
4012 mut config: Map,
4013) -> Result<MotionSource, Box<EvalAltResult>> {
4014 let property = script_property(property)?;
4015 let mut spring = MotionSpring::new(property, from, to);
4016 spring.initial_velocity = take_float(&mut config, "initial_velocity")?.unwrap_or(0.0);
4017 spring.stiffness = take_float(&mut config, "stiffness")?.unwrap_or(180.0);
4018 spring.damping = take_float(&mut config, "damping")?.unwrap_or(24.0);
4019 spring.mass = take_float(&mut config, "mass")?.unwrap_or(1.0);
4020 spring.intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback);
4021 reject_unknown_config(&config)?;
4022 checked_source(MotionSource::Spring(spring))
4023}
4024
4025fn motion_keyframes_from_script(
4026 property: &str,
4027 frames: Array,
4028 mut config: Map,
4029) -> Result<MotionSource, Box<EvalAltResult>> {
4030 let frames = frames
4031 .into_iter()
4032 .enumerate()
4033 .map(|(index, value)| parse_keyframe(index, value))
4034 .collect::<Result<Vec<_>, _>>()?;
4035 let spec = MotionKeyframes {
4036 property: script_property(property)?,
4037 frames,
4038 delay_ms: take_u64(&mut config, "delay_ms")?.unwrap_or(0),
4039 duration_ms: take_u64(&mut config, "duration_ms")?.unwrap_or(240),
4040 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
4041 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
4042 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
4043 };
4044 reject_unknown_config(&config)?;
4045 checked_source(MotionSource::Keyframes(spec))
4046}
4047
4048fn motion_inertia_from_script(
4049 property: &str,
4050 from: FLOAT,
4051 velocity: FLOAT,
4052 mut config: Map,
4053) -> Result<MotionSource, Box<EvalAltResult>> {
4054 let spec = MotionInertia {
4055 property: script_property(property)?,
4056 from,
4057 velocity,
4058 friction: take_float(&mut config, "friction")?.unwrap_or(8.0),
4059 min: take_float(&mut config, "min")?,
4060 max: take_float(&mut config, "max")?,
4061 bounce: take_float(&mut config, "bounce")?.unwrap_or(0.0),
4062 snap_points: take_float_array(&mut config, "snap_points")?.unwrap_or_default(),
4063 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback),
4064 };
4065 reject_unknown_config(&config)?;
4066 checked_source(MotionSource::Inertia(spec))
4067}
4068
4069fn parse_keyframe(index: usize, value: Dynamic) -> Result<MotionKeyframe, Box<EvalAltResult>> {
4070 let mut frame = value
4071 .try_cast::<Map>()
4072 .ok_or_else(|| script_boxed_error(format!("motion keyframe {index} must be a map")))?;
4073 let offset = take_required_float(&mut frame, "offset")?;
4074 let value = take_required_float(&mut frame, "value")?;
4075 let easing = take_string(&mut frame, "easing")?
4076 .map_or(Ok(MotionEasing::Linear), |value| {
4077 MotionEasing::parse(&value)
4078 })
4079 .map_err(script_boxed_error)?;
4080 reject_unknown_config(&frame)?;
4081 Ok(MotionKeyframe {
4082 offset,
4083 value,
4084 easing,
4085 })
4086}
4087
4088fn script_property(value: &str) -> Result<MotionProperty, Box<EvalAltResult>> {
4089 MotionProperty::parse(value).map_err(script_boxed_error)
4090}
4091
4092fn checked_source(source: MotionSource) -> Result<MotionSource, Box<EvalAltResult>> {
4093 validate_source(&source).map_err(script_boxed_error)?;
4094 Ok(source)
4095}
4096
4097fn take_dynamic(config: &mut Map, key: &str) -> Option<Dynamic> {
4098 config.remove(key)
4099}
4100
4101fn take_string(config: &mut Map, key: &str) -> Result<Option<ImmutableString>, Box<EvalAltResult>> {
4102 take_dynamic(config, key)
4103 .map(|value| {
4104 value.try_cast::<ImmutableString>().ok_or_else(|| {
4105 script_boxed_error(format!("motion option `{key}` must be a string"))
4106 })
4107 })
4108 .transpose()
4109}
4110
4111fn take_bool(config: &mut Map, key: &str) -> Result<Option<bool>, Box<EvalAltResult>> {
4112 take_dynamic(config, key)
4113 .map(|value| {
4114 value.try_cast::<bool>().ok_or_else(|| {
4115 script_boxed_error(format!("motion option `{key}` must be a boolean"))
4116 })
4117 })
4118 .transpose()
4119}
4120
4121fn dynamic_float(value: Dynamic, key: &str) -> Result<f64, Box<EvalAltResult>> {
4122 if value.is::<FLOAT>() {
4123 return Ok(value.cast::<FLOAT>());
4124 }
4125 value
4126 .try_cast::<INT>()
4127 .and_then(|value| value.to_string().parse::<f64>().ok())
4128 .ok_or_else(|| script_boxed_error(format!("motion option `{key}` must be numeric")))
4129}
4130
4131fn take_float(config: &mut Map, key: &str) -> Result<Option<f64>, Box<EvalAltResult>> {
4132 take_dynamic(config, key)
4133 .map(|value| dynamic_float(value, key))
4134 .transpose()
4135}
4136
4137fn take_required_float(config: &mut Map, key: &str) -> Result<f64, Box<EvalAltResult>> {
4138 take_float(config, key)?
4139 .ok_or_else(|| script_boxed_error(format!("motion option `{key}` is required")))
4140}
4141
4142fn take_u64(config: &mut Map, key: &str) -> Result<Option<u64>, Box<EvalAltResult>> {
4143 take_dynamic(config, key)
4144 .map(|value| {
4145 let value = value.try_cast::<INT>().ok_or_else(|| {
4146 script_boxed_error(format!("motion option `{key}` must be an integer"))
4147 })?;
4148 u64::try_from(value).map_err(|_| {
4149 script_boxed_error(format!("motion option `{key}` must be non-negative"))
4150 })
4151 })
4152 .transpose()
4153}
4154
4155#[allow(clippy::option_option)]
4156fn take_iterations(config: &mut Map) -> Result<Option<Option<u32>>, Box<EvalAltResult>> {
4157 let Some(value) = take_dynamic(config, "iterations") else {
4158 return Ok(None);
4159 };
4160 if let Some(value) = value.clone().try_cast::<ImmutableString>() {
4161 return (value.as_str() == "infinite")
4162 .then_some(Some(None))
4163 .ok_or_else(|| script_boxed_error("motion `iterations` string must be `infinite`"));
4164 }
4165 let value = value.try_cast::<INT>().ok_or_else(|| {
4166 script_boxed_error("motion option `iterations` must be a positive integer or `infinite`")
4167 })?;
4168 let value = u32::try_from(value)
4169 .map_err(|_| script_boxed_error("motion option `iterations` must be a positive integer"))?;
4170 if value == 0 {
4171 return Err(script_boxed_error(
4172 "motion option `iterations` must be greater than zero",
4173 ));
4174 }
4175 Ok(Some(Some(value)))
4176}
4177
4178fn take_intent(config: &mut Map) -> Result<Option<MotionIntent>, Box<EvalAltResult>> {
4179 take_string(config, "intent")?
4180 .map(|value| MotionIntent::parse(&value).map_err(script_boxed_error))
4181 .transpose()
4182}
4183
4184fn take_float_array(config: &mut Map, key: &str) -> Result<Option<Vec<f64>>, Box<EvalAltResult>> {
4185 take_dynamic(config, key)
4186 .map(|value| {
4187 let values = value.try_cast::<Array>().ok_or_else(|| {
4188 script_boxed_error(format!("motion option `{key}` must be an array"))
4189 })?;
4190 values
4191 .into_iter()
4192 .enumerate()
4193 .map(|(index, value)| dynamic_float(value, &format!("{key}[{index}]")))
4194 .collect()
4195 })
4196 .transpose()
4197}
4198
4199fn reject_unknown_config(config: &Map) -> Result<(), Box<EvalAltResult>> {
4200 if let Some(key) = config.keys().next() {
4201 Err(script_boxed_error(format!("unknown motion option `{key}`")))
4202 } else {
4203 Ok(())
4204 }
4205}
4206
4207#[allow(clippy::needless_pass_by_value)]
4208fn script_boxed_error(error: impl ToString) -> Box<EvalAltResult> {
4209 Box::new(motion_script_error(&error))
4210}
4211
4212fn motion_script_error(error: &impl ToString) -> EvalAltResult {
4213 EvalAltResult::ErrorRuntime(error.to_string().into(), Position::NONE)
4214}
4215
4216#[derive(Clone, Debug, Error, PartialEq)]
4217pub enum MotionError {
4218 #[error("motion property `{0}` is unknown")]
4219 UnknownProperty(String),
4220 #[error("motion easing `{0}` is unknown")]
4221 UnknownEasing(String),
4222 #[error("motion intent `{0}` is unknown")]
4223 UnknownIntent(String),
4224 #[error("motion parameters must be finite and physically valid")]
4225 InvalidSpec,
4226 #[error("motion node `{0}` requires a stable key")]
4227 MissingKey(String),
4228 #[error("invalid motion timeline: {0}")]
4229 InvalidTimeline(String),
4230 #[error("motion timeline `{0}` is not active in this scope")]
4231 UnknownTimeline(String),
4232 #[error("motion timeline handle `{0}` is stale")]
4233 StaleTimelineHandle(String),
4234 #[error("motion timeline handle `{0}` belongs to another runtime/view/component")]
4235 ForeignTimelineHandle(String),
4236 #[error("active motion budget exceeded: {actual} > {limit}")]
4237 ActiveBudget { actual: usize, limit: usize },
4238 #[error("shared layout identity `{group}/{id}` is duplicated in one presentation domain")]
4239 DuplicateSharedLayout { group: String, id: String },
4240 #[error("invalid shared layout declaration: {0}")]
4241 InvalidSharedLayout(String),
4242 #[error("invalid motion progress source: {0}")]
4243 InvalidProgressSource(String),
4244 #[error("node `{path}` declares more than one source for motion property `{property:?}`")]
4245 DuplicatePropertySource {
4246 path: String,
4247 property: MotionProperty,
4248 },
4249 #[error(
4250 "node `{path}` motion property `{property:?}` has conflicting owners `{first}` and `{second}`"
4251 )]
4252 PropertyOwnerConflict {
4253 path: String,
4254 property: MotionProperty,
4255 first: String,
4256 second: String,
4257 },
4258 #[error("timeline `{timeline}` targets missing node `{target}`")]
4259 UnknownTimelineTarget { timeline: String, target: String },
4260 #[error("virtual collection `{path}` item index {index} has no stable data key")]
4261 MissingVirtualItemKey { path: String, index: usize },
4262 #[error("motion scene is missing retained identity `{0}`")]
4263 MissingRetainedIdentity(String),
4264 #[error("motion scene path `{0}` is not unique")]
4265 DuplicateScenePath(String),
4266 #[error("exit motion does not support paint snapshots for node kind `{0}`")]
4267 UnsupportedExitGhost(String),
4268 #[error("motion property `{property:?}` is unsupported on {node} node `{path}`")]
4269 UnsupportedProperty {
4270 path: String,
4271 property: MotionProperty,
4272 node: &'static str,
4273 },
4274}
4275
4276#[derive(Clone, Debug)]
4277pub struct MotionFrame {
4278 pub values: BTreeMap<MotionKey, f64>,
4279 pub completed: BTreeSet<MotionKey>,
4280 pub needs_frame: bool,
4281}
4282
4283#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4284pub struct MotionResourceUsage {
4285 pub declarations: usize,
4286 pub keyframes: usize,
4287 pub timelines: usize,
4288 pub timeline_steps: usize,
4289 pub active: usize,
4290 pub particles: usize,
4291 pub shared_snapshots: usize,
4292 pub geometry_slots: usize,
4293}
4294
4295#[cfg(test)]
4296mod tests {
4297 use super::*;
4298
4299 fn key_path() -> ComponentInstancePath {
4300 ComponentInstancePath::root("Accordion", "settings")
4301 }
4302
4303 fn transition(property: MotionProperty, from: f64, to: f64, duration_ms: u64) -> MotionSource {
4304 let mut spec = MotionTransition::new(property, from, to, duration_ms);
4305 spec.easing = MotionEasing::Linear;
4306 MotionSource::Transition(spec)
4307 }
4308
4309 fn repeating_transition(
4310 property: MotionProperty,
4311 from: f64,
4312 to: f64,
4313 duration_ms: u64,
4314 ) -> MotionSource {
4315 let MotionSource::Transition(mut spec) = transition(property, from, to, duration_ms) else {
4316 unreachable!()
4317 };
4318 spec.iterations = None;
4319 MotionSource::Transition(spec)
4320 }
4321
4322 #[test]
4323 fn source_constructors_take_integer_and_mixed_numbers() {
4324 let runtime = crate::RuntimeEngine::new();
4325 let sources = runtime
4326 .engine()
4327 .eval::<Array>(
4328 r#"[
4329 motion_transition("opacity", 0, 1, #{}),
4330 motion_spring("translate_y", 12, 0.5, #{}),
4331 motion_inertia("translate_x", 0.0, 40, #{}),
4332 ]"#,
4333 )
4334 .unwrap()
4335 .into_iter()
4336 .map(Dynamic::cast::<MotionSource>)
4337 .collect::<Vec<_>>();
4338 let [
4339 MotionSource::Transition(transition),
4340 MotionSource::Spring(spring),
4341 MotionSource::Inertia(inertia),
4342 ] = sources.as_slice()
4343 else {
4344 panic!("unexpected sources: {sources:?}");
4345 };
4346 assert!((transition.to - 1.0).abs() < f64::EPSILON);
4347 assert!((spring.from - 12.0).abs() < f64::EPSILON);
4348 assert!((spring.to - 0.5).abs() < f64::EPSILON);
4349 assert!((inertia.velocity - 40.0).abs() < f64::EPSILON);
4350 let error = runtime
4351 .engine()
4352 .eval::<MotionSource>(r#"motion_transition("opacity", "0", 1, #{})"#)
4353 .unwrap_err();
4354 assert!(
4355 error.to_string().contains("motion `from` must be a number"),
4356 "{error}"
4357 );
4358 }
4359
4360 #[test]
4361 fn transition_retargets_from_current_sample() {
4362 let start = Instant::now();
4363 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4364 let key = runtime
4365 .start(
4366 key_path(),
4367 transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4368 start,
4369 )
4370 .unwrap();
4371 let halfway = start + Duration::from_millis(50);
4372 assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
4373 runtime
4374 .start(
4375 key_path(),
4376 transition(MotionProperty::Opacity, 1.0, 0.0, 100),
4377 halfway,
4378 )
4379 .unwrap();
4380 assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
4381 }
4382
4383 #[test]
4384 fn reduced_motion_settles_without_frames() {
4385 let mut runtime = MotionRuntime::new(MotionPreference::Reduced);
4386 let key = runtime
4387 .start(
4388 key_path(),
4389 transition(MotionProperty::Height, 0.0, 200.0, 300),
4390 Instant::now(),
4391 )
4392 .unwrap();
4393 assert_eq!(runtime.sample(&key, Instant::now()), Some(200.0));
4394 assert!(!runtime.tick(Instant::now()).needs_frame);
4395 }
4396
4397 #[test]
4398 fn reduced_motion_keeps_looping_indicators_visible_at_midpoint() {
4399 let now = Instant::now();
4400 let spec = repeating_transition(MotionProperty::TranslateX, -72.0, 200.0, 900);
4401 let mut reduced = MotionRuntime::new(MotionPreference::Reduced);
4402 let reduced_key = reduced.start(key_path(), spec.clone(), now).unwrap();
4403 assert_eq!(reduced.sample(&reduced_key, now), Some(64.0));
4404 assert!(!reduced.tick(now).needs_frame);
4405
4406 let mut switched = MotionRuntime::new(MotionPreference::Normal);
4407 let switched_key = switched.start(key_path(), spec, now).unwrap();
4408 switched.set_preference(MotionPreference::Reduced);
4409 assert_eq!(switched.sample(&switched_key, now), Some(64.0));
4410 assert!(!switched.tick(now).needs_frame);
4411 }
4412
4413 #[test]
4414 fn spring_converges_without_rhai_frame_callbacks() {
4415 let start = Instant::now();
4416 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4417 let key = runtime
4418 .start(
4419 key_path(),
4420 MotionSource::Spring(MotionSpring::new(MotionProperty::TranslateY, 0.0, 10.0)),
4421 start,
4422 )
4423 .unwrap();
4424 let mut now = start;
4425 for _ in 0..600 {
4426 now += Duration::from_millis(16);
4427 if !runtime.tick(now).needs_frame {
4428 break;
4429 }
4430 }
4431 assert!((runtime.sample(&key, now).unwrap() - 10.0).abs() < 0.001);
4432 }
4433
4434 #[test]
4435 fn node_declarations_reconcile_without_restarting_same_target() {
4436 let start = Instant::now();
4437 let node = UiNode::text("animated")
4438 .with_key("status")
4439 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100));
4440 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4441 reconcile_node_motion(&node, &mut runtime, start).unwrap();
4442 let halfway = start + Duration::from_millis(50);
4443 let values = reconcile_node_motion(&node, &mut runtime, halfway).unwrap();
4444 let key = MotionKey::for_node("root", MotionProperty::Opacity);
4445 assert!((values[&key] - 0.5).abs() < 0.01);
4446 }
4447
4448 #[test]
4449 fn animated_nodes_require_keys_and_removed_declarations_cancel() {
4450 let start = Instant::now();
4451 let animation = transition(MotionProperty::Height, 0.0, 40.0, 100);
4452 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4453 assert!(matches!(
4454 reconcile_node_motion(
4455 &UiNode::text("missing key").with_motion(animation.clone()),
4456 &mut runtime,
4457 start
4458 ),
4459 Err(MotionError::MissingKey(_))
4460 ));
4461 reconcile_node_motion(
4462 &UiNode::text("keyed").with_key("row").with_motion(animation),
4463 &mut runtime,
4464 start,
4465 )
4466 .unwrap();
4467 assert!(!runtime.snapshot(start).is_empty());
4468 reconcile_node_motion(&UiNode::text("plain"), &mut runtime, start).unwrap();
4469 assert!(runtime.snapshot(start).is_empty());
4470 }
4471
4472 #[test]
4473 fn scoped_reconciliation_does_not_remove_other_window_motion() {
4474 let now = Instant::now();
4475 let node = UiNode::text("loading")
4476 .with_key("progress")
4477 .with_motion(repeating_transition(MotionProperty::Opacity, 0.0, 1.0, 100));
4478 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4479 reconcile_node_motion_scoped(&node, &mut runtime, now, "window:main/root").unwrap();
4480 reconcile_node_motion_scoped(&node, &mut runtime, now, "window:settings/root").unwrap();
4481 assert_eq!(runtime.snapshot(now).len(), 2);
4482 runtime.cancel_node_scope("window:settings/root");
4483 assert_eq!(runtime.snapshot(now).len(), 1);
4484 }
4485
4486 #[test]
4487 fn looping_transition_repeats_without_completing() {
4488 let start = Instant::now();
4489 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4490 let key = runtime
4491 .start(
4492 key_path(),
4493 repeating_transition(MotionProperty::Opacity, 0.2, 0.8, 100),
4494 start,
4495 )
4496 .unwrap();
4497 let first = runtime.tick(start + Duration::from_millis(50));
4498 assert!(first.needs_frame);
4499 assert!((first.values[&key] - 0.5).abs() < 0.01);
4500 let repeated = runtime.tick(start + Duration::from_millis(150));
4501 assert!(repeated.needs_frame);
4502 assert!((repeated.values[&key] - 0.5).abs() < 0.01);
4503 }
4504
4505 #[test]
4506 fn timeline_sequence_seek_pause_and_complete_are_deterministic() {
4507 let start = Instant::now();
4508 let root = MotionTimelineStep::Sequence(vec![
4509 MotionTimelineStep::Track(MotionTrack {
4510 target: ".".to_owned(),
4511 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4512 }),
4513 MotionTimelineStep::Delay(50),
4514 MotionTimelineStep::Track(MotionTrack {
4515 target: ".".to_owned(),
4516 source: transition(MotionProperty::TranslateX, 0.0, 40.0, 100),
4517 }),
4518 ]);
4519 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4520 let handle = runtime
4521 .start_timeline(
4522 ComponentInstancePath::root("UiNode", "root/card"),
4523 MotionTimeline::new("intro", root),
4524 start,
4525 )
4526 .unwrap();
4527 let first = runtime.tick(start + Duration::from_millis(50));
4528 assert!(
4529 (first.values[&MotionKey::for_node("root/card", MotionProperty::Opacity)] - 0.5).abs()
4530 < 0.01
4531 );
4532 runtime
4533 .pause_timeline(&handle, start + Duration::from_millis(60))
4534 .unwrap();
4535 assert!(
4536 (runtime.snapshot(start + Duration::from_millis(500))
4537 [&MotionKey::for_node("root/card", MotionProperty::Opacity)]
4538 - 0.6)
4539 .abs()
4540 < 0.01
4541 );
4542 assert!(!runtime.tick(start + Duration::from_millis(500)).needs_frame);
4543 runtime
4544 .seek_timeline(&handle, 200, start + Duration::from_millis(500))
4545 .unwrap();
4546 assert!(
4547 (runtime.snapshot(start + Duration::from_millis(500))
4548 [&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
4549 - 20.0)
4550 .abs()
4551 < 0.01
4552 );
4553 runtime
4554 .play_timeline(&handle, start + Duration::from_millis(500))
4555 .unwrap();
4556 let final_frame = runtime.tick(start + Duration::from_millis(550));
4557 assert!(
4558 (final_frame.values[&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
4559 - 40.0)
4560 .abs()
4561 < 0.01
4562 );
4563 assert_eq!(
4564 runtime.timeline_state(&handle),
4565 Some(MotionPlaybackState::Completed)
4566 );
4567 }
4568
4569 #[test]
4570 fn timeline_rejects_overlapping_property_ownership() {
4571 let root = MotionTimelineStep::Parallel(vec![
4572 MotionTimelineStep::Track(MotionTrack {
4573 target: ".".to_owned(),
4574 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4575 }),
4576 MotionTimelineStep::Track(MotionTrack {
4577 target: ".".to_owned(),
4578 source: transition(MotionProperty::Opacity, 1.0, 0.0, 100),
4579 }),
4580 ]);
4581 assert!(matches!(
4582 compile_timeline(&root),
4583 Err(MotionError::InvalidTimeline(_))
4584 ));
4585 }
4586
4587 #[test]
4588 fn script_motion_configs_are_strict_and_keyframes_sample_natively() {
4589 let mut engine = Engine::new();
4590 register_motion_api(&mut engine);
4591 let source = engine
4592 .eval::<MotionSource>(
4593 r#"motion_keyframes("opacity", [
4594 #{ offset: 0.0, value: 0.0 },
4595 #{ offset: 0.5, value: 1.0, easing: "ease_out" },
4596 #{ offset: 1.0, value: 0.25 }
4597 ], #{ duration_ms: 200, intent: "decorative" })"#,
4598 )
4599 .unwrap();
4600 assert!(
4601 (sample_source(&source, Duration::from_millis(100)).value - 1.0).abs() < f64::EPSILON
4602 );
4603 assert!(
4604 engine
4605 .eval::<MotionSource>(r#"motion_transition("opacity", 0.0, 1.0, #{ typo: 100 })"#,)
4606 .unwrap_err()
4607 .to_string()
4608 .contains("unknown motion option `typo`")
4609 );
4610 }
4611
4612 #[test]
4613 fn replay_key_restarts_a_settled_declaration_without_restarting_equal_renders() {
4614 let start = Instant::now();
4615 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4616 let source = transition(MotionProperty::Opacity, 0.0, 1.0, 100);
4617 let component = key_path();
4618 let key = runtime
4619 .start_with_replay(
4620 component.clone(),
4621 source.clone(),
4622 Some("first".to_owned()),
4623 start,
4624 )
4625 .unwrap();
4626 let _ = runtime.tick(start + Duration::from_millis(100));
4627 runtime
4628 .start_with_replay(
4629 component.clone(),
4630 source.clone(),
4631 Some("first".to_owned()),
4632 start + Duration::from_millis(120),
4633 )
4634 .unwrap();
4635 assert_eq!(
4636 runtime.sample(&key, start + Duration::from_millis(120)),
4637 Some(1.0)
4638 );
4639 runtime
4640 .start_with_replay(
4641 component,
4642 source,
4643 Some("second".to_owned()),
4644 start + Duration::from_millis(120),
4645 )
4646 .unwrap();
4647 let frame = runtime.tick(start + Duration::from_millis(170));
4648 assert!(frame.needs_frame);
4649 assert!((frame.values[&key] - 0.5).abs() < 0.01);
4650 }
4651
4652 #[test]
4653 fn script_preference_cannot_relax_a_stricter_host_policy() {
4654 let mut runtime = MotionRuntime::new(MotionPreference::Reduced);
4655 runtime.request_preference(MotionPreference::Normal);
4656 assert_eq!(runtime.preference(), MotionPreference::Reduced);
4657 runtime.set_preference(MotionPreference::None);
4658 runtime.request_preference(MotionPreference::Normal);
4659 assert_eq!(runtime.preference(), MotionPreference::None);
4660 }
4661
4662 #[test]
4663 fn timeline_handles_are_instance_bound_and_play_is_idempotent() {
4664 let now = Instant::now();
4665 let scope = ComponentInstancePath::root("UiNode", "root/card");
4666 let timeline = || {
4667 MotionTimeline::new(
4668 "intro",
4669 MotionTimelineStep::Track(MotionTrack {
4670 target: ".".to_owned(),
4671 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4672 }),
4673 )
4674 };
4675 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4676 let old = runtime
4677 .start_timeline(scope.clone(), timeline(), now)
4678 .unwrap();
4679 let _ = runtime.tick(now + Duration::from_millis(400));
4680 runtime
4681 .play_timeline(&old, now + Duration::from_millis(400))
4682 .unwrap();
4683 let key = MotionKey::for_node("root/card", MotionProperty::Opacity);
4684 assert!((runtime.snapshot(now + Duration::from_millis(400))[&key] - 0.4).abs() < 0.01);
4685 runtime.cancel_node_scope("root/card");
4686 let new = runtime.start_timeline(scope, timeline(), now).unwrap();
4687 assert_ne!(old, new);
4688 assert!(matches!(
4689 runtime.cancel_timeline(&old),
4690 Err(MotionError::StaleTimelineHandle(_))
4691 ));
4692 assert_eq!(
4693 runtime.timeline_state(&new),
4694 Some(MotionPlaybackState::Playing)
4695 );
4696 assert!(matches!(
4697 MotionRuntime::new(MotionPreference::Normal).cancel_timeline(&new),
4698 Err(MotionError::StaleTimelineHandle(_))
4699 ));
4700 }
4701
4702 #[test]
4703 fn compatible_generation_migration_preserves_progress_but_rekeys_authority() {
4704 let now = Instant::now();
4705 let owner = ComponentInstancePath::root("View", "root");
4706 let node = UiNode::text("animated")
4707 .with_key("card")
4708 .with_timeline(MotionTimeline::new(
4709 "intro",
4710 MotionTimelineStep::Track(MotionTrack {
4711 target: ".".to_owned(),
4712 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4713 }),
4714 ));
4715 let incarnations = BTreeMap::new();
4716 let reconcile = |runtime: &mut MotionRuntime, generation| {
4717 reconcile_node_motion_scoped_owned(
4718 &node,
4719 runtime,
4720 now,
4721 "root",
4722 MotionReconcileContext {
4723 domain: "root",
4724 root_component: &owner,
4725 root_incarnation: ComponentIncarnation::unscoped(),
4726 generation,
4727 incarnations: &incarnations,
4728 identities: None,
4729 },
4730 )
4731 .unwrap();
4732 };
4733 let first_generation = ScriptGeneration::initial();
4734 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4735 reconcile(&mut runtime, first_generation);
4736 let first = runtime
4737 .timeline_handle_for_owner(
4738 "root",
4739 &owner,
4740 ComponentIncarnation::unscoped(),
4741 first_generation,
4742 "intro",
4743 )
4744 .unwrap();
4745 let _ = runtime.tick(now + Duration::from_millis(400));
4746 reconcile(&mut runtime, first_generation);
4747 assert_eq!(
4748 runtime
4749 .timeline_handle_for_owner(
4750 "root",
4751 &owner,
4752 ComponentIncarnation::unscoped(),
4753 first_generation,
4754 "intro",
4755 )
4756 .unwrap(),
4757 first
4758 );
4759
4760 let next_generation = first_generation.next();
4761 reconcile(&mut runtime, next_generation);
4762 let migrated = runtime
4763 .timeline_handle_for_owner(
4764 "root",
4765 &owner,
4766 ComponentIncarnation::unscoped(),
4767 next_generation,
4768 "intro",
4769 )
4770 .unwrap();
4771 assert_ne!(first, migrated);
4772 assert!(matches!(
4773 runtime.cancel_timeline(&first),
4774 Err(MotionError::StaleTimelineHandle(_))
4775 ));
4776 assert!(
4777 (runtime.snapshot(now + Duration::from_millis(400))
4778 [&MotionKey::for_node("root", MotionProperty::Opacity)]
4779 - 0.4)
4780 .abs()
4781 < 0.01
4782 );
4783 }
4784
4785 #[test]
4786 fn none_policy_cannot_be_bypassed_by_restart() {
4787 let now = Instant::now();
4788 let mut runtime = MotionRuntime::new(MotionPreference::None);
4789 let handle = runtime
4790 .start_timeline(
4791 ComponentInstancePath::root("UiNode", "root/card"),
4792 MotionTimeline::new(
4793 "intro",
4794 MotionTimelineStep::Track(MotionTrack {
4795 target: ".".to_owned(),
4796 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4797 }),
4798 ),
4799 now,
4800 )
4801 .unwrap();
4802 runtime.restart_timeline(&handle, now).unwrap();
4803 let frame = runtime.tick(now + Duration::from_millis(250));
4804 assert!(!frame.needs_frame);
4805 assert_eq!(
4806 runtime.timeline_state(&handle),
4807 Some(MotionPlaybackState::Completed)
4808 );
4809 assert!(
4810 (runtime.snapshot(now)[&MotionKey::for_node("root/card", MotionProperty::Opacity)]
4811 - 1.0)
4812 .abs()
4813 < f64::EPSILON
4814 );
4815
4816 let mut deferred = MotionTimeline::new(
4817 "deferred",
4818 MotionTimelineStep::Track(MotionTrack {
4819 target: ".".to_owned(),
4820 source: transition(MotionProperty::TranslateX, 0.0, 10.0, 1_000),
4821 }),
4822 );
4823 deferred.autoplay = false;
4824 let mut switched = MotionRuntime::new(MotionPreference::Normal);
4825 let handle = switched
4826 .start_timeline(
4827 ComponentInstancePath::root("UiNode", "root/deferred"),
4828 deferred,
4829 now,
4830 )
4831 .unwrap();
4832 switched.set_preference(MotionPreference::None);
4833 assert_eq!(
4834 switched.timeline_state(&handle),
4835 Some(MotionPlaybackState::Completed)
4836 );
4837 assert!(
4838 (switched.snapshot(now)
4839 [&MotionKey::for_node("root/deferred", MotionProperty::TranslateX,)]
4840 - 10.0)
4841 .abs()
4842 < f64::EPSILON
4843 );
4844 }
4845
4846 #[test]
4847 fn none_policy_reprojects_settled_and_outer_autoreverse_terminals() {
4848 let now = Instant::now();
4849 let mut reversed = MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, 100);
4850 reversed.iterations = Some(2);
4851 reversed.autoreverse = true;
4852 let mut reprojected = MotionRuntime::new(MotionPreference::Reduced);
4853 let key = reprojected
4854 .start(
4855 ComponentInstancePath::root("UiNode", "root/reprojected"),
4856 MotionSource::Transition(reversed),
4857 now,
4858 )
4859 .unwrap();
4860 assert!((reprojected.snapshot(now)[&key] - 1.0).abs() < f64::EPSILON);
4861 reprojected.set_preference(MotionPreference::None);
4862 assert!(reprojected.snapshot(now)[&key].abs() < f64::EPSILON);
4863
4864 let mut outer = MotionTimeline::new(
4865 "outer-reverse",
4866 MotionTimelineStep::Track(MotionTrack {
4867 target: ".".to_owned(),
4868 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4869 }),
4870 );
4871 outer.iterations = Some(2);
4872 outer.autoreverse = true;
4873 let mut reduced_timeline = MotionRuntime::new(MotionPreference::Reduced);
4874 reduced_timeline
4875 .start_timeline(
4876 ComponentInstancePath::root("UiNode", "root/outer"),
4877 outer.clone(),
4878 now,
4879 )
4880 .unwrap();
4881 let initial_events = reduced_timeline.drain_timeline_events().len();
4882 reduced_timeline.set_preference(MotionPreference::None);
4883 assert!(
4884 reduced_timeline.snapshot(now)
4885 [&MotionKey::for_node("root/outer", MotionProperty::Opacity)]
4886 .abs()
4887 < f64::EPSILON
4888 );
4889 assert_eq!(reduced_timeline.drain_timeline_events().len(), 0);
4890 assert_eq!(initial_events, 1);
4891 let mut static_timeline = MotionRuntime::new(MotionPreference::None);
4892 static_timeline
4893 .start_timeline(
4894 ComponentInstancePath::root("UiNode", "root/outer"),
4895 outer,
4896 now,
4897 )
4898 .unwrap();
4899 assert!(
4900 static_timeline.snapshot(now)
4901 [&MotionKey::for_node("root/outer", MotionProperty::Opacity)]
4902 .abs()
4903 < f64::EPSILON
4904 );
4905 }
4906
4907 #[test]
4908 fn direct_and_timeline_physics_share_one_sampler_and_extremes_fail() {
4909 let now = Instant::now();
4910 let scope = ComponentInstancePath::root("UiNode", "root/card");
4911 let spring = MotionSpring::new(MotionProperty::TranslateX, 0.0, 1.0);
4912 let mut direct = MotionRuntime::new(MotionPreference::Normal);
4913 let key = direct
4914 .start(scope.clone(), MotionSource::Spring(spring.clone()), now)
4915 .unwrap();
4916 let direct_value = direct.tick(now + Duration::from_secs(1)).values[&key];
4917 let mut timeline = MotionRuntime::new(MotionPreference::Normal);
4918 timeline
4919 .start_timeline(
4920 scope.clone(),
4921 MotionTimeline::new(
4922 "spring",
4923 MotionTimelineStep::Track(MotionTrack {
4924 target: ".".to_owned(),
4925 source: MotionSource::Spring(spring.clone()),
4926 }),
4927 ),
4928 now,
4929 )
4930 .unwrap();
4931 let timeline_value = timeline.tick(now + Duration::from_secs(1)).values[&key];
4932 assert!((direct_value - timeline_value).abs() < 1.0e-9);
4933
4934 let mut retargeted = MotionRuntime::new(MotionPreference::Normal);
4935 retargeted
4936 .start(scope.clone(), MotionSource::Spring(spring.clone()), now)
4937 .unwrap();
4938 let retarget_at = now + Duration::from_millis(16);
4939 let before = retargeted.inspect(retarget_at)[0].velocity.unwrap();
4940 let mut next = spring.clone();
4941 next.to = 2.0;
4942 retargeted
4943 .start(scope.clone(), MotionSource::Spring(next), retarget_at)
4944 .unwrap();
4945 let inherited = retargeted.inspect(retarget_at)[0].velocity.unwrap();
4946 assert!((before - inherited).abs() < 1.0e-9);
4947
4948 let mut extreme = spring;
4949 extreme.stiffness = 1.0e308;
4950 extreme.mass = 1.0e-308;
4951 assert_eq!(
4952 direct.start(scope, MotionSource::Spring(extreme), now),
4953 Err(MotionError::InvalidSpec)
4954 );
4955 }
4956
4957 #[test]
4958 fn unconstrained_inertia_uses_the_closed_form_at_any_animation_age() {
4959 let spec = MotionInertia {
4960 property: MotionProperty::TranslateX,
4961 from: 10.0,
4962 velocity: 1_000.0,
4963 friction: 0.5,
4964 min: None,
4965 max: None,
4966 bounce: 0.0,
4967 snap_points: Vec::new(),
4968 intent: MotionIntent::Decorative,
4969 };
4970 for seconds in [1.0, 5.0, 8.0] {
4971 let elapsed = Duration::from_secs_f64(seconds);
4972 let (position, velocity) = sample_inertia_at(&spec, elapsed);
4973 let decay = (-spec.friction * seconds).exp();
4974 let expected_position = spec.from + spec.velocity * (1.0 - decay) / spec.friction;
4975 let expected_velocity = spec.velocity * decay;
4976 assert!((position - expected_position).abs() < 1.0e-9);
4977 assert!((velocity - expected_velocity).abs() < 1.0e-9);
4978 }
4979 let before_cap = sample_inertia_at(&spec, Duration::from_millis(9_999)).0;
4980 let at_cap = sample_inertia_at(&spec, Duration::from_secs(10)).0;
4981 let after_cap = sample_inertia_at(&spec, Duration::from_secs(11)).0;
4982 assert!((at_cap - before_cap).abs() < 0.1);
4983 assert!((after_cap - at_cap).abs() < f64::EPSILON);
4984 }
4985
4986 #[test]
4987 fn bouncing_inertia_is_bounded_and_matches_timeline_sampling() {
4988 let now = Instant::now();
4989 let scope = ComponentInstancePath::root("UiNode", "root/inertia");
4990 let spec = MotionInertia {
4991 property: MotionProperty::TranslateX,
4992 from: 50.0,
4993 velocity: 500.0,
4994 friction: 1.0,
4995 min: Some(0.0),
4996 max: Some(100.0),
4997 bounce: 0.5,
4998 snap_points: vec![0.0, 100.0],
4999 intent: MotionIntent::Feedback,
5000 };
5001 let mut direct = MotionRuntime::new(MotionPreference::Normal);
5002 let key = direct
5003 .start(scope.clone(), MotionSource::Inertia(spec.clone()), now)
5004 .unwrap();
5005 let mut timeline = MotionRuntime::new(MotionPreference::Normal);
5006 timeline
5007 .start_timeline(
5008 scope,
5009 MotionTimeline::new(
5010 "inertia",
5011 MotionTimelineStep::Track(MotionTrack {
5012 target: ".".to_owned(),
5013 source: MotionSource::Inertia(spec),
5014 }),
5015 ),
5016 now,
5017 )
5018 .unwrap();
5019 for elapsed in [100, 500, 1_000, 5_000] {
5020 let at = now + Duration::from_millis(elapsed);
5021 let direct_value = direct.snapshot(at)[&key];
5022 let timeline_value = timeline.snapshot(at)[&key];
5023 assert!((0.0..=100.0).contains(&direct_value));
5024 assert!((direct_value - timeline_value).abs() < 1.0e-9);
5025 }
5026 }
5027
5028 #[test]
5029 fn timeline_snapshot_resets_future_sequence_tracks_when_seeking_back() {
5030 let now = Instant::now();
5031 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5032 let handle = runtime
5033 .start_timeline(
5034 ComponentInstancePath::root("UiNode", "root/card"),
5035 MotionTimeline::new(
5036 "sequence",
5037 MotionTimelineStep::Sequence(vec![
5038 MotionTimelineStep::Track(MotionTrack {
5039 target: ".".to_owned(),
5040 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5041 }),
5042 MotionTimelineStep::Track(MotionTrack {
5043 target: ".".to_owned(),
5044 source: transition(MotionProperty::TranslateX, 0.0, 100.0, 100),
5045 }),
5046 ]),
5047 ),
5048 now,
5049 )
5050 .unwrap();
5051 let _ = runtime.tick(now + Duration::from_millis(150));
5052 runtime
5053 .seek_timeline(&handle, 0, now + Duration::from_millis(150))
5054 .unwrap();
5055 assert!(
5056 runtime.snapshot(now)[&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
5057 .abs()
5058 < f64::EPSILON
5059 );
5060 }
5061
5062 #[test]
5063 fn terminal_events_are_partitioned_and_discarded_by_presentation_scope() {
5064 let now = Instant::now();
5065 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5066 for domain in ["window:a/view:a/root", "window:b/view:b/root"] {
5067 runtime
5068 .start_timeline(
5069 ComponentInstancePath::root("UiNode", domain),
5070 MotionTimeline::new(
5071 "done",
5072 MotionTimelineStep::Track(MotionTrack {
5073 target: ".".to_owned(),
5074 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1),
5075 }),
5076 ),
5077 now,
5078 )
5079 .unwrap();
5080 }
5081 let _ = runtime.tick(now + Duration::from_millis(1));
5082 assert_eq!(
5083 runtime
5084 .drain_timeline_events_for_domain("window:a/view:a/root")
5085 .len(),
5086 1
5087 );
5088 runtime.discard_timeline_events_in_scope("window:b");
5089 assert_eq!(
5090 runtime.drain_timeline_events(),
5091 [] as [crate::motion::MotionTimelineEvent; 0]
5092 );
5093 }
5094
5095 #[test]
5096 fn autoreverse_completion_keeps_the_sampled_terminal_value() {
5097 let now = Instant::now();
5098 let mut spec = MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, 100);
5099 spec.easing = MotionEasing::Linear;
5100 spec.iterations = Some(2);
5101 spec.autoreverse = true;
5102 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5103 let key = runtime
5104 .start(key_path(), MotionSource::Transition(spec), now)
5105 .unwrap();
5106 let frame = runtime.tick(now + Duration::from_millis(200));
5107 assert!(frame.values[&key].abs() < f64::EPSILON);
5108 assert!(runtime.snapshot(now)[&key].abs() < f64::EPSILON);
5109 }
5110
5111 #[test]
5112 fn reconciliation_validates_targets_ownership_and_is_atomic() {
5113 let now = Instant::now();
5114 let conflict = UiNode::text("conflict")
5115 .with_key("conflict")
5116 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100))
5117 .with_timeline(MotionTimeline::new(
5118 "conflict",
5119 MotionTimelineStep::Track(MotionTrack {
5120 target: ".".to_owned(),
5121 source: transition(MotionProperty::Opacity, 1.0, 0.0, 100),
5122 }),
5123 ));
5124 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5125 assert!(matches!(
5126 reconcile_node_motion(&conflict, &mut runtime, now),
5127 Err(MotionError::PropertyOwnerConflict { .. })
5128 ));
5129 assert_eq!(runtime.resource_usage().active, 0);
5130
5131 let missing = UiNode::text("missing")
5132 .with_key("root")
5133 .with_timeline(MotionTimeline::new(
5134 "missing",
5135 MotionTimelineStep::Track(MotionTrack {
5136 target: "child".to_owned(),
5137 source: transition(MotionProperty::Rotate, 0.0, 90.0, 100),
5138 }),
5139 ));
5140 assert!(matches!(
5141 reconcile_node_motion(&missing, &mut runtime, now),
5142 Err(MotionError::UnknownTimelineTarget { .. })
5143 ));
5144 assert_eq!(runtime.resource_usage().active, 0);
5145
5146 runtime.set_active_limit(1);
5147 reconcile_node_motion(
5148 &UiNode::text("old").with_key("node").with_motion(transition(
5149 MotionProperty::Opacity,
5150 0.0,
5151 1.0,
5152 100,
5153 )),
5154 &mut runtime,
5155 now,
5156 )
5157 .unwrap();
5158 reconcile_node_motion(
5159 &UiNode::text("new").with_key("node").with_motion(transition(
5160 MotionProperty::TranslateX,
5161 0.0,
5162 1.0,
5163 100,
5164 )),
5165 &mut runtime,
5166 now,
5167 )
5168 .unwrap();
5169 assert_eq!(runtime.resource_usage().active, 1);
5170 }
5171
5172 #[test]
5173 fn encoded_paths_do_not_alias_legal_slash_keys_and_root_remounts_restart() {
5174 let now = Instant::now();
5175 let nested = UiNode::column(vec![
5176 UiNode::text("flat").with_key("a/b").with_motion(transition(
5177 MotionProperty::Opacity,
5178 0.0,
5179 1.0,
5180 100,
5181 )),
5182 UiNode::column(vec![
5183 UiNode::text("nested").with_key("b").with_motion(transition(
5184 MotionProperty::Opacity,
5185 0.0,
5186 1.0,
5187 100,
5188 )),
5189 ])
5190 .with_key("a"),
5191 UiNode::text("numeric")
5192 .with_key("0")
5193 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5194 UiNode::text("reserved-looking")
5195 .with_key("item:alpha")
5196 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5197 ]);
5198 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5199 let values = reconcile_node_motion(&nested, &mut runtime, now).unwrap();
5200 assert_eq!(values.len(), 4);
5201
5202 let direct = UiNode::column(vec![
5203 UiNode::text("Title")
5204 .with_key("title")
5205 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5206 ])
5207 .with_key("panel");
5208 let timeline_target = UiNode::column(vec![UiNode::text("Title").with_key("title")])
5209 .with_key("panel")
5210 .with_timeline(MotionTimeline::new(
5211 "child",
5212 MotionTimelineStep::Track(MotionTrack {
5213 target: "title".to_owned(),
5214 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5215 }),
5216 ));
5217 let direct_keys = reconcile_node_motion(
5218 &direct,
5219 &mut MotionRuntime::new(MotionPreference::Normal),
5220 now,
5221 )
5222 .unwrap()
5223 .into_keys()
5224 .collect::<Vec<_>>();
5225 let timeline_keys = reconcile_node_motion(
5226 &timeline_target,
5227 &mut MotionRuntime::new(MotionPreference::Normal),
5228 now,
5229 )
5230 .unwrap()
5231 .into_keys()
5232 .collect::<Vec<_>>();
5233 assert_eq!(direct_keys, timeline_keys);
5234
5235 let timeline = || {
5236 MotionTimeline::new(
5237 "intro",
5238 MotionTimelineStep::Track(MotionTrack {
5239 target: ".".to_owned(),
5240 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
5241 }),
5242 )
5243 };
5244 let first = UiNode::text("first")
5245 .with_key("one")
5246 .with_timeline(timeline());
5247 reconcile_node_motion(&first, &mut runtime, now).unwrap();
5248 let old = runtime.inspect_timelines(now)[0].handle.clone();
5249 let second = UiNode::text("second")
5250 .with_key("two")
5251 .with_timeline(timeline());
5252 reconcile_node_motion(&second, &mut runtime, now + Duration::from_millis(500)).unwrap();
5253 let current = runtime.inspect_timelines(now)[0].handle.clone();
5254 assert_ne!(old, current);
5255 assert!(matches!(
5256 runtime.cancel_timeline(&old),
5257 Err(MotionError::StaleTimelineHandle(_))
5258 ));
5259 assert!(
5260 runtime
5261 .snapshot(now + Duration::from_millis(500))
5262 .values()
5263 .next()
5264 .copied()
5265 .unwrap()
5266 .abs()
5267 < f64::EPSILON
5268 );
5269 }
5270
5271 #[test]
5272 fn retained_timeline_targets_resolve_to_the_target_node_identity() {
5273 let now = Instant::now();
5274 let timeline = || {
5275 MotionTimeline::new(
5276 "intro",
5277 MotionTimelineStep::Track(MotionTrack {
5278 target: "title".to_owned(),
5279 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5280 }),
5281 )
5282 };
5283 let root = UiNode::column(vec![UiNode::text("Title").with_key("title")])
5284 .with_key("panel")
5285 .with_timeline(timeline());
5286 let mut retained = crate::RetainedUiTree::new();
5287 retained.reconcile(root.clone()).unwrap();
5288 let title = retained
5289 .nodes()
5290 .find(|node| node.key() == Some("title"))
5291 .unwrap()
5292 .id();
5293 let identities = retained_motion_identities(&root, &retained, "root").unwrap();
5294 let component = ComponentInstancePath::root("View", "retained-target");
5295 let incarnations = BTreeMap::new();
5296 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5297 let values = reconcile_node_motion_scoped_owned(
5298 &root,
5299 &mut runtime,
5300 now,
5301 "root",
5302 MotionReconcileContext {
5303 domain: "root",
5304 root_component: &component,
5305 root_incarnation: ComponentIncarnation::unscoped(),
5306 generation: ScriptGeneration::initial(),
5307 incarnations: &incarnations,
5308 identities: Some(&identities),
5309 },
5310 )
5311 .unwrap();
5312 assert!(values.contains_key(&MotionKey::for_node(
5313 &retained_node_path("root", title),
5314 MotionProperty::Opacity,
5315 )));
5316
5317 let replacement = UiNode::column(vec![UiNode::box_node(Vec::new()).with_key("title")])
5318 .with_key("panel")
5319 .with_timeline(timeline());
5320 retained.reconcile(replacement.clone()).unwrap();
5321 let replacement_title = retained
5322 .nodes()
5323 .find(|node| node.key() == Some("title"))
5324 .unwrap()
5325 .id();
5326 assert_ne!(title, replacement_title);
5327 let replacement_identities =
5328 retained_motion_identities(&replacement, &retained, "root").unwrap();
5329 let values = reconcile_node_motion_scoped_owned(
5330 &replacement,
5331 &mut runtime,
5332 now + Duration::from_millis(50),
5333 "root",
5334 MotionReconcileContext {
5335 domain: "root",
5336 root_component: &component,
5337 root_incarnation: ComponentIncarnation::unscoped(),
5338 generation: ScriptGeneration::initial(),
5339 incarnations: &incarnations,
5340 identities: Some(&replacement_identities),
5341 },
5342 )
5343 .unwrap();
5344 assert!(!values.contains_key(&MotionKey::for_node(
5345 &retained_node_path("root", title),
5346 MotionProperty::Opacity,
5347 )));
5348 assert!(
5349 (values[&MotionKey::for_node(
5350 &retained_node_path("root", replacement_title),
5351 MotionProperty::Opacity,
5352 )] - 0.5)
5353 .abs()
5354 < 0.01
5355 );
5356 }
5357
5358 #[test]
5359 fn exit_ghost_rejects_an_unsupported_nested_subtree_atomically() {
5360 let now = Instant::now();
5361 let root = UiNode::box_node(vec![UiNode::error_boundary(
5362 UiNode::text("content"),
5363 UiNode::text("fallback"),
5364 )])
5365 .with_key("panel")
5366 .with_exit_motion(transition(MotionProperty::Opacity, 1.0, 0.0, 100));
5367 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5368 assert!(matches!(
5369 reconcile_node_motion(&root, &mut runtime, now),
5370 Err(MotionError::UnsupportedExitGhost(_))
5371 ));
5372 assert_eq!(runtime.resource_usage().active, 0);
5373 }
5374
5375 #[test]
5376 fn affine_canvas_motion_rejects_axis_aligned_path_clips() {
5377 let scene = crate::CanvasScene::new(vec![crate::CanvasCommand::Path {
5378 key: "clipped".to_owned(),
5379 segments: vec![
5380 crate::CanvasPathSegment::Move { x: 0.0, y: 0.0 },
5381 crate::CanvasPathSegment::Line { x: 20.0, y: 20.0 },
5382 ],
5383 fill: None,
5384 stroke: Some((
5385 crate::ColorValue::Literal(crate::Rgba8::from_rgb_hex(0x00ff_ffff)),
5386 1.0,
5387 )),
5388 transform: crate::CanvasTransform::default(),
5389 clip: Some(crate::CanvasClipRect {
5390 x: 0.0,
5391 y: 0.0,
5392 width: 10.0,
5393 height: 10.0,
5394 }),
5395 }])
5396 .unwrap();
5397 let node = UiNode::canvas(scene)
5398 .with_key("canvas")
5399 .with_motion(transition(MotionProperty::Rotate, 0.0, 45.0, 100));
5400 assert!(matches!(
5401 reconcile_node_motion(
5402 &node,
5403 &mut MotionRuntime::new(MotionPreference::Normal),
5404 Instant::now(),
5405 ),
5406 Err(MotionError::UnsupportedProperty {
5407 node: "canvas_with_clipped_path",
5408 ..
5409 })
5410 ));
5411 }
5412
5413 #[test]
5414 fn play_and_restart_recheck_active_budget() {
5415 let now = Instant::now();
5416 let mut timeline = MotionTimeline::new(
5417 "budget",
5418 MotionTimelineStep::Parallel(vec![
5419 MotionTimelineStep::Track(MotionTrack {
5420 target: ".".to_owned(),
5421 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5422 }),
5423 MotionTimelineStep::Track(MotionTrack {
5424 target: ".".to_owned(),
5425 source: transition(MotionProperty::TranslateX, 0.0, 1.0, 100),
5426 }),
5427 ]),
5428 );
5429 timeline.autoplay = false;
5430 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5431 runtime.set_active_limit(1);
5432 let handle = runtime
5433 .start_timeline(
5434 ComponentInstancePath::root("UiNode", "root/card"),
5435 timeline,
5436 now,
5437 )
5438 .unwrap();
5439 assert!(matches!(
5440 runtime.play_timeline(&handle, now),
5441 Err(MotionError::ActiveBudget {
5442 actual: 2,
5443 limit: 1
5444 })
5445 ));
5446 assert_eq!(
5447 runtime.timeline_state(&handle),
5448 Some(MotionPlaybackState::Idle)
5449 );
5450 }
5451
5452 #[test]
5453 fn timeline_budget_is_independent_of_declaration_order() {
5454 let now = Instant::now();
5455 let timeline = |name: &str, property: MotionProperty, autoplay: bool| {
5456 let mut timeline = MotionTimeline::new(
5457 name,
5458 MotionTimelineStep::Track(MotionTrack {
5459 target: ".".to_owned(),
5460 source: transition(property, 0.0, 1.0, 100),
5461 }),
5462 );
5463 timeline.autoplay = autoplay;
5464 timeline
5465 };
5466 for reversed in [false, true] {
5467 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5468 runtime.set_active_limit(1);
5469 let initial = UiNode::text("x")
5470 .with_key("x")
5471 .with_timeline(timeline("a", MotionProperty::Opacity, true))
5472 .with_timeline(timeline("b", MotionProperty::Width, false));
5473 reconcile_node_motion(&initial, &mut runtime, now).unwrap();
5474 let first = timeline("a", MotionProperty::Opacity, false);
5475 let second = timeline("b", MotionProperty::Width, true);
5476 let replacement = if reversed {
5477 UiNode::text("x")
5478 .with_key("x")
5479 .with_timeline(second)
5480 .with_timeline(first)
5481 } else {
5482 UiNode::text("x")
5483 .with_key("x")
5484 .with_timeline(first)
5485 .with_timeline(second)
5486 };
5487 reconcile_node_motion(&replacement, &mut runtime, now).unwrap();
5488 assert_eq!(runtime.resource_usage().active, 1);
5489 }
5490 }
5491}