Skip to main content

gpui_rhai/
motion.rs

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