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