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