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