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
2546fn validate_shared_layout_ids(root: &UiNode) -> Result<(), MotionError> {
2547 shared_layout_ids(root).map(|_| ())
2548}
2549
2550pub(crate) fn shared_layout_ids(root: &UiNode) -> Result<BTreeSet<(String, String)>, MotionError> {
2551 fn visit(node: &UiNode, seen: &mut BTreeSet<(String, String)>) -> Result<(), MotionError> {
2552 let group = node.attributes().get("shared_layout_group");
2553 let id = node.attributes().get("shared_layout_id");
2554 match (group, id) {
2555 (Some(crate::UiValue::String(group)), Some(crate::UiValue::String(id))) => {
2556 if group.is_empty() || id.is_empty() {
2557 return Err(MotionError::InvalidSharedLayout(
2558 "shared layout group and id must be non-empty".to_owned(),
2559 ));
2560 }
2561 if !seen.insert((group.clone(), id.clone())) {
2562 return Err(MotionError::DuplicateSharedLayout {
2563 group: group.clone(),
2564 id: id.clone(),
2565 });
2566 }
2567 }
2568 (None, None) => {}
2569 _ => {
2570 return Err(MotionError::InvalidSharedLayout(
2571 "shared layout group and id must be declared together".to_owned(),
2572 ));
2573 }
2574 }
2575 match node.kind() {
2576 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2577 for child in children {
2578 visit(child, seen)?;
2579 }
2580 }
2581 UiNodeKind::Overlay {
2582 trigger, content, ..
2583 } => {
2584 visit(trigger, seen)?;
2585 visit(content, seen)?;
2586 }
2587 UiNodeKind::Layer { content, .. } => visit(content, seen)?,
2588 UiNodeKind::ErrorBoundary { child, fallback } => {
2589 visit(child, seen)?;
2590 visit(fallback, seen)?;
2591 }
2592 UiNodeKind::VirtualCollection { spec } => {
2593 for child in spec.realized.values() {
2594 visit(child, seen)?;
2595 }
2596 }
2597 UiNodeKind::Text { .. }
2598 | UiNodeKind::RichText { .. }
2599 | UiNodeKind::Canvas { .. }
2600 | UiNodeKind::Svg { .. }
2601 | UiNodeKind::Custom { .. }
2602 | UiNodeKind::Image { .. }
2603 | UiNodeKind::DirectionalImage { .. } => {}
2604 }
2605 Ok(())
2606 }
2607 let mut seen = BTreeSet::new();
2608 visit(root, &mut seen)?;
2609 Ok(seen)
2610}
2611
2612#[allow(clippy::too_many_lines)]
2613pub(crate) fn retained_motion_identities(
2614 root: &UiNode,
2615 retained: &crate::RetainedUiTree,
2616 domain: &str,
2617) -> Result<BTreeMap<String, String>, MotionError> {
2618 fn group_ids(
2619 retained: &crate::RetainedUiTree,
2620 parent: crate::NodeId,
2621 group: &str,
2622 ) -> Result<Vec<crate::NodeId>, MotionError> {
2623 let node = retained
2624 .node(parent)
2625 .ok_or_else(|| MotionError::MissingRetainedIdentity(parent.get().to_string()))?;
2626 Ok(node
2627 .children()
2628 .filter(|child| child.group() == group)
2629 .map(crate::RetainedChildLink::node)
2630 .collect())
2631 }
2632
2633 #[allow(clippy::too_many_lines)]
2634 fn visit(
2635 node: &UiNode,
2636 retained: &crate::RetainedUiTree,
2637 id: crate::NodeId,
2638 path: &str,
2639 domain: &str,
2640 identities: &mut BTreeMap<String, String>,
2641 ) -> Result<(), MotionError> {
2642 let identity = retained_node_path(domain, id);
2643 if identities
2644 .insert(path.to_owned(), identity.clone())
2645 .is_some()
2646 {
2647 return Err(MotionError::DuplicateScenePath(path.to_owned()));
2648 }
2649 if let UiNodeKind::RichText { spans, .. } = node.kind() {
2650 for span in spans {
2651 if let Some(key) = span.key() {
2652 identities.insert(
2653 span_motion_path(path, key),
2654 span_motion_path(&identity, key),
2655 );
2656 }
2657 }
2658 }
2659 match node.kind() {
2660 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2661 let ids = group_ids(retained, id, "children")?;
2662 if ids.len() != children.len() {
2663 return Err(MotionError::MissingRetainedIdentity(path.to_owned()));
2664 }
2665 for (index, (child, child_id)) in children.iter().zip(ids).enumerate() {
2666 visit(
2667 child,
2668 retained,
2669 child_id,
2670 &child_path(path, index, child),
2671 domain,
2672 identities,
2673 )?;
2674 }
2675 }
2676 UiNodeKind::Overlay {
2677 trigger, content, ..
2678 } => {
2679 let trigger_id = group_ids(retained, id, "trigger")?
2680 .into_iter()
2681 .next()
2682 .ok_or_else(|| {
2683 MotionError::MissingRetainedIdentity(format!("{path}/trigger"))
2684 })?;
2685 let content_id = group_ids(retained, id, "content")?
2686 .into_iter()
2687 .next()
2688 .ok_or_else(|| {
2689 MotionError::MissingRetainedIdentity(format!("{path}/content"))
2690 })?;
2691 visit(
2692 trigger,
2693 retained,
2694 trigger_id,
2695 &format!("{path}/trigger"),
2696 domain,
2697 identities,
2698 )?;
2699 visit(
2700 content,
2701 retained,
2702 content_id,
2703 &format!("{path}/content"),
2704 domain,
2705 identities,
2706 )?;
2707 }
2708 UiNodeKind::Layer { content, .. } => {
2709 let child_id = group_ids(retained, id, "content")?
2710 .into_iter()
2711 .next()
2712 .ok_or_else(|| {
2713 MotionError::MissingRetainedIdentity(format!("{path}/content"))
2714 })?;
2715 visit(
2716 content,
2717 retained,
2718 child_id,
2719 &format!("{path}/content"),
2720 domain,
2721 identities,
2722 )?;
2723 }
2724 UiNodeKind::ErrorBoundary { child, fallback } => {
2725 let child_id = group_ids(retained, id, "child")?
2726 .into_iter()
2727 .next()
2728 .ok_or_else(|| {
2729 MotionError::MissingRetainedIdentity(format!("{path}/boundary"))
2730 })?;
2731 let fallback_id = group_ids(retained, id, "fallback")?
2732 .into_iter()
2733 .next()
2734 .ok_or_else(|| {
2735 MotionError::MissingRetainedIdentity(format!("{path}/fallback"))
2736 })?;
2737 visit(
2738 child,
2739 retained,
2740 child_id,
2741 &format!("{path}/boundary"),
2742 domain,
2743 identities,
2744 )?;
2745 visit(
2746 fallback,
2747 retained,
2748 fallback_id,
2749 &format!("{path}/fallback"),
2750 domain,
2751 identities,
2752 )?;
2753 }
2754 UiNodeKind::VirtualCollection { spec } => {
2755 let ids = group_ids(retained, id, "items")?;
2756 if ids.len() != spec.realized.len() {
2757 return Err(MotionError::MissingRetainedIdentity(path.to_owned()));
2758 }
2759 for ((index, item), item_id) in spec.realized.iter().zip(ids) {
2760 let key = crate::virtual_list_element::collection_item_key(spec, *index)
2761 .ok_or_else(|| MotionError::MissingVirtualItemKey {
2762 path: path.to_owned(),
2763 index: *index,
2764 })?;
2765 visit(
2766 item,
2767 retained,
2768 item_id,
2769 &virtual_item_path(path, key),
2770 domain,
2771 identities,
2772 )?;
2773 }
2774 }
2775 UiNodeKind::Text { .. }
2776 | UiNodeKind::RichText { .. }
2777 | UiNodeKind::Canvas { .. }
2778 | UiNodeKind::Svg { .. }
2779 | UiNodeKind::Custom { .. }
2780 | UiNodeKind::Image { .. }
2781 | UiNodeKind::DirectionalImage { .. } => {}
2782 }
2783 Ok(())
2784 }
2785
2786 let root_id = retained
2787 .root_id()
2788 .ok_or_else(|| MotionError::MissingRetainedIdentity(domain.to_owned()))?;
2789 let mut identities = BTreeMap::new();
2790 visit(root, retained, root_id, domain, domain, &mut identities)?;
2791 Ok(identities)
2792}
2793
2794#[must_use]
2795pub fn node_motion_resource_usage(root: &UiNode) -> MotionResourceUsage {
2796 fn visit(node: &UiNode, usage: &mut MotionResourceUsage) {
2797 if let Some(crate::UiValue::Integer(count)) = node.attributes().get("motion_particle_count")
2798 {
2799 usage.particles = usage
2800 .particles
2801 .saturating_add(usize::try_from(*count).unwrap_or(usize::MAX));
2802 }
2803 if node.attributes().contains_key("shared_layout_id") {
2804 usage.shared_snapshots = usage.shared_snapshots.saturating_add(1);
2805 }
2806 if node.attributes().contains_key("layout_motion_duration_ms") {
2807 usage.geometry_slots = usage.geometry_slots.saturating_add(1);
2808 usage.declarations = usage.declarations.saturating_add(1);
2809 }
2810 usage.geometry_slots = usage
2811 .geometry_slots
2812 .saturating_add(node.progress_motions().len());
2813 usage.declarations = usage.declarations.saturating_add(node.motions().len());
2814 usage.declarations = usage.declarations.saturating_add(node.exit_motions().len());
2815 usage.declarations = usage
2816 .declarations
2817 .saturating_add(node.progress_motions().len());
2818 usage.keyframes = usage.keyframes.saturating_add(
2819 node.motions()
2820 .iter()
2821 .map(source_keyframe_count)
2822 .sum::<usize>(),
2823 );
2824 usage.keyframes = usage.keyframes.saturating_add(
2825 node.exit_motions()
2826 .iter()
2827 .map(source_keyframe_count)
2828 .sum::<usize>(),
2829 );
2830 usage.keyframes = usage.keyframes.saturating_add(
2831 node.progress_motions()
2832 .iter()
2833 .map(|binding| source_keyframe_count(&binding.source))
2834 .sum::<usize>(),
2835 );
2836 usage.timelines = usage.timelines.saturating_add(node.timelines().len());
2837 for timeline in node.timelines() {
2838 usage.timeline_steps = usage
2839 .timeline_steps
2840 .saturating_add(timeline_step_count(&timeline.root));
2841 visit_timeline_sources(&timeline.root, &mut |source| {
2842 usage.declarations = usage.declarations.saturating_add(1);
2843 usage.keyframes = usage
2844 .keyframes
2845 .saturating_add(source_keyframe_count(source));
2846 });
2847 }
2848 match node.kind() {
2849 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2850 for child in children {
2851 visit(child, usage);
2852 }
2853 }
2854 UiNodeKind::Overlay {
2855 trigger, content, ..
2856 } => {
2857 visit(trigger, usage);
2858 visit(content, usage);
2859 }
2860 UiNodeKind::Layer { content, .. } => visit(content, usage),
2861 UiNodeKind::ErrorBoundary { child, fallback } => {
2862 visit(child, usage);
2863 visit(fallback, usage);
2864 }
2865 UiNodeKind::VirtualCollection { spec } => {
2866 for child in spec.realized.values() {
2867 visit(child, usage);
2868 }
2869 }
2870 UiNodeKind::RichText { spans, .. } => {
2871 for span in spans {
2872 usage.declarations = usage.declarations.saturating_add(span.motions().len());
2873 usage.keyframes = usage.keyframes.saturating_add(
2874 span.motions()
2875 .iter()
2876 .map(source_keyframe_count)
2877 .sum::<usize>(),
2878 );
2879 }
2880 }
2881 UiNodeKind::Text { .. }
2882 | UiNodeKind::Canvas { .. }
2883 | UiNodeKind::Svg { .. }
2884 | UiNodeKind::Custom { .. }
2885 | UiNodeKind::Image { .. }
2886 | UiNodeKind::DirectionalImage { .. } => {}
2887 }
2888 }
2889
2890 let mut usage = MotionResourceUsage::default();
2891 visit(root, &mut usage);
2892 usage
2893}
2894
2895fn source_keyframe_count(source: &MotionSource) -> usize {
2896 match source {
2897 MotionSource::Keyframes(spec) => spec.frames.len(),
2898 MotionSource::Transition(_) | MotionSource::Spring(_) | MotionSource::Inertia(_) => 0,
2899 }
2900}
2901
2902fn timeline_step_count(step: &MotionTimelineStep) -> usize {
2903 1usize.saturating_add(match step {
2904 MotionTimelineStep::Track(_) | MotionTimelineStep::Delay(_) => 0,
2905 MotionTimelineStep::Sequence(steps)
2906 | MotionTimelineStep::Parallel(steps)
2907 | MotionTimelineStep::Stagger { steps, .. } => steps.iter().map(timeline_step_count).sum(),
2908 })
2909}
2910
2911fn visit_timeline_sources(step: &MotionTimelineStep, visit: &mut impl FnMut(&MotionSource)) {
2912 match step {
2913 MotionTimelineStep::Track(track) => visit(&track.source),
2914 MotionTimelineStep::Delay(_) => {}
2915 MotionTimelineStep::Sequence(steps)
2916 | MotionTimelineStep::Parallel(steps)
2917 | MotionTimelineStep::Stagger { steps, .. } => {
2918 for step in steps {
2919 visit_timeline_sources(step, visit);
2920 }
2921 }
2922 }
2923}
2924
2925#[allow(clippy::too_many_lines)]
2926fn collect_node_motion(
2927 node: &UiNode,
2928 path: &str,
2929 inherited_owner: &ComponentInstancePath,
2930 output: &mut Vec<(String, MotionSource, Option<String>)>,
2931 timelines: &mut Vec<(String, ComponentInstancePath, MotionTimeline)>,
2932) -> Result<(), MotionError> {
2933 let owner = node.component_root().unwrap_or(inherited_owner);
2934 if (!node.motions().is_empty()
2935 || !node.exit_motions().is_empty()
2936 || !node.progress_motions().is_empty()
2937 || !node.timelines().is_empty())
2938 && node.key().is_none()
2939 {
2940 return Err(MotionError::MissingKey(path.to_owned()));
2941 }
2942 if !node.exit_motions().is_empty() && node.motion_ghost().is_none() {
2943 return Err(MotionError::UnsupportedExitGhost(format!(
2944 "{:?}",
2945 node.kind_tag()
2946 )));
2947 }
2948 for source in node.exit_motions() {
2949 validate_source(source)?;
2950 validate_node_property(node, source.property(), path)?;
2951 }
2952 for binding in node.progress_motions() {
2953 validate_progress_source(&binding.source)?;
2954 validate_node_property(node, binding.property(), path)?;
2955 if node
2956 .motions()
2957 .iter()
2958 .any(|source| source.property() == binding.property())
2959 {
2960 return Err(MotionError::DuplicatePropertySource {
2961 path: path.to_owned(),
2962 property: binding.property(),
2963 });
2964 }
2965 }
2966 for source in node.motions() {
2967 validate_node_property(node, source.property(), path)?;
2968 }
2969 let replay_key = match node.attributes().get("motion_replay_key") {
2970 Some(crate::UiValue::String(key)) => Some(key.clone()),
2971 _ => None,
2972 };
2973 output.extend(
2974 node.motions()
2975 .iter()
2976 .cloned()
2977 .map(|animation| (path.to_owned(), animation, replay_key.clone())),
2978 );
2979 timelines.extend(
2980 node.timelines()
2981 .iter()
2982 .cloned()
2983 .map(|timeline| (path.to_owned(), owner.clone(), timeline)),
2984 );
2985 match node.kind() {
2986 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
2987 for (index, child) in children.iter().enumerate() {
2988 collect_node_motion(
2989 child,
2990 &child_path(path, index, child),
2991 owner,
2992 output,
2993 timelines,
2994 )?;
2995 }
2996 }
2997 UiNodeKind::Overlay {
2998 trigger, content, ..
2999 } => {
3000 collect_node_motion(
3001 trigger,
3002 &format!("{path}/trigger"),
3003 owner,
3004 output,
3005 timelines,
3006 )?;
3007 collect_node_motion(
3008 content,
3009 &format!("{path}/content"),
3010 owner,
3011 output,
3012 timelines,
3013 )?;
3014 }
3015 UiNodeKind::Layer { content, .. } => {
3016 collect_node_motion(
3017 content,
3018 &format!("{path}/content"),
3019 owner,
3020 output,
3021 timelines,
3022 )?;
3023 }
3024 UiNodeKind::ErrorBoundary { child, fallback } => {
3025 collect_node_motion(child, &format!("{path}/boundary"), owner, output, timelines)?;
3026 collect_node_motion(
3027 fallback,
3028 &format!("{path}/fallback"),
3029 owner,
3030 output,
3031 timelines,
3032 )?;
3033 }
3034 UiNodeKind::VirtualCollection { spec } => {
3035 for (index, item) in &spec.realized {
3036 let key = crate::virtual_list_element::collection_item_key(spec, *index)
3037 .ok_or_else(|| MotionError::MissingVirtualItemKey {
3038 path: path.to_owned(),
3039 index: *index,
3040 })?;
3041 collect_node_motion(
3042 item,
3043 &virtual_item_path(path, key),
3044 owner,
3045 output,
3046 timelines,
3047 )?;
3048 }
3049 }
3050 UiNodeKind::RichText { spans, .. } => {
3051 for (index, span) in spans.iter().enumerate() {
3052 if span.motions().is_empty() {
3053 continue;
3054 }
3055 let key = span
3056 .key()
3057 .ok_or_else(|| MotionError::MissingKey(format!("{path}/span-index:{index}")))?;
3058 let span_path = span_motion_path(path, key);
3059 if span
3060 .motions()
3061 .iter()
3062 .any(|source| source.property() != MotionProperty::Opacity)
3063 {
3064 return Err(MotionError::UnsupportedProperty {
3065 path: span_path,
3066 property: span
3067 .motions()
3068 .iter()
3069 .find(|source| source.property() != MotionProperty::Opacity)
3070 .map_or(MotionProperty::Opacity, MotionSource::property),
3071 node: "rich_text_span",
3072 });
3073 }
3074 output.extend(
3075 span.motions()
3076 .iter()
3077 .cloned()
3078 .map(|source| (span_path.clone(), source, replay_key.clone())),
3079 );
3080 }
3081 }
3082 UiNodeKind::Text { .. }
3083 | UiNodeKind::Canvas { .. }
3084 | UiNodeKind::Svg { .. }
3085 | UiNodeKind::Custom { .. }
3086 | UiNodeKind::Image { .. }
3087 | UiNodeKind::DirectionalImage { .. } => {}
3088 }
3089 Ok(())
3090}
3091
3092enum MotionSceneTarget<'a> {
3093 Node(&'a UiNode),
3094 Span,
3095}
3096
3097fn validate_motion_plan(
3098 root: &UiNode,
3099 root_path: &str,
3100 declarations: &[(String, MotionSource, Option<String>)],
3101 timelines: &[(String, ComponentInstancePath, MotionTimeline)],
3102) -> Result<(), MotionError> {
3103 let mut scene = BTreeMap::new();
3104 collect_motion_scene(root, root_path, &mut scene)?;
3105 let mut owners = BTreeMap::<(String, MotionProperty), String>::new();
3106 for (path, source, _) in declarations {
3107 insert_property_owner(&mut owners, path, source.property(), "motion declaration")?;
3108 }
3109 collect_non_timeline_owners(root, root_path, &mut owners)?;
3110 for (base, _, timeline) in timelines {
3111 let (tracks, _) = compile_timeline(&timeline.root)?;
3112 let mut timeline_properties = BTreeSet::new();
3113 for track in tracks {
3114 let target_path = resolve_timeline_target(base, &track.target);
3115 let target =
3116 scene
3117 .get(&target_path)
3118 .ok_or_else(|| MotionError::UnknownTimelineTarget {
3119 timeline: timeline.name.clone(),
3120 target: target_path.clone(),
3121 })?;
3122 validate_scene_target_property(target, track.source.property(), &target_path)?;
3123 let key = (target_path.clone(), track.source.property());
3124 if timeline_properties.insert(key.clone()) {
3125 insert_property_owner(
3126 &mut owners,
3127 &target_path,
3128 track.source.property(),
3129 &format!("timeline `{}`", timeline.name),
3130 )?;
3131 }
3132 }
3133 }
3134 Ok(())
3135}
3136
3137fn insert_property_owner(
3138 owners: &mut BTreeMap<(String, MotionProperty), String>,
3139 path: &str,
3140 property: MotionProperty,
3141 owner: &str,
3142) -> Result<(), MotionError> {
3143 let key = (path.to_owned(), property);
3144 if let Some(previous) = owners.insert(key, owner.to_owned()) {
3145 Err(MotionError::PropertyOwnerConflict {
3146 path: path.to_owned(),
3147 property,
3148 first: previous,
3149 second: owner.to_owned(),
3150 })
3151 } else {
3152 Ok(())
3153 }
3154}
3155
3156fn resolve_timeline_target(base: &str, target: &str) -> String {
3157 match target {
3158 "." | "" => base.to_owned(),
3159 target => target.split('/').fold(base.to_owned(), |path, segment| {
3160 format!("{path}/{}", encode_path_segment("key", segment))
3161 }),
3162 }
3163}
3164
3165fn validate_scene_target_property(
3166 target: &MotionSceneTarget<'_>,
3167 property: MotionProperty,
3168 path: &str,
3169) -> Result<(), MotionError> {
3170 match target {
3171 MotionSceneTarget::Node(node) => validate_node_property(node, property, path),
3172 MotionSceneTarget::Span if property == MotionProperty::Opacity => Ok(()),
3173 MotionSceneTarget::Span => Err(MotionError::UnsupportedProperty {
3174 path: path.to_owned(),
3175 property,
3176 node: "rich_text_span",
3177 }),
3178 }
3179}
3180
3181fn collect_non_timeline_owners(
3182 node: &UiNode,
3183 path: &str,
3184 owners: &mut BTreeMap<(String, MotionProperty), String>,
3185) -> Result<(), MotionError> {
3186 for binding in node.progress_motions() {
3187 insert_property_owner(owners, path, binding.property(), "progress binding")?;
3188 }
3189 for (property, _) in node.signal_bindings() {
3190 let property = match property {
3191 crate::SignalProperty::Opacity => Some(MotionProperty::Opacity),
3192 crate::SignalProperty::TranslateX | crate::SignalProperty::TranslateXOverride => {
3193 Some(MotionProperty::TranslateX)
3194 }
3195 crate::SignalProperty::TranslateY | crate::SignalProperty::TranslateYOverride => {
3196 Some(MotionProperty::TranslateY)
3197 }
3198 crate::SignalProperty::Rotate | crate::SignalProperty::RotateOverride => {
3199 Some(MotionProperty::Rotate)
3200 }
3201 crate::SignalProperty::ScaleX | crate::SignalProperty::ScaleXOverride => {
3202 Some(MotionProperty::ScaleX)
3203 }
3204 crate::SignalProperty::ScaleY | crate::SignalProperty::ScaleYOverride => {
3205 Some(MotionProperty::ScaleY)
3206 }
3207 crate::SignalProperty::Width | crate::SignalProperty::WidthOverride => {
3208 Some(MotionProperty::Width)
3209 }
3210 crate::SignalProperty::Height | crate::SignalProperty::HeightOverride => {
3211 Some(MotionProperty::Height)
3212 }
3213 crate::SignalProperty::Background
3214 | crate::SignalProperty::TextColor
3215 | crate::SignalProperty::BorderColor => None,
3216 };
3217 if let Some(property) = property {
3218 insert_property_owner(owners, path, property, "native signal")?;
3219 }
3220 }
3221 visit_motion_children(node, path, |child, child_path| {
3222 collect_non_timeline_owners(child, child_path, owners)
3223 })
3224}
3225
3226fn collect_motion_scene<'a>(
3227 node: &'a UiNode,
3228 path: &str,
3229 scene: &mut BTreeMap<String, MotionSceneTarget<'a>>,
3230) -> Result<(), MotionError> {
3231 if scene
3232 .insert(path.to_owned(), MotionSceneTarget::Node(node))
3233 .is_some()
3234 {
3235 return Err(MotionError::DuplicateScenePath(path.to_owned()));
3236 }
3237 if let UiNodeKind::RichText { spans, .. } = node.kind() {
3238 for span in spans {
3239 if let Some(key) = span.key() {
3240 let span_path = span_motion_path(path, key);
3241 if scene
3242 .insert(span_path.clone(), MotionSceneTarget::Span)
3243 .is_some()
3244 {
3245 return Err(MotionError::DuplicateScenePath(span_path));
3246 }
3247 }
3248 }
3249 }
3250 visit_motion_children(node, path, |child, child_path| {
3251 collect_motion_scene(child, child_path, scene)
3252 })
3253}
3254
3255fn visit_motion_children<'a>(
3256 node: &'a UiNode,
3257 path: &str,
3258 mut visit: impl FnMut(&'a UiNode, &str) -> Result<(), MotionError>,
3259) -> Result<(), MotionError> {
3260 match node.kind() {
3261 UiNodeKind::Box { children } | UiNodeKind::Fragment { children } => {
3262 for (index, child) in children.iter().enumerate() {
3263 visit(child, &child_path(path, index, child))?;
3264 }
3265 }
3266 UiNodeKind::Overlay {
3267 trigger, content, ..
3268 } => {
3269 visit(trigger, &format!("{path}/trigger"))?;
3270 visit(content, &format!("{path}/content"))?;
3271 }
3272 UiNodeKind::Layer { content, .. } => visit(content, &format!("{path}/content"))?,
3273 UiNodeKind::ErrorBoundary { child, fallback } => {
3274 visit(child, &format!("{path}/boundary"))?;
3275 visit(fallback, &format!("{path}/fallback"))?;
3276 }
3277 UiNodeKind::VirtualCollection { spec } => {
3278 for (index, item) in &spec.realized {
3279 let key = crate::virtual_list_element::collection_item_key(spec, *index)
3280 .ok_or_else(|| MotionError::MissingVirtualItemKey {
3281 path: path.to_owned(),
3282 index: *index,
3283 })?;
3284 visit(item, &virtual_item_path(path, key))?;
3285 }
3286 }
3287 UiNodeKind::Text { .. }
3288 | UiNodeKind::RichText { .. }
3289 | UiNodeKind::Canvas { .. }
3290 | UiNodeKind::Svg { .. }
3291 | UiNodeKind::Custom { .. }
3292 | UiNodeKind::Image { .. }
3293 | UiNodeKind::DirectionalImage { .. } => {}
3294 }
3295 Ok(())
3296}
3297
3298fn validate_node_property(
3299 node: &UiNode,
3300 property: MotionProperty,
3301 path: &str,
3302) -> Result<(), MotionError> {
3303 if matches!(
3304 property,
3305 MotionProperty::Rotate
3306 | MotionProperty::ScaleX
3307 | MotionProperty::ScaleY
3308 | MotionProperty::SkewX
3309 | MotionProperty::SkewY
3310 | MotionProperty::PathProgress
3311 ) && !matches!(node.kind(), UiNodeKind::Canvas { .. })
3312 {
3313 Err(MotionError::UnsupportedProperty {
3314 path: path.to_owned(),
3315 property,
3316 node: "non_canvas",
3317 })
3318 } else if matches!(
3319 property,
3320 MotionProperty::Rotate
3321 | MotionProperty::ScaleX
3322 | MotionProperty::ScaleY
3323 | MotionProperty::SkewX
3324 | MotionProperty::SkewY
3325 ) && matches!(node.kind(), UiNodeKind::Canvas { scene } if scene.commands().iter().any(|command| {
3326 matches!(
3327 command,
3328 crate::CanvasCommand::Path { clip: Some(_), .. }
3329 | crate::CanvasCommand::MorphPath { clip: Some(_), .. }
3330 )
3331 })) {
3332 Err(MotionError::UnsupportedProperty {
3333 path: path.to_owned(),
3334 property,
3335 node: "canvas_with_clipped_path",
3336 })
3337 } else {
3338 Ok(())
3339 }
3340}
3341
3342pub(crate) fn child_path(path: &str, index: usize, child: &UiNode) -> String {
3343 child.key().map_or_else(
3344 || format!("{path}/index:{index}"),
3345 |key| format!("{path}/{}", encode_path_segment("key", key.as_str())),
3346 )
3347}
3348
3349#[derive(Clone, Copy, Debug)]
3350struct SourceSample {
3351 value: f64,
3352 velocity: Option<f64>,
3353 done: bool,
3354}
3355
3356fn motion_elapsed(motion: &ActiveMotion, now: Instant) -> Duration {
3357 motion
3358 .elapsed_before_play
3359 .saturating_add(now.saturating_duration_since(motion.started))
3360}
3361
3362fn sample_active_motion(motion: &ActiveMotion, now: Instant, suspended: bool) -> SourceSample {
3363 let elapsed = if suspended {
3364 motion.elapsed_before_play
3365 } else {
3366 motion_elapsed(motion, now)
3367 };
3368 sample_source_with_duration(&motion.source, elapsed, motion.finite_duration_ms)
3369}
3370
3371fn source_initial_value(source: &MotionSource) -> f64 {
3372 match source {
3373 MotionSource::Transition(spec) => spec.from,
3374 MotionSource::Spring(spec) => spec.from,
3375 MotionSource::Keyframes(spec) => spec.frames.first().map_or(0.0, |frame| frame.value),
3376 MotionSource::Inertia(spec) => spec.from,
3377 }
3378}
3379
3380fn source_terminal_value(source: &MotionSource) -> f64 {
3381 source_duration_ms(source).map_or_else(
3382 |_| source.reduced_value(),
3383 |duration| {
3384 sample_source_with_duration(source, Duration::from_millis(duration), Some(duration))
3385 .value
3386 },
3387 )
3388}
3389
3390fn retarget_source(
3391 declaration: &MotionSource,
3392 current: f64,
3393 inherited_velocity: Option<f64>,
3394) -> MotionSource {
3395 match declaration {
3396 MotionSource::Transition(spec) => {
3397 let mut spec = spec.clone();
3398 spec.from = current;
3399 MotionSource::Transition(spec)
3400 }
3401 MotionSource::Spring(spec) => {
3402 let mut spec = spec.clone();
3403 spec.from = current;
3404 spec.initial_velocity = inherited_velocity.unwrap_or(spec.initial_velocity);
3405 MotionSource::Spring(spec)
3406 }
3407 MotionSource::Keyframes(spec) => {
3408 let mut spec = spec.clone();
3409 if let Some(first) = spec.frames.first_mut() {
3410 first.value = current;
3411 }
3412 MotionSource::Keyframes(spec)
3413 }
3414 MotionSource::Inertia(spec) => {
3415 let mut spec = spec.clone();
3416 spec.from = current;
3417 MotionSource::Inertia(spec)
3418 }
3419 }
3420}
3421
3422#[cfg(test)]
3423fn sample_source(source: &MotionSource, elapsed: Duration) -> SourceSample {
3424 sample_source_with_duration(source, elapsed, source_duration_ms(source).ok())
3425}
3426
3427fn sample_source_with_duration(
3428 source: &MotionSource,
3429 elapsed: Duration,
3430 finite_duration_ms: Option<u64>,
3431) -> SourceSample {
3432 let elapsed_ms = duration_ms(elapsed);
3433 let sample = match source {
3434 MotionSource::Transition(spec) => {
3435 let (progress, done) = timed_progress(
3436 elapsed_ms,
3437 spec.delay_ms,
3438 spec.duration_ms,
3439 spec.iterations,
3440 spec.autoreverse,
3441 );
3442 SourceSample {
3443 value: spec.from + (spec.to - spec.from) * spec.easing.sample(progress),
3444 velocity: None,
3445 done,
3446 }
3447 }
3448 MotionSource::Keyframes(spec) => {
3449 let (progress, done) = timed_progress(
3450 elapsed_ms,
3451 spec.delay_ms,
3452 spec.duration_ms,
3453 spec.iterations,
3454 spec.autoreverse,
3455 );
3456 SourceSample {
3457 value: sample_keyframes(&spec.frames, progress),
3458 velocity: None,
3459 done,
3460 }
3461 }
3462 MotionSource::Spring(spec) => {
3463 let settle_ms = finite_duration_ms.unwrap_or_else(|| spring_settle_ms(spec));
3464 let done = elapsed_ms >= settle_ms;
3465 let (value, velocity) = if done {
3466 (spec.to, 0.0)
3467 } else {
3468 sample_spring_at(spec, elapsed.as_secs_f64())
3469 };
3470 SourceSample {
3471 value,
3472 velocity: Some(velocity),
3473 done,
3474 }
3475 }
3476 MotionSource::Inertia(spec) => {
3477 let settle_ms = finite_duration_ms.unwrap_or_else(|| inertia_settle_ms(spec));
3478 let done = elapsed_ms >= settle_ms;
3479 let (value, velocity) =
3480 sample_inertia_at(spec, elapsed.min(Duration::from_millis(settle_ms)));
3481 SourceSample {
3482 value,
3483 velocity: Some(if done { 0.0 } else { velocity }),
3484 done,
3485 }
3486 }
3487 };
3488 if sample.value.is_finite() && sample.velocity.is_none_or(f64::is_finite) {
3489 sample
3490 } else {
3491 SourceSample {
3492 value: source_initial_value(source),
3493 velocity: Some(0.0),
3494 done: true,
3495 }
3496 }
3497}
3498
3499fn timed_progress(
3500 elapsed_ms: u64,
3501 delay_ms: u64,
3502 duration_ms: u64,
3503 iterations: Option<u32>,
3504 autoreverse: bool,
3505) -> (f64, bool) {
3506 if elapsed_ms < delay_ms {
3507 return (0.0, false);
3508 }
3509 let elapsed = elapsed_ms.saturating_sub(delay_ms);
3510 let duration = duration_ms.max(1);
3511 let raw_iteration = elapsed / duration;
3512 let done = iterations.is_some_and(|count| raw_iteration >= u64::from(count));
3513 let cycle = if done {
3514 u64::from(iterations.unwrap_or(1).saturating_sub(1))
3515 } else {
3516 raw_iteration
3517 };
3518 let mut progress = if done {
3519 1.0
3520 } else {
3521 Duration::from_millis(elapsed % duration).as_secs_f64()
3522 / Duration::from_millis(duration).as_secs_f64()
3523 };
3524 if autoreverse && cycle % 2 == 1 {
3525 progress = 1.0 - progress;
3526 }
3527 (progress, done)
3528}
3529
3530fn reduced_value(animation: &ActiveMotion) -> f64 {
3531 animation.declaration.reduced_value()
3532}
3533
3534fn validate_source(spec: &MotionSource) -> Result<(), MotionError> {
3535 let finite = match spec {
3536 MotionSource::Transition(spec) => {
3537 spec.from.is_finite()
3538 && spec.to.is_finite()
3539 && spec.duration_ms > 0
3540 && spec.iterations != Some(0)
3541 }
3542 MotionSource::Spring(spec) => valid_spring(spec),
3543 MotionSource::Keyframes(spec) => {
3544 spec.duration_ms > 0 && spec.iterations != Some(0) && valid_keyframes(&spec.frames)
3545 }
3546 MotionSource::Inertia(spec) => {
3547 spec.from.is_finite()
3548 && spec.velocity.is_finite()
3549 && spec.friction.is_finite()
3550 && (1.0e-9..=1.0e9).contains(&spec.friction)
3551 && spec.from.abs() <= 1.0e12
3552 && spec.velocity.abs() <= 1.0e12
3553 && spec.min.is_none_or(f64::is_finite)
3554 && spec.max.is_none_or(f64::is_finite)
3555 && spec.min.zip(spec.max).is_none_or(|(min, max)| min <= max)
3556 && spec.bounce.is_finite()
3557 && (0.0..=1.0).contains(&spec.bounce)
3558 && spec.snap_points.len() <= 1_024
3559 && spec.snap_points.iter().all(|point| point.is_finite())
3560 }
3561 };
3562 if finite {
3563 Ok(())
3564 } else {
3565 Err(MotionError::InvalidSpec)
3566 }
3567}
3568
3569fn valid_spring(spec: &MotionSpring) -> bool {
3570 if !spec.from.is_finite()
3571 || !spec.to.is_finite()
3572 || !spec.initial_velocity.is_finite()
3573 || spec.from.abs() > 1.0e12
3574 || spec.to.abs() > 1.0e12
3575 || spec.initial_velocity.abs() > 1.0e12
3576 || !(1.0e-9..=1.0e12).contains(&spec.stiffness)
3577 || !(0.0..=1.0e12).contains(&spec.damping)
3578 || !(1.0e-9..=1.0e12).contains(&spec.mass)
3579 {
3580 return false;
3581 }
3582 let ratio = spec.stiffness / spec.mass;
3583 let product = spec.stiffness * spec.mass;
3584 ratio.is_finite()
3585 && product.is_finite()
3586 && ratio.sqrt().is_finite()
3587 && product.sqrt().is_finite()
3588}
3589
3590fn sample_keyframes(frames: &[MotionKeyframe], progress: f64) -> f64 {
3591 let Some(first) = frames.first() else {
3592 return 0.0;
3593 };
3594 if progress <= first.offset {
3595 return first.value;
3596 }
3597 for pair in frames.windows(2) {
3598 let [left, right] = pair else { continue };
3599 if progress <= right.offset {
3600 let width = right.offset - left.offset;
3601 let local = if width <= f64::EPSILON {
3602 1.0
3603 } else {
3604 (progress - left.offset) / width
3605 };
3606 return left.value + (right.value - left.value) * right.easing.sample(local);
3607 }
3608 }
3609 frames.last().map_or(first.value, |frame| frame.value)
3610}
3611
3612fn valid_keyframes(frames: &[MotionKeyframe]) -> bool {
3613 frames.len() >= 2
3614 && frames.len() <= 4_096
3615 && frames
3616 .first()
3617 .is_some_and(|frame| frame.offset.abs() < f64::EPSILON)
3618 && frames
3619 .last()
3620 .is_some_and(|frame| (frame.offset - 1.0).abs() < f64::EPSILON)
3621 && frames.iter().all(|frame| {
3622 frame.offset.is_finite()
3623 && (0.0..=1.0).contains(&frame.offset)
3624 && frame.value.is_finite()
3625 })
3626 && frames
3627 .windows(2)
3628 .all(|pair| pair[0].offset < pair[1].offset)
3629}
3630
3631fn inertia_target(spec: &MotionInertia) -> f64 {
3632 let projected = spec.from + spec.velocity / spec.friction.max(f64::EPSILON);
3633 let clamped = projected.clamp(
3634 spec.min.unwrap_or(f64::NEG_INFINITY),
3635 spec.max.unwrap_or(f64::INFINITY),
3636 );
3637 nearest_snap(clamped, &spec.snap_points).unwrap_or(clamped)
3638}
3639
3640fn nearest_snap(value: f64, points: &[f64]) -> Option<f64> {
3641 points
3642 .iter()
3643 .copied()
3644 .min_by(|left, right| (value - *left).abs().total_cmp(&(value - *right).abs()))
3645}
3646
3647pub(crate) fn register_motion_api(engine: &mut Engine) {
3648 engine.build_type::<MotionSource>();
3649 engine.build_type::<MotionTimelineStep>();
3650 engine.build_type::<MotionTimeline>();
3651 engine.build_type::<MotionHandle>();
3652 engine.build_type::<MotionProgressBinding>();
3653 FuncRegistration::new("motion_transition")
3654 .in_global_namespace()
3655 .register_into_engine(engine, motion_transition_from_script);
3656 FuncRegistration::new("motion_spring")
3657 .in_global_namespace()
3658 .register_into_engine(engine, motion_spring_from_script);
3659 FuncRegistration::new("motion_keyframes")
3660 .in_global_namespace()
3661 .register_into_engine(engine, motion_keyframes_from_script);
3662 FuncRegistration::new("motion_inertia")
3663 .in_global_namespace()
3664 .register_into_engine(engine, motion_inertia_from_script);
3665 FuncRegistration::new("motion_path_follow")
3666 .in_global_namespace()
3667 .register_into_engine(engine, motion_path_follow_from_script);
3668 FuncRegistration::new("motion_in_view")
3669 .in_global_namespace()
3670 .register_into_engine(engine, |source: MotionSource| {
3671 MotionProgressBinding::new(MotionProgressDriver::InView, source)
3672 .map_err(script_boxed_error)
3673 });
3674 FuncRegistration::new("motion_viewport")
3675 .in_global_namespace()
3676 .register_into_engine(engine, |source: MotionSource| {
3677 MotionProgressBinding::new(MotionProgressDriver::Viewport, source)
3678 .map_err(script_boxed_error)
3679 });
3680 FuncRegistration::new("motion_scroll")
3681 .in_global_namespace()
3682 .register_into_engine(engine, motion_scroll_from_script);
3683 for (name, driver) in [
3684 ("motion_hover", MotionProgressDriver::Hover),
3685 ("motion_press", MotionProgressDriver::Press),
3686 ("motion_focus", MotionProgressDriver::Focus),
3687 ] {
3688 FuncRegistration::new(name)
3689 .in_global_namespace()
3690 .register_into_engine(engine, move |source: MotionSource| {
3691 MotionProgressBinding::new(driver, source).map_err(script_boxed_error)
3692 });
3693 }
3694 FuncRegistration::new("motion_text_spans")
3695 .in_global_namespace()
3696 .register_into_engine(engine, motion_text_spans_from_script);
3697 FuncRegistration::new("motion_track")
3698 .in_global_namespace()
3699 .register_into_engine(engine, |target: &str, source: MotionSource| {
3700 MotionTimelineStep::Track(MotionTrack {
3701 target: target.to_owned(),
3702 source,
3703 })
3704 });
3705 FuncRegistration::new("motion_delay")
3706 .in_global_namespace()
3707 .register_into_engine(engine, motion_delay_from_script);
3708 FuncRegistration::new("motion_sequence")
3709 .in_global_namespace()
3710 .register_into_engine(engine, motion_sequence_from_script);
3711 FuncRegistration::new("motion_parallel")
3712 .in_global_namespace()
3713 .register_into_engine(engine, motion_parallel_from_script);
3714 FuncRegistration::new("motion_stagger")
3715 .in_global_namespace()
3716 .register_into_engine(engine, motion_stagger_from_script);
3717 FuncRegistration::new("motion_timeline")
3718 .in_global_namespace()
3719 .register_into_engine(engine, motion_timeline_from_script);
3720}
3721
3722fn motion_text_spans_from_script(text: &str, mut config: Map) -> Result<Array, Box<EvalAltResult>> {
3723 let duration_ms = take_u64(&mut config, "duration_ms")?.unwrap_or(180);
3724 let stagger_ms = take_u64(&mut config, "stagger_ms")?.unwrap_or(24);
3725 let easing = take_string(&mut config, "easing")?
3726 .map_or(Ok(MotionEasing::EaseOut), |value| {
3727 MotionEasing::parse(&value)
3728 })
3729 .map_err(script_boxed_error)?;
3730 let intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative);
3731 reject_unknown_config(&config)?;
3732 Ok(text
3733 .graphemes(true)
3734 .enumerate()
3735 .map(|(index, grapheme)| {
3736 let delay_ms = stagger_ms.saturating_mul(u64::try_from(index).unwrap_or(u64::MAX));
3737 let mut transition =
3738 MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, duration_ms);
3739 transition.delay_ms = delay_ms;
3740 transition.easing = easing;
3741 transition.intent = intent;
3742 Dynamic::from(
3743 crate::Span::new(grapheme)
3744 .with_key(format!("grapheme-{index}"))
3745 .motion(MotionSource::Transition(transition)),
3746 )
3747 })
3748 .collect::<Array>())
3749}
3750
3751fn motion_scroll_from_script(
3752 axis: &str,
3753 source: MotionSource,
3754) -> Result<MotionProgressBinding, Box<EvalAltResult>> {
3755 let driver = match axis {
3756 "x" | "horizontal" => MotionProgressDriver::ScrollX,
3757 "y" | "vertical" => MotionProgressDriver::ScrollY,
3758 _ => return Err(script_boxed_error("motion_scroll axis must be `x` or `y`")),
3759 };
3760 MotionProgressBinding::new(driver, source).map_err(script_boxed_error)
3761}
3762
3763#[allow(clippy::needless_pass_by_value)]
3764fn motion_path_follow_from_script(
3765 scene: crate::CanvasScene,
3766 key: &str,
3767 property: &str,
3768 mut config: Map,
3769) -> Result<MotionSource, Box<EvalAltResult>> {
3770 let property = MotionProperty::parse(property).map_err(script_boxed_error)?;
3771 if !matches!(
3772 property,
3773 MotionProperty::TranslateX | MotionProperty::TranslateY | MotionProperty::Rotate
3774 ) {
3775 return Err(script_boxed_error(
3776 "motion_path_follow property must be `translate_x`, `translate_y`, or `rotate`",
3777 ));
3778 }
3779 let samples = take_u64(&mut config, "samples")?.unwrap_or(128);
3780 if !(16..=512).contains(&samples) {
3781 return Err(script_boxed_error(
3782 "motion_path_follow samples must be between 16 and 512",
3783 ));
3784 }
3785 let mut frames = Vec::with_capacity(usize::try_from(samples + 1).unwrap_or(513));
3786 let samples_u32 = u32::try_from(samples).unwrap_or(512);
3787 for index in 0..=samples_u32 {
3788 let offset = f64::from(index) / f64::from(samples_u32);
3789 let sample = scene.sample_path(key, offset).map_err(script_boxed_error)?;
3790 let value = match property {
3791 MotionProperty::TranslateX => sample.x,
3792 MotionProperty::TranslateY => sample.y,
3793 MotionProperty::Rotate => sample.tangent_degrees,
3794 _ => unreachable!(),
3795 };
3796 frames.push(MotionKeyframe {
3797 offset,
3798 value,
3799 easing: MotionEasing::Linear,
3800 });
3801 }
3802 let source = MotionSource::Keyframes(MotionKeyframes {
3803 property,
3804 frames,
3805 delay_ms: take_u64(&mut config, "delay_ms")?.unwrap_or(0),
3806 duration_ms: take_u64(&mut config, "duration_ms")?.unwrap_or(1_000),
3807 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
3808 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
3809 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
3810 });
3811 reject_unknown_config(&config)?;
3812 checked_source(source)
3813}
3814
3815fn motion_delay_from_script(duration_ms: INT) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3816 let duration_ms = u64::try_from(duration_ms)
3817 .map_err(|_| script_boxed_error("motion delay must be non-negative"))?;
3818 Ok(MotionTimelineStep::Delay(duration_ms))
3819}
3820
3821fn timeline_steps(
3822 values: Array,
3823 kind: &str,
3824) -> Result<Vec<MotionTimelineStep>, Box<EvalAltResult>> {
3825 values
3826 .into_iter()
3827 .enumerate()
3828 .map(|(index, value)| {
3829 value.try_cast::<MotionTimelineStep>().ok_or_else(|| {
3830 script_boxed_error(format!(
3831 "{kind} item {index} must be a motion timeline step"
3832 ))
3833 })
3834 })
3835 .collect()
3836}
3837
3838fn motion_sequence_from_script(values: Array) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3839 Ok(MotionTimelineStep::Sequence(timeline_steps(
3840 values,
3841 "motion_sequence",
3842 )?))
3843}
3844
3845fn motion_parallel_from_script(values: Array) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3846 Ok(MotionTimelineStep::Parallel(timeline_steps(
3847 values,
3848 "motion_parallel",
3849 )?))
3850}
3851
3852fn motion_stagger_from_script(
3853 values: Array,
3854 interval_ms: INT,
3855) -> Result<MotionTimelineStep, Box<EvalAltResult>> {
3856 let interval_ms = u64::try_from(interval_ms)
3857 .map_err(|_| script_boxed_error("motion stagger interval must be non-negative"))?;
3858 Ok(MotionTimelineStep::Stagger {
3859 interval_ms,
3860 steps: timeline_steps(values, "motion_stagger")?,
3861 })
3862}
3863
3864#[allow(clippy::needless_pass_by_value)]
3865fn motion_timeline_from_script(
3866 call: NativeCallContext<'_>,
3867 name: &str,
3868 root: MotionTimelineStep,
3869 mut config: Map,
3870) -> Result<MotionTimeline, Box<EvalAltResult>> {
3871 validate_timeline_name(name).map_err(script_boxed_error)?;
3872 let timeline = MotionTimeline {
3873 name: name.to_owned(),
3874 root,
3875 autoplay: take_bool(&mut config, "autoplay")?.unwrap_or(true),
3876 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
3877 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
3878 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
3879 on_complete: take_callback(&call, &mut config, "on_complete")?,
3880 on_cancel: take_callback(&call, &mut config, "on_cancel")?,
3881 };
3882 reject_unknown_config(&config)?;
3883 compile_timeline(&timeline.root).map_err(script_boxed_error)?;
3884 Ok(timeline)
3885}
3886
3887fn take_callback(
3888 call: &NativeCallContext<'_>,
3889 config: &mut Map,
3890 key: &str,
3891) -> Result<Option<ScriptCallback>, Box<EvalAltResult>> {
3892 let Some(value) = take_dynamic(config, key) else {
3893 return Ok(None);
3894 };
3895 let function = value.try_cast::<FnPtr>().ok_or_else(|| {
3896 script_boxed_error(format!("motion option `{key}` must be a function pointer"))
3897 })?;
3898 let mut callback = ScriptCallback::try_from_fn_ptr(function, ScriptGeneration::default())
3899 .map_err(script_boxed_error)?;
3900 callback.bind_native_context_if_unset(
3901 crate::invocation::ScriptInvocationContext::capture_retained(call),
3902 );
3903 Ok(Some(callback))
3904}
3905
3906fn motion_transition_from_script(
3907 property: &str,
3908 from: FLOAT,
3909 to: FLOAT,
3910 mut config: Map,
3911) -> Result<MotionSource, Box<EvalAltResult>> {
3912 let property = script_property(property)?;
3913 let mut transition = MotionTransition::new(property, from, to, 180);
3914 transition.delay_ms = take_u64(&mut config, "delay_ms")?.unwrap_or(0);
3915 transition.duration_ms = take_u64(&mut config, "duration_ms")?.unwrap_or(180);
3916 transition.easing = take_string(&mut config, "easing")?
3917 .map_or(Ok(MotionEasing::EaseOut), |value| {
3918 MotionEasing::parse(&value)
3919 })
3920 .map_err(script_boxed_error)?;
3921 transition.iterations = take_iterations(&mut config)?.unwrap_or(Some(1));
3922 transition.autoreverse = take_bool(&mut config, "autoreverse")?.unwrap_or(false);
3923 transition.intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback);
3924 reject_unknown_config(&config)?;
3925 checked_source(MotionSource::Transition(transition))
3926}
3927
3928fn motion_spring_from_script(
3929 property: &str,
3930 from: FLOAT,
3931 to: FLOAT,
3932 mut config: Map,
3933) -> Result<MotionSource, Box<EvalAltResult>> {
3934 let property = script_property(property)?;
3935 let mut spring = MotionSpring::new(property, from, to);
3936 spring.initial_velocity = take_float(&mut config, "initial_velocity")?.unwrap_or(0.0);
3937 spring.stiffness = take_float(&mut config, "stiffness")?.unwrap_or(180.0);
3938 spring.damping = take_float(&mut config, "damping")?.unwrap_or(24.0);
3939 spring.mass = take_float(&mut config, "mass")?.unwrap_or(1.0);
3940 spring.intent = take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback);
3941 reject_unknown_config(&config)?;
3942 checked_source(MotionSource::Spring(spring))
3943}
3944
3945fn motion_keyframes_from_script(
3946 property: &str,
3947 frames: Array,
3948 mut config: Map,
3949) -> Result<MotionSource, Box<EvalAltResult>> {
3950 let frames = frames
3951 .into_iter()
3952 .enumerate()
3953 .map(|(index, value)| parse_keyframe(index, value))
3954 .collect::<Result<Vec<_>, _>>()?;
3955 let spec = MotionKeyframes {
3956 property: script_property(property)?,
3957 frames,
3958 delay_ms: take_u64(&mut config, "delay_ms")?.unwrap_or(0),
3959 duration_ms: take_u64(&mut config, "duration_ms")?.unwrap_or(240),
3960 iterations: take_iterations(&mut config)?.unwrap_or(Some(1)),
3961 autoreverse: take_bool(&mut config, "autoreverse")?.unwrap_or(false),
3962 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Decorative),
3963 };
3964 reject_unknown_config(&config)?;
3965 checked_source(MotionSource::Keyframes(spec))
3966}
3967
3968fn motion_inertia_from_script(
3969 property: &str,
3970 from: FLOAT,
3971 velocity: FLOAT,
3972 mut config: Map,
3973) -> Result<MotionSource, Box<EvalAltResult>> {
3974 let spec = MotionInertia {
3975 property: script_property(property)?,
3976 from,
3977 velocity,
3978 friction: take_float(&mut config, "friction")?.unwrap_or(8.0),
3979 min: take_float(&mut config, "min")?,
3980 max: take_float(&mut config, "max")?,
3981 bounce: take_float(&mut config, "bounce")?.unwrap_or(0.0),
3982 snap_points: take_float_array(&mut config, "snap_points")?.unwrap_or_default(),
3983 intent: take_intent(&mut config)?.unwrap_or(MotionIntent::Feedback),
3984 };
3985 reject_unknown_config(&config)?;
3986 checked_source(MotionSource::Inertia(spec))
3987}
3988
3989fn parse_keyframe(index: usize, value: Dynamic) -> Result<MotionKeyframe, Box<EvalAltResult>> {
3990 let mut frame = value
3991 .try_cast::<Map>()
3992 .ok_or_else(|| script_boxed_error(format!("motion keyframe {index} must be a map")))?;
3993 let offset = take_required_float(&mut frame, "offset")?;
3994 let value = take_required_float(&mut frame, "value")?;
3995 let easing = take_string(&mut frame, "easing")?
3996 .map_or(Ok(MotionEasing::Linear), |value| {
3997 MotionEasing::parse(&value)
3998 })
3999 .map_err(script_boxed_error)?;
4000 reject_unknown_config(&frame)?;
4001 Ok(MotionKeyframe {
4002 offset,
4003 value,
4004 easing,
4005 })
4006}
4007
4008fn script_property(value: &str) -> Result<MotionProperty, Box<EvalAltResult>> {
4009 MotionProperty::parse(value).map_err(script_boxed_error)
4010}
4011
4012fn checked_source(source: MotionSource) -> Result<MotionSource, Box<EvalAltResult>> {
4013 validate_source(&source).map_err(script_boxed_error)?;
4014 Ok(source)
4015}
4016
4017fn take_dynamic(config: &mut Map, key: &str) -> Option<Dynamic> {
4018 config.remove(key)
4019}
4020
4021fn take_string(config: &mut Map, key: &str) -> Result<Option<ImmutableString>, Box<EvalAltResult>> {
4022 take_dynamic(config, key)
4023 .map(|value| {
4024 value.try_cast::<ImmutableString>().ok_or_else(|| {
4025 script_boxed_error(format!("motion option `{key}` must be a string"))
4026 })
4027 })
4028 .transpose()
4029}
4030
4031fn take_bool(config: &mut Map, key: &str) -> Result<Option<bool>, Box<EvalAltResult>> {
4032 take_dynamic(config, key)
4033 .map(|value| {
4034 value.try_cast::<bool>().ok_or_else(|| {
4035 script_boxed_error(format!("motion option `{key}` must be a boolean"))
4036 })
4037 })
4038 .transpose()
4039}
4040
4041fn dynamic_float(value: Dynamic, key: &str) -> Result<f64, Box<EvalAltResult>> {
4042 if value.is::<FLOAT>() {
4043 return Ok(value.cast::<FLOAT>());
4044 }
4045 value
4046 .try_cast::<INT>()
4047 .and_then(|value| value.to_string().parse::<f64>().ok())
4048 .ok_or_else(|| script_boxed_error(format!("motion option `{key}` must be numeric")))
4049}
4050
4051fn take_float(config: &mut Map, key: &str) -> Result<Option<f64>, Box<EvalAltResult>> {
4052 take_dynamic(config, key)
4053 .map(|value| dynamic_float(value, key))
4054 .transpose()
4055}
4056
4057fn take_required_float(config: &mut Map, key: &str) -> Result<f64, Box<EvalAltResult>> {
4058 take_float(config, key)?
4059 .ok_or_else(|| script_boxed_error(format!("motion option `{key}` is required")))
4060}
4061
4062fn take_u64(config: &mut Map, key: &str) -> Result<Option<u64>, Box<EvalAltResult>> {
4063 take_dynamic(config, key)
4064 .map(|value| {
4065 let value = value.try_cast::<INT>().ok_or_else(|| {
4066 script_boxed_error(format!("motion option `{key}` must be an integer"))
4067 })?;
4068 u64::try_from(value).map_err(|_| {
4069 script_boxed_error(format!("motion option `{key}` must be non-negative"))
4070 })
4071 })
4072 .transpose()
4073}
4074
4075#[allow(clippy::option_option)]
4076fn take_iterations(config: &mut Map) -> Result<Option<Option<u32>>, Box<EvalAltResult>> {
4077 let Some(value) = take_dynamic(config, "iterations") else {
4078 return Ok(None);
4079 };
4080 if let Some(value) = value.clone().try_cast::<ImmutableString>() {
4081 return (value.as_str() == "infinite")
4082 .then_some(Some(None))
4083 .ok_or_else(|| script_boxed_error("motion `iterations` string must be `infinite`"));
4084 }
4085 let value = value.try_cast::<INT>().ok_or_else(|| {
4086 script_boxed_error("motion option `iterations` must be a positive integer or `infinite`")
4087 })?;
4088 let value = u32::try_from(value)
4089 .map_err(|_| script_boxed_error("motion option `iterations` must be a positive integer"))?;
4090 if value == 0 {
4091 return Err(script_boxed_error(
4092 "motion option `iterations` must be greater than zero",
4093 ));
4094 }
4095 Ok(Some(Some(value)))
4096}
4097
4098fn take_intent(config: &mut Map) -> Result<Option<MotionIntent>, Box<EvalAltResult>> {
4099 take_string(config, "intent")?
4100 .map(|value| MotionIntent::parse(&value).map_err(script_boxed_error))
4101 .transpose()
4102}
4103
4104fn take_float_array(config: &mut Map, key: &str) -> Result<Option<Vec<f64>>, Box<EvalAltResult>> {
4105 take_dynamic(config, key)
4106 .map(|value| {
4107 let values = value.try_cast::<Array>().ok_or_else(|| {
4108 script_boxed_error(format!("motion option `{key}` must be an array"))
4109 })?;
4110 values
4111 .into_iter()
4112 .enumerate()
4113 .map(|(index, value)| dynamic_float(value, &format!("{key}[{index}]")))
4114 .collect()
4115 })
4116 .transpose()
4117}
4118
4119fn reject_unknown_config(config: &Map) -> Result<(), Box<EvalAltResult>> {
4120 if let Some(key) = config.keys().next() {
4121 Err(script_boxed_error(format!("unknown motion option `{key}`")))
4122 } else {
4123 Ok(())
4124 }
4125}
4126
4127#[allow(clippy::needless_pass_by_value)]
4128fn script_boxed_error(error: impl ToString) -> Box<EvalAltResult> {
4129 Box::new(motion_script_error(&error))
4130}
4131
4132fn motion_script_error(error: &impl ToString) -> EvalAltResult {
4133 EvalAltResult::ErrorRuntime(error.to_string().into(), Position::NONE)
4134}
4135
4136#[derive(Clone, Debug, Error, PartialEq)]
4137pub enum MotionError {
4138 #[error("motion property `{0}` is unknown")]
4139 UnknownProperty(String),
4140 #[error("motion easing `{0}` is unknown")]
4141 UnknownEasing(String),
4142 #[error("motion intent `{0}` is unknown")]
4143 UnknownIntent(String),
4144 #[error("motion parameters must be finite and physically valid")]
4145 InvalidSpec,
4146 #[error("motion node `{0}` requires a stable key")]
4147 MissingKey(String),
4148 #[error("invalid motion timeline: {0}")]
4149 InvalidTimeline(String),
4150 #[error("motion timeline `{0}` is not active in this scope")]
4151 UnknownTimeline(String),
4152 #[error("motion timeline handle `{0}` is stale")]
4153 StaleTimelineHandle(String),
4154 #[error("motion timeline handle `{0}` belongs to another runtime/view/component")]
4155 ForeignTimelineHandle(String),
4156 #[error("active motion budget exceeded: {actual} > {limit}")]
4157 ActiveBudget { actual: usize, limit: usize },
4158 #[error("shared layout identity `{group}/{id}` is duplicated in one presentation domain")]
4159 DuplicateSharedLayout { group: String, id: String },
4160 #[error("invalid shared layout declaration: {0}")]
4161 InvalidSharedLayout(String),
4162 #[error("invalid motion progress source: {0}")]
4163 InvalidProgressSource(String),
4164 #[error("node `{path}` declares more than one source for motion property `{property:?}`")]
4165 DuplicatePropertySource {
4166 path: String,
4167 property: MotionProperty,
4168 },
4169 #[error(
4170 "node `{path}` motion property `{property:?}` has conflicting owners `{first}` and `{second}`"
4171 )]
4172 PropertyOwnerConflict {
4173 path: String,
4174 property: MotionProperty,
4175 first: String,
4176 second: String,
4177 },
4178 #[error("timeline `{timeline}` targets missing node `{target}`")]
4179 UnknownTimelineTarget { timeline: String, target: String },
4180 #[error("virtual collection `{path}` item index {index} has no stable data key")]
4181 MissingVirtualItemKey { path: String, index: usize },
4182 #[error("motion scene is missing retained identity `{0}`")]
4183 MissingRetainedIdentity(String),
4184 #[error("motion scene path `{0}` is not unique")]
4185 DuplicateScenePath(String),
4186 #[error("exit motion does not support paint snapshots for node kind `{0}`")]
4187 UnsupportedExitGhost(String),
4188 #[error("motion property `{property:?}` is unsupported on {node} node `{path}`")]
4189 UnsupportedProperty {
4190 path: String,
4191 property: MotionProperty,
4192 node: &'static str,
4193 },
4194}
4195
4196#[derive(Clone, Debug)]
4197pub struct MotionFrame {
4198 pub values: BTreeMap<MotionKey, f64>,
4199 pub completed: BTreeSet<MotionKey>,
4200 pub needs_frame: bool,
4201}
4202
4203#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4204pub struct MotionResourceUsage {
4205 pub declarations: usize,
4206 pub keyframes: usize,
4207 pub timelines: usize,
4208 pub timeline_steps: usize,
4209 pub active: usize,
4210 pub particles: usize,
4211 pub shared_snapshots: usize,
4212 pub geometry_slots: usize,
4213}
4214
4215#[cfg(test)]
4216mod tests {
4217 use super::*;
4218
4219 fn key_path() -> ComponentInstancePath {
4220 ComponentInstancePath::root("Accordion", "settings")
4221 }
4222
4223 fn transition(property: MotionProperty, from: f64, to: f64, duration_ms: u64) -> MotionSource {
4224 let mut spec = MotionTransition::new(property, from, to, duration_ms);
4225 spec.easing = MotionEasing::Linear;
4226 MotionSource::Transition(spec)
4227 }
4228
4229 fn repeating_transition(
4230 property: MotionProperty,
4231 from: f64,
4232 to: f64,
4233 duration_ms: u64,
4234 ) -> MotionSource {
4235 let MotionSource::Transition(mut spec) = transition(property, from, to, duration_ms) else {
4236 unreachable!()
4237 };
4238 spec.iterations = None;
4239 MotionSource::Transition(spec)
4240 }
4241
4242 #[test]
4243 fn transition_retargets_from_current_sample() {
4244 let start = Instant::now();
4245 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4246 let key = runtime
4247 .start(
4248 key_path(),
4249 transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4250 start,
4251 )
4252 .unwrap();
4253 let halfway = start + Duration::from_millis(50);
4254 assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
4255 runtime
4256 .start(
4257 key_path(),
4258 transition(MotionProperty::Opacity, 1.0, 0.0, 100),
4259 halfway,
4260 )
4261 .unwrap();
4262 assert!((runtime.sample(&key, halfway).unwrap() - 0.5).abs() < 0.01);
4263 }
4264
4265 #[test]
4266 fn reduced_motion_settles_without_frames() {
4267 let mut runtime = MotionRuntime::new(MotionPreference::Reduced);
4268 let key = runtime
4269 .start(
4270 key_path(),
4271 transition(MotionProperty::Height, 0.0, 200.0, 300),
4272 Instant::now(),
4273 )
4274 .unwrap();
4275 assert_eq!(runtime.sample(&key, Instant::now()), Some(200.0));
4276 assert!(!runtime.tick(Instant::now()).needs_frame);
4277 }
4278
4279 #[test]
4280 fn reduced_motion_keeps_looping_indicators_visible_at_midpoint() {
4281 let now = Instant::now();
4282 let spec = repeating_transition(MotionProperty::TranslateX, -72.0, 200.0, 900);
4283 let mut reduced = MotionRuntime::new(MotionPreference::Reduced);
4284 let reduced_key = reduced.start(key_path(), spec.clone(), now).unwrap();
4285 assert_eq!(reduced.sample(&reduced_key, now), Some(64.0));
4286 assert!(!reduced.tick(now).needs_frame);
4287
4288 let mut switched = MotionRuntime::new(MotionPreference::Normal);
4289 let switched_key = switched.start(key_path(), spec, now).unwrap();
4290 switched.set_preference(MotionPreference::Reduced);
4291 assert_eq!(switched.sample(&switched_key, now), Some(64.0));
4292 assert!(!switched.tick(now).needs_frame);
4293 }
4294
4295 #[test]
4296 fn spring_converges_without_rhai_frame_callbacks() {
4297 let start = Instant::now();
4298 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4299 let key = runtime
4300 .start(
4301 key_path(),
4302 MotionSource::Spring(MotionSpring::new(MotionProperty::TranslateY, 0.0, 10.0)),
4303 start,
4304 )
4305 .unwrap();
4306 let mut now = start;
4307 for _ in 0..600 {
4308 now += Duration::from_millis(16);
4309 if !runtime.tick(now).needs_frame {
4310 break;
4311 }
4312 }
4313 assert!((runtime.sample(&key, now).unwrap() - 10.0).abs() < 0.001);
4314 }
4315
4316 #[test]
4317 fn node_declarations_reconcile_without_restarting_same_target() {
4318 let start = Instant::now();
4319 let node = UiNode::text("animated")
4320 .with_key("status")
4321 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100));
4322 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4323 reconcile_node_motion(&node, &mut runtime, start).unwrap();
4324 let halfway = start + Duration::from_millis(50);
4325 let values = reconcile_node_motion(&node, &mut runtime, halfway).unwrap();
4326 let key = MotionKey::for_node("root", MotionProperty::Opacity);
4327 assert!((values[&key] - 0.5).abs() < 0.01);
4328 }
4329
4330 #[test]
4331 fn animated_nodes_require_keys_and_removed_declarations_cancel() {
4332 let start = Instant::now();
4333 let animation = transition(MotionProperty::Height, 0.0, 40.0, 100);
4334 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4335 assert!(matches!(
4336 reconcile_node_motion(
4337 &UiNode::text("missing key").with_motion(animation.clone()),
4338 &mut runtime,
4339 start
4340 ),
4341 Err(MotionError::MissingKey(_))
4342 ));
4343 reconcile_node_motion(
4344 &UiNode::text("keyed").with_key("row").with_motion(animation),
4345 &mut runtime,
4346 start,
4347 )
4348 .unwrap();
4349 assert!(!runtime.snapshot(start).is_empty());
4350 reconcile_node_motion(&UiNode::text("plain"), &mut runtime, start).unwrap();
4351 assert!(runtime.snapshot(start).is_empty());
4352 }
4353
4354 #[test]
4355 fn scoped_reconciliation_does_not_remove_other_window_motion() {
4356 let now = Instant::now();
4357 let node = UiNode::text("loading")
4358 .with_key("progress")
4359 .with_motion(repeating_transition(MotionProperty::Opacity, 0.0, 1.0, 100));
4360 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4361 reconcile_node_motion_scoped(&node, &mut runtime, now, "window:main/root").unwrap();
4362 reconcile_node_motion_scoped(&node, &mut runtime, now, "window:settings/root").unwrap();
4363 assert_eq!(runtime.snapshot(now).len(), 2);
4364 runtime.cancel_node_scope("window:settings/root");
4365 assert_eq!(runtime.snapshot(now).len(), 1);
4366 }
4367
4368 #[test]
4369 fn looping_transition_repeats_without_completing() {
4370 let start = Instant::now();
4371 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4372 let key = runtime
4373 .start(
4374 key_path(),
4375 repeating_transition(MotionProperty::Opacity, 0.2, 0.8, 100),
4376 start,
4377 )
4378 .unwrap();
4379 let first = runtime.tick(start + Duration::from_millis(50));
4380 assert!(first.needs_frame);
4381 assert!((first.values[&key] - 0.5).abs() < 0.01);
4382 let repeated = runtime.tick(start + Duration::from_millis(150));
4383 assert!(repeated.needs_frame);
4384 assert!((repeated.values[&key] - 0.5).abs() < 0.01);
4385 }
4386
4387 #[test]
4388 fn timeline_sequence_seek_pause_and_complete_are_deterministic() {
4389 let start = Instant::now();
4390 let root = MotionTimelineStep::Sequence(vec![
4391 MotionTimelineStep::Track(MotionTrack {
4392 target: ".".to_owned(),
4393 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4394 }),
4395 MotionTimelineStep::Delay(50),
4396 MotionTimelineStep::Track(MotionTrack {
4397 target: ".".to_owned(),
4398 source: transition(MotionProperty::TranslateX, 0.0, 40.0, 100),
4399 }),
4400 ]);
4401 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4402 let handle = runtime
4403 .start_timeline(
4404 ComponentInstancePath::root("UiNode", "root/card"),
4405 MotionTimeline::new("intro", root),
4406 start,
4407 )
4408 .unwrap();
4409 let first = runtime.tick(start + Duration::from_millis(50));
4410 assert!(
4411 (first.values[&MotionKey::for_node("root/card", MotionProperty::Opacity)] - 0.5).abs()
4412 < 0.01
4413 );
4414 runtime
4415 .pause_timeline(&handle, start + Duration::from_millis(60))
4416 .unwrap();
4417 assert!(
4418 (runtime.snapshot(start + Duration::from_millis(500))
4419 [&MotionKey::for_node("root/card", MotionProperty::Opacity)]
4420 - 0.6)
4421 .abs()
4422 < 0.01
4423 );
4424 assert!(!runtime.tick(start + Duration::from_millis(500)).needs_frame);
4425 runtime
4426 .seek_timeline(&handle, 200, start + Duration::from_millis(500))
4427 .unwrap();
4428 assert!(
4429 (runtime.snapshot(start + Duration::from_millis(500))
4430 [&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
4431 - 20.0)
4432 .abs()
4433 < 0.01
4434 );
4435 runtime
4436 .play_timeline(&handle, start + Duration::from_millis(500))
4437 .unwrap();
4438 let final_frame = runtime.tick(start + Duration::from_millis(550));
4439 assert!(
4440 (final_frame.values[&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
4441 - 40.0)
4442 .abs()
4443 < 0.01
4444 );
4445 assert_eq!(
4446 runtime.timeline_state(&handle),
4447 Some(MotionPlaybackState::Completed)
4448 );
4449 }
4450
4451 #[test]
4452 fn timeline_rejects_overlapping_property_ownership() {
4453 let root = MotionTimelineStep::Parallel(vec![
4454 MotionTimelineStep::Track(MotionTrack {
4455 target: ".".to_owned(),
4456 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4457 }),
4458 MotionTimelineStep::Track(MotionTrack {
4459 target: ".".to_owned(),
4460 source: transition(MotionProperty::Opacity, 1.0, 0.0, 100),
4461 }),
4462 ]);
4463 assert!(matches!(
4464 compile_timeline(&root),
4465 Err(MotionError::InvalidTimeline(_))
4466 ));
4467 }
4468
4469 #[test]
4470 fn script_motion_configs_are_strict_and_keyframes_sample_natively() {
4471 let mut engine = Engine::new();
4472 register_motion_api(&mut engine);
4473 let source = engine
4474 .eval::<MotionSource>(
4475 r#"motion_keyframes("opacity", [
4476 #{ offset: 0.0, value: 0.0 },
4477 #{ offset: 0.5, value: 1.0, easing: "ease_out" },
4478 #{ offset: 1.0, value: 0.25 }
4479 ], #{ duration_ms: 200, intent: "decorative" })"#,
4480 )
4481 .unwrap();
4482 assert!(
4483 (sample_source(&source, Duration::from_millis(100)).value - 1.0).abs() < f64::EPSILON
4484 );
4485 assert!(
4486 engine
4487 .eval::<MotionSource>(r#"motion_transition("opacity", 0.0, 1.0, #{ typo: 100 })"#,)
4488 .unwrap_err()
4489 .to_string()
4490 .contains("unknown motion option `typo`")
4491 );
4492 }
4493
4494 #[test]
4495 fn replay_key_restarts_a_settled_declaration_without_restarting_equal_renders() {
4496 let start = Instant::now();
4497 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4498 let source = transition(MotionProperty::Opacity, 0.0, 1.0, 100);
4499 let component = key_path();
4500 let key = runtime
4501 .start_with_replay(
4502 component.clone(),
4503 source.clone(),
4504 Some("first".to_owned()),
4505 start,
4506 )
4507 .unwrap();
4508 let _ = runtime.tick(start + Duration::from_millis(100));
4509 runtime
4510 .start_with_replay(
4511 component.clone(),
4512 source.clone(),
4513 Some("first".to_owned()),
4514 start + Duration::from_millis(120),
4515 )
4516 .unwrap();
4517 assert_eq!(
4518 runtime.sample(&key, start + Duration::from_millis(120)),
4519 Some(1.0)
4520 );
4521 runtime
4522 .start_with_replay(
4523 component,
4524 source,
4525 Some("second".to_owned()),
4526 start + Duration::from_millis(120),
4527 )
4528 .unwrap();
4529 let frame = runtime.tick(start + Duration::from_millis(170));
4530 assert!(frame.needs_frame);
4531 assert!((frame.values[&key] - 0.5).abs() < 0.01);
4532 }
4533
4534 #[test]
4535 fn script_preference_cannot_relax_a_stricter_host_policy() {
4536 let mut runtime = MotionRuntime::new(MotionPreference::Reduced);
4537 runtime.request_preference(MotionPreference::Normal);
4538 assert_eq!(runtime.preference(), MotionPreference::Reduced);
4539 runtime.set_preference(MotionPreference::None);
4540 runtime.request_preference(MotionPreference::Normal);
4541 assert_eq!(runtime.preference(), MotionPreference::None);
4542 }
4543
4544 #[test]
4545 fn timeline_handles_are_instance_bound_and_play_is_idempotent() {
4546 let now = Instant::now();
4547 let scope = ComponentInstancePath::root("UiNode", "root/card");
4548 let timeline = || {
4549 MotionTimeline::new(
4550 "intro",
4551 MotionTimelineStep::Track(MotionTrack {
4552 target: ".".to_owned(),
4553 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4554 }),
4555 )
4556 };
4557 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4558 let old = runtime
4559 .start_timeline(scope.clone(), timeline(), now)
4560 .unwrap();
4561 let _ = runtime.tick(now + Duration::from_millis(400));
4562 runtime
4563 .play_timeline(&old, now + Duration::from_millis(400))
4564 .unwrap();
4565 let key = MotionKey::for_node("root/card", MotionProperty::Opacity);
4566 assert!((runtime.snapshot(now + Duration::from_millis(400))[&key] - 0.4).abs() < 0.01);
4567 runtime.cancel_node_scope("root/card");
4568 let new = runtime.start_timeline(scope, timeline(), now).unwrap();
4569 assert_ne!(old, new);
4570 assert!(matches!(
4571 runtime.cancel_timeline(&old),
4572 Err(MotionError::StaleTimelineHandle(_))
4573 ));
4574 assert_eq!(
4575 runtime.timeline_state(&new),
4576 Some(MotionPlaybackState::Playing)
4577 );
4578 assert!(matches!(
4579 MotionRuntime::new(MotionPreference::Normal).cancel_timeline(&new),
4580 Err(MotionError::StaleTimelineHandle(_))
4581 ));
4582 }
4583
4584 #[test]
4585 fn compatible_generation_migration_preserves_progress_but_rekeys_authority() {
4586 let now = Instant::now();
4587 let owner = ComponentInstancePath::root("View", "root");
4588 let node = UiNode::text("animated")
4589 .with_key("card")
4590 .with_timeline(MotionTimeline::new(
4591 "intro",
4592 MotionTimelineStep::Track(MotionTrack {
4593 target: ".".to_owned(),
4594 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4595 }),
4596 ));
4597 let incarnations = BTreeMap::new();
4598 let reconcile = |runtime: &mut MotionRuntime, generation| {
4599 reconcile_node_motion_scoped_owned(
4600 &node,
4601 runtime,
4602 now,
4603 "root",
4604 MotionReconcileContext {
4605 domain: "root",
4606 root_component: &owner,
4607 root_incarnation: ComponentIncarnation::unscoped(),
4608 generation,
4609 incarnations: &incarnations,
4610 identities: None,
4611 },
4612 )
4613 .unwrap();
4614 };
4615 let first_generation = ScriptGeneration::initial();
4616 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4617 reconcile(&mut runtime, first_generation);
4618 let first = runtime
4619 .timeline_handle_for_owner(
4620 "root",
4621 &owner,
4622 ComponentIncarnation::unscoped(),
4623 first_generation,
4624 "intro",
4625 )
4626 .unwrap();
4627 let _ = runtime.tick(now + Duration::from_millis(400));
4628 reconcile(&mut runtime, first_generation);
4629 assert_eq!(
4630 runtime
4631 .timeline_handle_for_owner(
4632 "root",
4633 &owner,
4634 ComponentIncarnation::unscoped(),
4635 first_generation,
4636 "intro",
4637 )
4638 .unwrap(),
4639 first
4640 );
4641
4642 let next_generation = first_generation.next();
4643 reconcile(&mut runtime, next_generation);
4644 let migrated = runtime
4645 .timeline_handle_for_owner(
4646 "root",
4647 &owner,
4648 ComponentIncarnation::unscoped(),
4649 next_generation,
4650 "intro",
4651 )
4652 .unwrap();
4653 assert_ne!(first, migrated);
4654 assert!(matches!(
4655 runtime.cancel_timeline(&first),
4656 Err(MotionError::StaleTimelineHandle(_))
4657 ));
4658 assert!(
4659 (runtime.snapshot(now + Duration::from_millis(400))
4660 [&MotionKey::for_node("root", MotionProperty::Opacity)]
4661 - 0.4)
4662 .abs()
4663 < 0.01
4664 );
4665 }
4666
4667 #[test]
4668 fn none_policy_cannot_be_bypassed_by_restart() {
4669 let now = Instant::now();
4670 let mut runtime = MotionRuntime::new(MotionPreference::None);
4671 let handle = runtime
4672 .start_timeline(
4673 ComponentInstancePath::root("UiNode", "root/card"),
4674 MotionTimeline::new(
4675 "intro",
4676 MotionTimelineStep::Track(MotionTrack {
4677 target: ".".to_owned(),
4678 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
4679 }),
4680 ),
4681 now,
4682 )
4683 .unwrap();
4684 runtime.restart_timeline(&handle, now).unwrap();
4685 let frame = runtime.tick(now + Duration::from_millis(250));
4686 assert!(!frame.needs_frame);
4687 assert_eq!(
4688 runtime.timeline_state(&handle),
4689 Some(MotionPlaybackState::Completed)
4690 );
4691 assert!(
4692 (runtime.snapshot(now)[&MotionKey::for_node("root/card", MotionProperty::Opacity)]
4693 - 1.0)
4694 .abs()
4695 < f64::EPSILON
4696 );
4697
4698 let mut deferred = MotionTimeline::new(
4699 "deferred",
4700 MotionTimelineStep::Track(MotionTrack {
4701 target: ".".to_owned(),
4702 source: transition(MotionProperty::TranslateX, 0.0, 10.0, 1_000),
4703 }),
4704 );
4705 deferred.autoplay = false;
4706 let mut switched = MotionRuntime::new(MotionPreference::Normal);
4707 let handle = switched
4708 .start_timeline(
4709 ComponentInstancePath::root("UiNode", "root/deferred"),
4710 deferred,
4711 now,
4712 )
4713 .unwrap();
4714 switched.set_preference(MotionPreference::None);
4715 assert_eq!(
4716 switched.timeline_state(&handle),
4717 Some(MotionPlaybackState::Completed)
4718 );
4719 assert!(
4720 (switched.snapshot(now)
4721 [&MotionKey::for_node("root/deferred", MotionProperty::TranslateX,)]
4722 - 10.0)
4723 .abs()
4724 < f64::EPSILON
4725 );
4726 }
4727
4728 #[test]
4729 fn none_policy_reprojects_settled_and_outer_autoreverse_terminals() {
4730 let now = Instant::now();
4731 let mut reversed = MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, 100);
4732 reversed.iterations = Some(2);
4733 reversed.autoreverse = true;
4734 let mut reprojected = MotionRuntime::new(MotionPreference::Reduced);
4735 let key = reprojected
4736 .start(
4737 ComponentInstancePath::root("UiNode", "root/reprojected"),
4738 MotionSource::Transition(reversed),
4739 now,
4740 )
4741 .unwrap();
4742 assert!((reprojected.snapshot(now)[&key] - 1.0).abs() < f64::EPSILON);
4743 reprojected.set_preference(MotionPreference::None);
4744 assert!(reprojected.snapshot(now)[&key].abs() < f64::EPSILON);
4745
4746 let mut outer = MotionTimeline::new(
4747 "outer-reverse",
4748 MotionTimelineStep::Track(MotionTrack {
4749 target: ".".to_owned(),
4750 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4751 }),
4752 );
4753 outer.iterations = Some(2);
4754 outer.autoreverse = true;
4755 let mut reduced_timeline = MotionRuntime::new(MotionPreference::Reduced);
4756 reduced_timeline
4757 .start_timeline(
4758 ComponentInstancePath::root("UiNode", "root/outer"),
4759 outer.clone(),
4760 now,
4761 )
4762 .unwrap();
4763 let initial_events = reduced_timeline.drain_timeline_events().len();
4764 reduced_timeline.set_preference(MotionPreference::None);
4765 assert!(
4766 reduced_timeline.snapshot(now)
4767 [&MotionKey::for_node("root/outer", MotionProperty::Opacity)]
4768 .abs()
4769 < f64::EPSILON
4770 );
4771 assert_eq!(reduced_timeline.drain_timeline_events().len(), 0);
4772 assert_eq!(initial_events, 1);
4773 let mut static_timeline = MotionRuntime::new(MotionPreference::None);
4774 static_timeline
4775 .start_timeline(
4776 ComponentInstancePath::root("UiNode", "root/outer"),
4777 outer,
4778 now,
4779 )
4780 .unwrap();
4781 assert!(
4782 static_timeline.snapshot(now)
4783 [&MotionKey::for_node("root/outer", MotionProperty::Opacity)]
4784 .abs()
4785 < f64::EPSILON
4786 );
4787 }
4788
4789 #[test]
4790 fn direct_and_timeline_physics_share_one_sampler_and_extremes_fail() {
4791 let now = Instant::now();
4792 let scope = ComponentInstancePath::root("UiNode", "root/card");
4793 let spring = MotionSpring::new(MotionProperty::TranslateX, 0.0, 1.0);
4794 let mut direct = MotionRuntime::new(MotionPreference::Normal);
4795 let key = direct
4796 .start(scope.clone(), MotionSource::Spring(spring.clone()), now)
4797 .unwrap();
4798 let direct_value = direct.tick(now + Duration::from_secs(1)).values[&key];
4799 let mut timeline = MotionRuntime::new(MotionPreference::Normal);
4800 timeline
4801 .start_timeline(
4802 scope.clone(),
4803 MotionTimeline::new(
4804 "spring",
4805 MotionTimelineStep::Track(MotionTrack {
4806 target: ".".to_owned(),
4807 source: MotionSource::Spring(spring.clone()),
4808 }),
4809 ),
4810 now,
4811 )
4812 .unwrap();
4813 let timeline_value = timeline.tick(now + Duration::from_secs(1)).values[&key];
4814 assert!((direct_value - timeline_value).abs() < 1.0e-9);
4815
4816 let mut retargeted = MotionRuntime::new(MotionPreference::Normal);
4817 retargeted
4818 .start(scope.clone(), MotionSource::Spring(spring.clone()), now)
4819 .unwrap();
4820 let retarget_at = now + Duration::from_millis(16);
4821 let before = retargeted.inspect(retarget_at)[0].velocity.unwrap();
4822 let mut next = spring.clone();
4823 next.to = 2.0;
4824 retargeted
4825 .start(scope.clone(), MotionSource::Spring(next), retarget_at)
4826 .unwrap();
4827 let inherited = retargeted.inspect(retarget_at)[0].velocity.unwrap();
4828 assert!((before - inherited).abs() < 1.0e-9);
4829
4830 let mut extreme = spring;
4831 extreme.stiffness = 1.0e308;
4832 extreme.mass = 1.0e-308;
4833 assert_eq!(
4834 direct.start(scope, MotionSource::Spring(extreme), now),
4835 Err(MotionError::InvalidSpec)
4836 );
4837 }
4838
4839 #[test]
4840 fn unconstrained_inertia_uses_the_closed_form_at_any_animation_age() {
4841 let spec = MotionInertia {
4842 property: MotionProperty::TranslateX,
4843 from: 10.0,
4844 velocity: 1_000.0,
4845 friction: 0.5,
4846 min: None,
4847 max: None,
4848 bounce: 0.0,
4849 snap_points: Vec::new(),
4850 intent: MotionIntent::Decorative,
4851 };
4852 for seconds in [1.0, 5.0, 8.0] {
4853 let elapsed = Duration::from_secs_f64(seconds);
4854 let (position, velocity) = sample_inertia_at(&spec, elapsed);
4855 let decay = (-spec.friction * seconds).exp();
4856 let expected_position = spec.from + spec.velocity * (1.0 - decay) / spec.friction;
4857 let expected_velocity = spec.velocity * decay;
4858 assert!((position - expected_position).abs() < 1.0e-9);
4859 assert!((velocity - expected_velocity).abs() < 1.0e-9);
4860 }
4861 let before_cap = sample_inertia_at(&spec, Duration::from_millis(9_999)).0;
4862 let at_cap = sample_inertia_at(&spec, Duration::from_secs(10)).0;
4863 let after_cap = sample_inertia_at(&spec, Duration::from_secs(11)).0;
4864 assert!((at_cap - before_cap).abs() < 0.1);
4865 assert!((after_cap - at_cap).abs() < f64::EPSILON);
4866 }
4867
4868 #[test]
4869 fn bouncing_inertia_is_bounded_and_matches_timeline_sampling() {
4870 let now = Instant::now();
4871 let scope = ComponentInstancePath::root("UiNode", "root/inertia");
4872 let spec = MotionInertia {
4873 property: MotionProperty::TranslateX,
4874 from: 50.0,
4875 velocity: 500.0,
4876 friction: 1.0,
4877 min: Some(0.0),
4878 max: Some(100.0),
4879 bounce: 0.5,
4880 snap_points: vec![0.0, 100.0],
4881 intent: MotionIntent::Feedback,
4882 };
4883 let mut direct = MotionRuntime::new(MotionPreference::Normal);
4884 let key = direct
4885 .start(scope.clone(), MotionSource::Inertia(spec.clone()), now)
4886 .unwrap();
4887 let mut timeline = MotionRuntime::new(MotionPreference::Normal);
4888 timeline
4889 .start_timeline(
4890 scope,
4891 MotionTimeline::new(
4892 "inertia",
4893 MotionTimelineStep::Track(MotionTrack {
4894 target: ".".to_owned(),
4895 source: MotionSource::Inertia(spec),
4896 }),
4897 ),
4898 now,
4899 )
4900 .unwrap();
4901 for elapsed in [100, 500, 1_000, 5_000] {
4902 let at = now + Duration::from_millis(elapsed);
4903 let direct_value = direct.snapshot(at)[&key];
4904 let timeline_value = timeline.snapshot(at)[&key];
4905 assert!((0.0..=100.0).contains(&direct_value));
4906 assert!((direct_value - timeline_value).abs() < 1.0e-9);
4907 }
4908 }
4909
4910 #[test]
4911 fn timeline_snapshot_resets_future_sequence_tracks_when_seeking_back() {
4912 let now = Instant::now();
4913 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4914 let handle = runtime
4915 .start_timeline(
4916 ComponentInstancePath::root("UiNode", "root/card"),
4917 MotionTimeline::new(
4918 "sequence",
4919 MotionTimelineStep::Sequence(vec![
4920 MotionTimelineStep::Track(MotionTrack {
4921 target: ".".to_owned(),
4922 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
4923 }),
4924 MotionTimelineStep::Track(MotionTrack {
4925 target: ".".to_owned(),
4926 source: transition(MotionProperty::TranslateX, 0.0, 100.0, 100),
4927 }),
4928 ]),
4929 ),
4930 now,
4931 )
4932 .unwrap();
4933 let _ = runtime.tick(now + Duration::from_millis(150));
4934 runtime
4935 .seek_timeline(&handle, 0, now + Duration::from_millis(150))
4936 .unwrap();
4937 assert!(
4938 runtime.snapshot(now)[&MotionKey::for_node("root/card", MotionProperty::TranslateX)]
4939 .abs()
4940 < f64::EPSILON
4941 );
4942 }
4943
4944 #[test]
4945 fn terminal_events_are_partitioned_and_discarded_by_presentation_scope() {
4946 let now = Instant::now();
4947 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4948 for domain in ["window:a/view:a/root", "window:b/view:b/root"] {
4949 runtime
4950 .start_timeline(
4951 ComponentInstancePath::root("UiNode", domain),
4952 MotionTimeline::new(
4953 "done",
4954 MotionTimelineStep::Track(MotionTrack {
4955 target: ".".to_owned(),
4956 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1),
4957 }),
4958 ),
4959 now,
4960 )
4961 .unwrap();
4962 }
4963 let _ = runtime.tick(now + Duration::from_millis(1));
4964 assert_eq!(
4965 runtime
4966 .drain_timeline_events_for_domain("window:a/view:a/root")
4967 .len(),
4968 1
4969 );
4970 runtime.discard_timeline_events_in_scope("window:b");
4971 assert!(runtime.drain_timeline_events().is_empty());
4972 }
4973
4974 #[test]
4975 fn autoreverse_completion_keeps_the_sampled_terminal_value() {
4976 let now = Instant::now();
4977 let mut spec = MotionTransition::new(MotionProperty::Opacity, 0.0, 1.0, 100);
4978 spec.easing = MotionEasing::Linear;
4979 spec.iterations = Some(2);
4980 spec.autoreverse = true;
4981 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
4982 let key = runtime
4983 .start(key_path(), MotionSource::Transition(spec), now)
4984 .unwrap();
4985 let frame = runtime.tick(now + Duration::from_millis(200));
4986 assert!(frame.values[&key].abs() < f64::EPSILON);
4987 assert!(runtime.snapshot(now)[&key].abs() < f64::EPSILON);
4988 }
4989
4990 #[test]
4991 fn reconciliation_validates_targets_ownership_and_is_atomic() {
4992 let now = Instant::now();
4993 let conflict = UiNode::text("conflict")
4994 .with_key("conflict")
4995 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100))
4996 .with_timeline(MotionTimeline::new(
4997 "conflict",
4998 MotionTimelineStep::Track(MotionTrack {
4999 target: ".".to_owned(),
5000 source: transition(MotionProperty::Opacity, 1.0, 0.0, 100),
5001 }),
5002 ));
5003 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5004 assert!(matches!(
5005 reconcile_node_motion(&conflict, &mut runtime, now),
5006 Err(MotionError::PropertyOwnerConflict { .. })
5007 ));
5008 assert_eq!(runtime.resource_usage().active, 0);
5009
5010 let missing = UiNode::text("missing")
5011 .with_key("root")
5012 .with_timeline(MotionTimeline::new(
5013 "missing",
5014 MotionTimelineStep::Track(MotionTrack {
5015 target: "child".to_owned(),
5016 source: transition(MotionProperty::Rotate, 0.0, 90.0, 100),
5017 }),
5018 ));
5019 assert!(matches!(
5020 reconcile_node_motion(&missing, &mut runtime, now),
5021 Err(MotionError::UnknownTimelineTarget { .. })
5022 ));
5023 assert_eq!(runtime.resource_usage().active, 0);
5024
5025 runtime.set_active_limit(1);
5026 reconcile_node_motion(
5027 &UiNode::text("old").with_key("node").with_motion(transition(
5028 MotionProperty::Opacity,
5029 0.0,
5030 1.0,
5031 100,
5032 )),
5033 &mut runtime,
5034 now,
5035 )
5036 .unwrap();
5037 reconcile_node_motion(
5038 &UiNode::text("new").with_key("node").with_motion(transition(
5039 MotionProperty::TranslateX,
5040 0.0,
5041 1.0,
5042 100,
5043 )),
5044 &mut runtime,
5045 now,
5046 )
5047 .unwrap();
5048 assert_eq!(runtime.resource_usage().active, 1);
5049 }
5050
5051 #[test]
5052 fn encoded_paths_do_not_alias_legal_slash_keys_and_root_remounts_restart() {
5053 let now = Instant::now();
5054 let nested = UiNode::column(vec![
5055 UiNode::text("flat").with_key("a/b").with_motion(transition(
5056 MotionProperty::Opacity,
5057 0.0,
5058 1.0,
5059 100,
5060 )),
5061 UiNode::column(vec![
5062 UiNode::text("nested").with_key("b").with_motion(transition(
5063 MotionProperty::Opacity,
5064 0.0,
5065 1.0,
5066 100,
5067 )),
5068 ])
5069 .with_key("a"),
5070 UiNode::text("numeric")
5071 .with_key("0")
5072 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5073 UiNode::text("reserved-looking")
5074 .with_key("item:alpha")
5075 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5076 ]);
5077 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5078 let values = reconcile_node_motion(&nested, &mut runtime, now).unwrap();
5079 assert_eq!(values.len(), 4);
5080
5081 let direct = UiNode::column(vec![
5082 UiNode::text("Title")
5083 .with_key("title")
5084 .with_motion(transition(MotionProperty::Opacity, 0.0, 1.0, 100)),
5085 ])
5086 .with_key("panel");
5087 let timeline_target = UiNode::column(vec![UiNode::text("Title").with_key("title")])
5088 .with_key("panel")
5089 .with_timeline(MotionTimeline::new(
5090 "child",
5091 MotionTimelineStep::Track(MotionTrack {
5092 target: "title".to_owned(),
5093 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5094 }),
5095 ));
5096 let direct_keys = reconcile_node_motion(
5097 &direct,
5098 &mut MotionRuntime::new(MotionPreference::Normal),
5099 now,
5100 )
5101 .unwrap()
5102 .into_keys()
5103 .collect::<Vec<_>>();
5104 let timeline_keys = reconcile_node_motion(
5105 &timeline_target,
5106 &mut MotionRuntime::new(MotionPreference::Normal),
5107 now,
5108 )
5109 .unwrap()
5110 .into_keys()
5111 .collect::<Vec<_>>();
5112 assert_eq!(direct_keys, timeline_keys);
5113
5114 let timeline = || {
5115 MotionTimeline::new(
5116 "intro",
5117 MotionTimelineStep::Track(MotionTrack {
5118 target: ".".to_owned(),
5119 source: transition(MotionProperty::Opacity, 0.0, 1.0, 1_000),
5120 }),
5121 )
5122 };
5123 let first = UiNode::text("first")
5124 .with_key("one")
5125 .with_timeline(timeline());
5126 reconcile_node_motion(&first, &mut runtime, now).unwrap();
5127 let old = runtime.inspect_timelines(now)[0].handle.clone();
5128 let second = UiNode::text("second")
5129 .with_key("two")
5130 .with_timeline(timeline());
5131 reconcile_node_motion(&second, &mut runtime, now + Duration::from_millis(500)).unwrap();
5132 let current = runtime.inspect_timelines(now)[0].handle.clone();
5133 assert_ne!(old, current);
5134 assert!(matches!(
5135 runtime.cancel_timeline(&old),
5136 Err(MotionError::StaleTimelineHandle(_))
5137 ));
5138 assert!(
5139 runtime
5140 .snapshot(now + Duration::from_millis(500))
5141 .values()
5142 .next()
5143 .copied()
5144 .unwrap()
5145 .abs()
5146 < f64::EPSILON
5147 );
5148 }
5149
5150 #[test]
5151 fn retained_timeline_targets_resolve_to_the_target_node_identity() {
5152 let now = Instant::now();
5153 let timeline = || {
5154 MotionTimeline::new(
5155 "intro",
5156 MotionTimelineStep::Track(MotionTrack {
5157 target: "title".to_owned(),
5158 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5159 }),
5160 )
5161 };
5162 let root = UiNode::column(vec![UiNode::text("Title").with_key("title")])
5163 .with_key("panel")
5164 .with_timeline(timeline());
5165 let mut retained = crate::RetainedUiTree::new();
5166 retained.reconcile(root.clone()).unwrap();
5167 let title = retained
5168 .nodes()
5169 .find(|node| node.key() == Some("title"))
5170 .unwrap()
5171 .id();
5172 let identities = retained_motion_identities(&root, &retained, "root").unwrap();
5173 let component = ComponentInstancePath::root("View", "retained-target");
5174 let incarnations = BTreeMap::new();
5175 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5176 let values = reconcile_node_motion_scoped_owned(
5177 &root,
5178 &mut runtime,
5179 now,
5180 "root",
5181 MotionReconcileContext {
5182 domain: "root",
5183 root_component: &component,
5184 root_incarnation: ComponentIncarnation::unscoped(),
5185 generation: ScriptGeneration::initial(),
5186 incarnations: &incarnations,
5187 identities: Some(&identities),
5188 },
5189 )
5190 .unwrap();
5191 assert!(values.contains_key(&MotionKey::for_node(
5192 &retained_node_path("root", title),
5193 MotionProperty::Opacity,
5194 )));
5195
5196 let replacement = UiNode::column(vec![UiNode::box_node(Vec::new()).with_key("title")])
5197 .with_key("panel")
5198 .with_timeline(timeline());
5199 retained.reconcile(replacement.clone()).unwrap();
5200 let replacement_title = retained
5201 .nodes()
5202 .find(|node| node.key() == Some("title"))
5203 .unwrap()
5204 .id();
5205 assert_ne!(title, replacement_title);
5206 let replacement_identities =
5207 retained_motion_identities(&replacement, &retained, "root").unwrap();
5208 let values = reconcile_node_motion_scoped_owned(
5209 &replacement,
5210 &mut runtime,
5211 now + Duration::from_millis(50),
5212 "root",
5213 MotionReconcileContext {
5214 domain: "root",
5215 root_component: &component,
5216 root_incarnation: ComponentIncarnation::unscoped(),
5217 generation: ScriptGeneration::initial(),
5218 incarnations: &incarnations,
5219 identities: Some(&replacement_identities),
5220 },
5221 )
5222 .unwrap();
5223 assert!(!values.contains_key(&MotionKey::for_node(
5224 &retained_node_path("root", title),
5225 MotionProperty::Opacity,
5226 )));
5227 assert!(
5228 (values[&MotionKey::for_node(
5229 &retained_node_path("root", replacement_title),
5230 MotionProperty::Opacity,
5231 )] - 0.5)
5232 .abs()
5233 < 0.01
5234 );
5235 }
5236
5237 #[test]
5238 fn exit_ghost_rejects_an_unsupported_nested_subtree_atomically() {
5239 let now = Instant::now();
5240 let root = UiNode::box_node(vec![UiNode::error_boundary(
5241 UiNode::text("content"),
5242 UiNode::text("fallback"),
5243 )])
5244 .with_key("panel")
5245 .with_exit_motion(transition(MotionProperty::Opacity, 1.0, 0.0, 100));
5246 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5247 assert!(matches!(
5248 reconcile_node_motion(&root, &mut runtime, now),
5249 Err(MotionError::UnsupportedExitGhost(_))
5250 ));
5251 assert_eq!(runtime.resource_usage().active, 0);
5252 }
5253
5254 #[test]
5255 fn affine_canvas_motion_rejects_axis_aligned_path_clips() {
5256 let scene = crate::CanvasScene::new(vec![crate::CanvasCommand::Path {
5257 key: "clipped".to_owned(),
5258 segments: vec![
5259 crate::CanvasPathSegment::Move { x: 0.0, y: 0.0 },
5260 crate::CanvasPathSegment::Line { x: 20.0, y: 20.0 },
5261 ],
5262 fill: None,
5263 stroke: Some((
5264 crate::ColorValue::Literal(crate::Rgba8::from_rgb_hex(0x00ff_ffff)),
5265 1.0,
5266 )),
5267 transform: crate::CanvasTransform::default(),
5268 clip: Some(crate::CanvasClipRect {
5269 x: 0.0,
5270 y: 0.0,
5271 width: 10.0,
5272 height: 10.0,
5273 }),
5274 }])
5275 .unwrap();
5276 let node = UiNode::canvas(scene)
5277 .with_key("canvas")
5278 .with_motion(transition(MotionProperty::Rotate, 0.0, 45.0, 100));
5279 assert!(matches!(
5280 reconcile_node_motion(
5281 &node,
5282 &mut MotionRuntime::new(MotionPreference::Normal),
5283 Instant::now(),
5284 ),
5285 Err(MotionError::UnsupportedProperty {
5286 node: "canvas_with_clipped_path",
5287 ..
5288 })
5289 ));
5290 }
5291
5292 #[test]
5293 fn play_and_restart_recheck_active_budget() {
5294 let now = Instant::now();
5295 let mut timeline = MotionTimeline::new(
5296 "budget",
5297 MotionTimelineStep::Parallel(vec![
5298 MotionTimelineStep::Track(MotionTrack {
5299 target: ".".to_owned(),
5300 source: transition(MotionProperty::Opacity, 0.0, 1.0, 100),
5301 }),
5302 MotionTimelineStep::Track(MotionTrack {
5303 target: ".".to_owned(),
5304 source: transition(MotionProperty::TranslateX, 0.0, 1.0, 100),
5305 }),
5306 ]),
5307 );
5308 timeline.autoplay = false;
5309 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5310 runtime.set_active_limit(1);
5311 let handle = runtime
5312 .start_timeline(
5313 ComponentInstancePath::root("UiNode", "root/card"),
5314 timeline,
5315 now,
5316 )
5317 .unwrap();
5318 assert!(matches!(
5319 runtime.play_timeline(&handle, now),
5320 Err(MotionError::ActiveBudget {
5321 actual: 2,
5322 limit: 1
5323 })
5324 ));
5325 assert_eq!(
5326 runtime.timeline_state(&handle),
5327 Some(MotionPlaybackState::Idle)
5328 );
5329 }
5330
5331 #[test]
5332 fn timeline_budget_is_independent_of_declaration_order() {
5333 let now = Instant::now();
5334 let timeline = |name: &str, property: MotionProperty, autoplay: bool| {
5335 let mut timeline = MotionTimeline::new(
5336 name,
5337 MotionTimelineStep::Track(MotionTrack {
5338 target: ".".to_owned(),
5339 source: transition(property, 0.0, 1.0, 100),
5340 }),
5341 );
5342 timeline.autoplay = autoplay;
5343 timeline
5344 };
5345 for reversed in [false, true] {
5346 let mut runtime = MotionRuntime::new(MotionPreference::Normal);
5347 runtime.set_active_limit(1);
5348 let initial = UiNode::text("x")
5349 .with_key("x")
5350 .with_timeline(timeline("a", MotionProperty::Opacity, true))
5351 .with_timeline(timeline("b", MotionProperty::Width, false));
5352 reconcile_node_motion(&initial, &mut runtime, now).unwrap();
5353 let first = timeline("a", MotionProperty::Opacity, false);
5354 let second = timeline("b", MotionProperty::Width, true);
5355 let replacement = if reversed {
5356 UiNode::text("x")
5357 .with_key("x")
5358 .with_timeline(second)
5359 .with_timeline(first)
5360 } else {
5361 UiNode::text("x")
5362 .with_key("x")
5363 .with_timeline(first)
5364 .with_timeline(second)
5365 };
5366 reconcile_node_motion(&replacement, &mut runtime, now).unwrap();
5367 assert_eq!(runtime.resource_usage().active, 1);
5368 }
5369 }
5370}