Skip to main content

pax_runtime/
settings_motion.rs

1//! Receiver-owned motion for effective imported settings values.
2
3use std::{cell::RefCell, rc::Rc};
4
5use pax_manifest::ValueDefinition;
6use pax_runtime_api::{
7    properties::{current_frame, register_effect_property, PropertyValue},
8    CoercionRules, Duration, EasingCurve, Interpolatable, LocalProperty, PaxValue, ToPaxValue,
9};
10
11use crate::{
12    ExpandedNode, RuntimePropertiesStackFrame, RuntimeResolvedPropertyEntry, RuntimeSettingsSource,
13};
14
15/// Runtime projection of an ImportSettings transition; no playback state is shared.
16#[derive(Clone, Debug, PartialEq)]
17pub struct SettingsTransitionConfig {
18    pub duration: Duration,
19    pub curve: &'static str,
20}
21impl Default for SettingsTransitionConfig {
22    fn default() -> Self {
23        Self {
24            duration: Duration::default(),
25            curve: "Linear",
26        }
27    }
28}
29impl Interpolatable for SettingsTransitionConfig {}
30
31#[derive(Clone, Default)]
32struct Owner {
33    imported: bool,
34    explicit: bool,
35    config: Option<SettingsTransitionConfig>,
36}
37impl Interpolatable for Owner {}
38
39fn owner_property(
40    entries: &[RuntimeResolvedPropertyEntry],
41    stack: &Rc<RuntimePropertiesStackFrame>,
42) -> LocalProperty<Owner> {
43    let mut dependencies = Vec::new();
44    for entry in entries {
45        if let Some(policy) = &entry.transition {
46            dependencies.push(policy.untyped());
47        }
48        if let Some(condition) = &entry.condition {
49            crate::cartridge::collect_settings_condition_dependencies(
50                condition,
51                entry.source_stack.as_ref().unwrap_or(stack),
52                &mut dependencies,
53            );
54        }
55    }
56    let entries = entries.to_vec();
57    let stack = stack.clone();
58    LocalProperty::computed_with_name(
59        move || {
60            entries
61                .iter()
62                .rev()
63                .find(|entry| {
64                    entry.condition.as_ref().map_or(true, |condition| {
65                        crate::cartridge::settings_condition_is_active(
66                            condition,
67                            entry.source_stack.as_ref().unwrap_or(&stack),
68                        )
69                    })
70                })
71                .map(|entry| Owner {
72                    imported: matches!(entry.source, RuntimeSettingsSource::ImportedLayer { .. }),
73                    explicit: matches!(entry.source, RuntimeSettingsSource::Inline)
74                        || matches!(
75                            entry.value,
76                            ValueDefinition::Timeline(_)
77                                | ValueDefinition::Transition(_)
78                                | ValueDefinition::DoubleBinding(_)
79                        ),
80                    config: entry.transition.as_ref().and_then(LocalProperty::get),
81                })
82                .unwrap_or_default()
83        },
84        &dependencies,
85        "settings transition owner",
86    )
87}
88
89struct Endpoints<T> {
90    from: T,
91    to: T,
92    curve: &'static str,
93}
94
95struct SettingsMotion<T: PropertyValue> {
96    target: LocalProperty<T>,
97    owner: LocalProperty<Owner>,
98    progress: LocalProperty<f64>,
99    _listener: LocalProperty<()>,
100}
101
102impl<T: PropertyValue> Drop for SettingsMotion<T> {
103    fn drop(&mut self) {
104        self.progress.cancel_transitions();
105    }
106}
107
108fn frozen<T: PropertyValue + CoercionRules + ToPaxValue>(
109    value: T,
110) -> Result<(T, PaxValue), String> {
111    // PaxValue conversion snapshots Property-wrapped leaves. Cloning T alone
112    // would retain live provider handles in a transition's endpoints.
113    let snapshot = value.to_pax_value_in_graph();
114    T::try_coerce(snapshot.clone()).map(|value| (value, snapshot))
115}
116
117impl<T: PropertyValue + CoercionRules + ToPaxValue> SettingsMotion<T> {
118    fn new(
119        output: &LocalProperty<T>,
120        target: LocalProperty<T>,
121        owner: LocalProperty<Owner>,
122        animate_initial_change: bool,
123        interpolate: Rc<dyn Fn(&T, &T, f64) -> T>,
124    ) -> Option<Self> {
125        let (initial, initial_snapshot) = frozen(output.get()).ok()?;
126        let progress = LocalProperty::new_with_name(1.0, "settings transition progress");
127        let revision = LocalProperty::new(0_u64);
128        let endpoints = Rc::new(RefCell::new(Endpoints {
129            from: initial.clone(),
130            to: initial,
131            curve: "Linear",
132        }));
133        let animation = {
134            let progress = progress.clone();
135            let endpoints = endpoints.clone();
136            let dependencies = [progress.untyped(), revision.untyped()];
137            LocalProperty::computed_with_name(
138                move || {
139                    let t = progress.get();
140                    let endpoints = endpoints.borrow();
141                    if t >= 1.0 {
142                        endpoints.to.clone()
143                    } else {
144                        let multiplier =
145                            crate::cartridge::easing_curve_from_name(Some(endpoints.curve))
146                                .interpolate(&0.0, &1.0, t);
147                        interpolate(&endpoints.from, &endpoints.to, multiplier)
148                    }
149                },
150                &dependencies,
151                "animated settings value",
152            )
153        };
154        output.cancel_transitions();
155        output.replace_with(animation);
156        let listener = {
157            let target = target.clone();
158            let owner = owner.clone();
159            let output = output.clone();
160            let progress = progress.clone();
161            let previous =
162                RefCell::new((initial_snapshot, Owner::default(), !animate_initial_change));
163            let dependencies = [target.untyped(), owner.untyped()];
164            LocalProperty::computed_with_name(
165                move || {
166                    let new_owner = owner.get();
167                    let next = target.get();
168                    let snapshot = next.clone().to_pax_value_in_graph();
169                    let (next, can_interpolate) = match T::try_coerce(snapshot.clone()) {
170                        Ok(frozen) => (frozen, true),
171                        // Custom types without a lossless value roundtrip still follow
172                        // their target, but cannot retain a safe interpolation endpoint.
173                        Err(_) => (next, false),
174                    };
175                    let mut previous = previous.borrow_mut();
176                    let config = if new_owner.explicit {
177                        None
178                    } else if new_owner.imported {
179                        new_owner.config.clone()
180                    } else if previous.1.imported {
181                        previous.1.config.clone()
182                    } else {
183                        None
184                    };
185                    let changed = snapshot != previous.0;
186                    let disabled = new_owner.explicit
187                        || (new_owner.imported
188                            && new_owner.config.as_ref().map_or(true, |config| {
189                                let duration = config.duration.raw_value();
190                                !duration.is_finite() || duration <= 0.0
191                            }));
192                    if !changed && !disabled && !previous.2 {
193                        previous.1 = new_owner;
194                        return;
195                    }
196                    // Sample before cancelling the old clock or touching its endpoints.
197                    let current = frozen(output.get())
198                        .map(|(value, _)| value)
199                        .unwrap_or_else(|_| next.clone());
200                    progress.cancel_transitions();
201                    let config = config.filter(|config| {
202                        let duration = match config.duration {
203                            Duration::Frames(v)
204                            | Duration::Milliseconds(v)
205                            | Duration::Seconds(v) => v.to_float(),
206                        };
207                        can_interpolate && duration.is_finite() && duration > 0.0
208                    });
209                    *endpoints.borrow_mut() = Endpoints {
210                        from: current,
211                        to: next,
212                        curve: config.as_ref().map_or("Linear", |config| config.curve),
213                    };
214                    if changed && !previous.2 {
215                        if let Some(config) = config {
216                            progress.set(0.0);
217                            progress.ease_to(1.0, config.duration, EasingCurve::Linear);
218                        } else {
219                            progress.set(1.0);
220                        }
221                    } else {
222                        progress.set(1.0);
223                    }
224                    revision.set(revision.get().wrapping_add(1));
225                    *previous = (snapshot, new_owner, false);
226                },
227                &dependencies,
228                "settings target listener",
229            )
230        };
231        register_effect_property(&listener);
232        // Establish the baseline before the first render, even for an unobserved value.
233        listener.get();
234        Some(Self {
235            target,
236            owner,
237            progress,
238            _listener: listener,
239        })
240    }
241}
242
243/// Applies a resolved target while retaining a receiver's active settings motion.
244/// Generated cartridges call this after resolving all precedence layers.
245#[doc(hidden)]
246pub fn bind_settings_property<T: PropertyValue + CoercionRules + ToPaxValue>(
247    output: &LocalProperty<T>,
248    target: LocalProperty<T>,
249    name: &str,
250    entries: &[RuntimeResolvedPropertyEntry],
251    stack: &Rc<RuntimePropertiesStackFrame>,
252    node: Option<&ExpandedNode>,
253) {
254    bind_settings_property_with(output, target, name, entries, stack, node, T::interpolate);
255}
256
257pub(crate) fn bind_settings_property_with<T: PropertyValue + CoercionRules + ToPaxValue>(
258    output: &LocalProperty<T>,
259    target: LocalProperty<T>,
260    name: &str,
261    entries: &[RuntimeResolvedPropertyEntry],
262    stack: &Rc<RuntimePropertiesStackFrame>,
263    node: Option<&ExpandedNode>,
264    interpolate: impl Fn(&T, &T, f64) -> T + 'static,
265) {
266    let Some(node) = node else {
267        output.replace_with(target);
268        return;
269    };
270    let mut motions = node.settings_motion.borrow_mut();
271    // Unconditional explicit ownership cannot be affected by an imported layer.
272    let explicit = entries.last().map_or(false, |entry| {
273        entry.condition.is_none()
274            && (matches!(entry.source, RuntimeSettingsSource::Inline)
275                || matches!(
276                    entry.value,
277                    ValueDefinition::Timeline(_)
278                        | ValueDefinition::Transition(_)
279                        | ValueDefinition::DoubleBinding(_)
280                ))
281    });
282    if explicit {
283        motions.remove(name);
284        output.replace_with(target);
285        return;
286    }
287    if let Some(motion) = motions
288        .get_mut(name)
289        .and_then(|motion| motion.downcast_mut::<SettingsMotion<T>>())
290    {
291        motion.target.replace_with(target);
292        motion.owner.replace_with(owner_property(entries, stack));
293        return;
294    }
295    let enabled = entries.iter().any(|entry| {
296        entry
297            .transition
298            .as_ref()
299            .and_then(LocalProperty::get)
300            .is_some()
301    });
302    if !enabled {
303        output.replace_with(target);
304        return;
305    }
306    let owner = owner_property(entries, stack);
307    if let Some(motion) = SettingsMotion::new(
308        output,
309        target.clone(),
310        owner,
311        current_frame() != node.settings_birth_frame,
312        Rc::new(interpolate),
313    ) {
314        motions.insert(name.to_owned(), Box::new(motion));
315    } else {
316        output.replace_with(target);
317    }
318}
319
320/// Releases automatic motion before a two-way binding takes direct ownership.
321#[doc(hidden)]
322pub fn clear_settings_motion(node: Option<&ExpandedNode>, name: &str) {
323    if let Some(node) = node {
324        node.settings_motion.borrow_mut().remove(name);
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use pax_runtime_api::properties::{
332        drain_effects, property_has_direct_outbound, register_millis, register_time,
333    };
334
335    fn clock() -> LocalProperty<u64> {
336        register_time(&LocalProperty::new(0));
337        let millis = LocalProperty::new(0);
338        register_millis(&millis);
339        millis
340    }
341
342    fn owner() -> LocalProperty<Owner> {
343        LocalProperty::new(Owner {
344            imported: true,
345            explicit: false,
346            config: Some(SettingsTransitionConfig {
347                duration: Duration::Milliseconds(100.into()),
348                curve: "Linear",
349            }),
350        })
351    }
352
353    fn settle() {
354        drain_effects(100);
355    }
356
357    #[test]
358    fn retarget_samples_current_clock_and_restarts_full_duration() {
359        let millis = clock();
360        let output = LocalProperty::new(0.0);
361        let source = LocalProperty::new(0.0);
362        let motion = SettingsMotion::new(
363            &output,
364            source.clone(),
365            owner(),
366            false,
367            Rc::new(f64::interpolate),
368        )
369        .unwrap();
370        source.set(10.0);
371        settle();
372        millis.set(40);
373        // Deliberately don't pull the old output before interrupting it.
374        source.set(20.0);
375        settle();
376        assert_eq!(output.get(), 4.0);
377        millis.set(90);
378        assert_eq!(output.get(), 12.0);
379        millis.set(140);
380        assert_eq!(output.get(), 20.0);
381        millis.set(141);
382        assert_eq!(output.get(), 20.0);
383        assert!(!property_has_direct_outbound(
384            &millis.untyped(),
385            &motion.progress.untyped()
386        ));
387        // Source remains live after completing the first two generations.
388        source.set(0.0);
389        settle();
390        millis.set(191);
391        assert_eq!(output.get(), 10.0);
392    }
393
394    #[test]
395    fn initial_value_and_equal_targets_do_not_animate_or_restart() {
396        let millis = clock();
397        let output = LocalProperty::new(0.0);
398        let source = LocalProperty::new(10.0);
399        let motion = SettingsMotion::new(
400            &output,
401            source.clone(),
402            owner(),
403            false,
404            Rc::new(f64::interpolate),
405        )
406        .unwrap();
407        assert_eq!(output.get(), 10.0);
408        assert!(!property_has_direct_outbound(
409            &millis.untyped(),
410            &motion.progress.untyped()
411        ));
412        source.set(20.0);
413        settle();
414        millis.set(40);
415        source.set(20.0);
416        settle();
417        millis.set(100);
418        assert_eq!(output.get(), 20.0);
419    }
420
421    #[test]
422    fn rebind_preserves_motion_and_instances_have_independent_clocks() {
423        let millis = clock();
424        let a = LocalProperty::new(0.0);
425        let b = LocalProperty::new(0.0);
426        let a_target = LocalProperty::new(0.0);
427        let b_target = LocalProperty::new(0.0);
428        let first = SettingsMotion::new(
429            &a,
430            a_target.clone(),
431            owner(),
432            false,
433            Rc::new(f64::interpolate),
434        )
435        .unwrap();
436        let _second = SettingsMotion::new(
437            &b,
438            b_target.clone(),
439            owner(),
440            false,
441            Rc::new(f64::interpolate),
442        )
443        .unwrap();
444        a_target.set(10.0);
445        b_target.set(10.0);
446        settle();
447        millis.set(40);
448        first.target.replace_with(LocalProperty::new(20.0));
449        settle();
450        millis.set(90);
451        assert_eq!(a.get(), 12.0);
452        assert_eq!(b.get(), 9.0);
453    }
454
455    #[test]
456    fn disabling_snaps_and_detaches_clock() {
457        let millis = clock();
458        let output = LocalProperty::new(0.0);
459        let target = LocalProperty::new(0.0);
460        let policy = owner();
461        let motion = SettingsMotion::new(
462            &output,
463            target.clone(),
464            policy.clone(),
465            false,
466            Rc::new(f64::interpolate),
467        )
468        .unwrap();
469        target.set(10.0);
470        settle();
471        millis.set(40);
472        policy.update(|policy| policy.config = None);
473        settle();
474        assert_eq!(output.get(), 10.0);
475        assert!(!property_has_direct_outbound(
476            &millis.untyped(),
477            &motion.progress.untyped()
478        ));
479    }
480
481    #[test]
482    fn easing_overshoot_is_not_mistaken_for_completion() {
483        let millis = clock();
484        let output = LocalProperty::new(0.0);
485        let target = LocalProperty::new(0.0);
486        let policy = owner();
487        policy.update(|policy| policy.config.as_mut().unwrap().curve = "OutBack");
488        let _motion = SettingsMotion::new(
489            &output,
490            target.clone(),
491            policy,
492            false,
493            Rc::new(f64::interpolate),
494        )
495        .unwrap();
496        target.set(10.0);
497        settle();
498        millis.set(70);
499        assert!(output.get() > 10.0);
500        millis.set(100);
501        assert_eq!(output.get(), 10.0);
502    }
503
504    #[test]
505    fn compound_endpoints_do_not_keep_live_property_handles() {
506        let graph = pax_runtime_api::properties::PropertyGraph::new(|| {});
507        let _entered = graph.enter();
508        use pax_runtime_api::{Size, Stroke};
509        let millis = clock();
510        let width = LocalProperty::new(Size::Pixels(10.into()));
511        let source = {
512            let width = width.clone();
513            let dependencies = [width.untyped()];
514            LocalProperty::computed(
515                move || {
516                    let _ = width.get();
517                    Stroke {
518                        width: pax_runtime_api::PropertyBinding::from_local(width.clone()),
519                        ..Stroke::default()
520                    }
521                },
522                &dependencies,
523            )
524        };
525        let output = LocalProperty::new(Stroke::default());
526        let _motion = SettingsMotion::new(
527            &output,
528            source,
529            owner(),
530            false,
531            Rc::new(Stroke::interpolate),
532        )
533        .unwrap();
534        width.set(Size::Pixels(20.into()));
535        settle();
536        millis.set(50);
537        assert_eq!(output.get().width.get().expect_pixels().to_float(), 15.0);
538        width.set(Size::Pixels(40.into()));
539        settle();
540        assert_eq!(output.get().width.get().expect_pixels().to_float(), 15.0);
541        millis.set(100);
542        assert_eq!(output.get().width.get().expect_pixels().to_float(), 27.5);
543    }
544
545    #[test]
546    fn zero_duration_configuration_applies_pending_target_immediately() {
547        let millis = clock();
548        let output = LocalProperty::new(0.0);
549        let source = LocalProperty::new(0.0);
550        let policy = owner();
551        let motion = SettingsMotion::new(
552            &output,
553            source.clone(),
554            policy.clone(),
555            false,
556            Rc::new(f64::interpolate),
557        )
558        .unwrap();
559        source.set(10.0);
560        settle();
561        millis.set(40);
562        policy.update(|owner| {
563            owner.config.as_mut().unwrap().duration = Duration::Milliseconds(0.into())
564        });
565        settle();
566        assert_eq!(output.get(), 10.0);
567        assert!(!property_has_direct_outbound(
568            &millis.untyped(),
569            &motion.progress.untyped()
570        ));
571    }
572
573    #[test]
574    fn conditional_winner_controls_motion_not_the_last_structural_entry() {
575        use pax_manifest::{ExpressionInfo, TypeId};
576        use pax_runtime_api::Variable;
577        use std::collections::HashMap;
578        let millis = clock();
579        let condition = LocalProperty::new(false);
580        let base = LocalProperty::new(0.0);
581        let imported = LocalProperty::new(10.0);
582        let stack = RuntimePropertiesStackFrame::new(HashMap::from([(
583            "enabled".into(),
584            Variable::new_from_typed_property(condition.clone()),
585        )]));
586        let condition_info = crate::RuntimeSettingsCondition {
587            positive: vec![ExpressionInfo::new(
588                pax_language::parse_pax_expression("enabled").unwrap(),
589            )],
590            negative: vec![],
591        };
592        let entries = vec![RuntimeResolvedPropertyEntry {
593            source: RuntimeSettingsSource::ImportedLayer {
594                provider_id: crate::ExpandedNodeIdentifier(1),
595                provider_type_id: TypeId::default(),
596            },
597            transition: Some(LocalProperty::new(owner().get().config)),
598            selector: None,
599            source_location: None,
600            source_stack: Some(stack.clone()),
601            value: ValueDefinition::Undefined,
602            condition: Some(condition_info.clone()),
603            axis_index: None,
604        }];
605        let target = crate::apply_runtime_settings_condition(
606            "value",
607            Some(&condition_info),
608            &stack,
609            base.clone(),
610            imported.clone(),
611        );
612        let output = LocalProperty::new(0.0);
613        let _motion = SettingsMotion::new(
614            &output,
615            target,
616            owner_property(&entries, &stack),
617            false,
618            Rc::new(f64::interpolate),
619        )
620        .unwrap();
621        base.set(2.0);
622        imported.set(20.0);
623        settle();
624        assert_eq!(output.get(), 2.0); // The losing import does not animate this change.
625        condition.set(true);
626        settle();
627        millis.set(50);
628        assert_eq!(output.get(), 11.0);
629        condition.set(false);
630        settle();
631        millis.set(100);
632        assert_eq!(output.get(), 6.5); // Removal uses the outgoing import policy.
633    }
634}