gallery/vendor/i-slint-core/properties/properties_animations.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

1740 lines
70 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
use super::*;
use crate::{
animations::simulations::{
Parameter, Simulation,
spring::{SpringDurationBounceParameters, SpringParameters, SpringRegime},
},
items::{AnimationDirection, PropertyAnimation},
lengths::LogicalLength,
};
use euclid::Length;
#[cfg(not(feature = "std"))]
use num_traits::Float;
enum AnimationState {
/// The animation will start after the delay is finished
Delaying,
/// Actual animation
Animating {
current_iteration: u64,
},
Done {
iteration_count: u64,
},
}
pub(super) struct PropertyPhysicsAnimationData<S> {
simulation: S,
state: AnimationState,
}
impl<S> PropertyPhysicsAnimationData<S>
where
S: Simulation,
{
pub fn new(simulation: S) -> PropertyPhysicsAnimationData<S> {
PropertyPhysicsAnimationData { simulation, state: AnimationState::Delaying }
}
/// Single iteration of the animation
pub fn update_value(&mut self, target: &mut crate::Coord) -> bool {
match self.state {
AnimationState::Delaying => {
// Decide on next state:
self.state = AnimationState::Animating { current_iteration: 0 };
self.update_value(target)
}
AnimationState::Animating { current_iteration: _ } => {
// TODO: Pass in Coord directly?
let mut value: f32 = *target as f32;
let finished = self.simulation.step(&mut value, crate::animations::current_tick());
*target = value as crate::Coord;
if finished {
self.state = AnimationState::Done { iteration_count: 0 };
true
} else {
false
}
}
AnimationState::Done { iteration_count: _ } => true,
}
}
}
pub(super) struct PropertyValueAnimationData<T> {
from_value: T,
to_value: Option<T>,
details: PropertyAnimation,
start_time: crate::animations::Instant,
state: AnimationState,
/// Applied to every interpolated value before it is stored. Lets a
/// type-erased property (the interpreter's `Property<Value>`) reproduce
/// the interpolation of the erased type, e.g. rounding for `int`.
map: Option<fn(T) -> T>,
spring: Option<SpringRegime>,
/// Whether the final iteration's spring has already been re-damped
spring_settle_clamped: bool,
}
impl<T: InterpolatedPropertyValue + Clone> PropertyValueAnimationData<T> {
pub fn new(from_value: T, to_value: Option<T>, details: PropertyAnimation) -> Self {
Self::new_with_velocity(from_value, to_value, details, 0.0)
}
/// Used to carry velocity over across a retarget.
pub fn new_with_velocity(
from_value: T,
to_value: Option<T>,
details: PropertyAnimation,
initial_velocity: f32,
) -> Self {
let start_time = crate::animations::current_tick();
let spring = Self::compute_spring(&details, &from_value, &to_value, initial_velocity);
Self {
from_value,
to_value,
details,
start_time,
state: AnimationState::Delaying,
map: None,
spring,
spring_settle_clamped: false,
}
}
/// A spring with duration <= 0 (and no mass/stiffness/damping override) can't be simulated
fn compute_spring(
details: &PropertyAnimation,
from_value: &T,
to_value: &Option<T>,
initial_velocity: f32,
) -> Option<SpringRegime> {
matches!(details.easing, crate::animations::EasingCurve::Spring(_))
.then(|| {
let crate::animations::EasingCurve::Spring(bounce) = details.easing else {
return None;
};
let (w_n, zeta) = if details.duration > 0 {
Some(
SpringDurationBounceParameters::new(
details.duration as f32 / 1000.0,
bounce,
)
.to_natural_frequency_and_damping_ratio(),
)
} else {
None
}?;
// -1 so that the spring knows to go to 0; re-express the carried-over velocity
// (in property units/sec) in the spring's -1..=0-relative units.
let delta = to_value.as_ref().map_or(0.0, |tv| from_value.scalar_delta(tv));
let v0 = if delta != 0.0 { initial_velocity / delta } else { 0.0 };
Some(SpringRegime::new(-1.0, v0, w_n, zeta))
})
.flatten()
}
pub fn with_map(mut self, map: fn(T) -> T) -> Self {
self.map = Some(map);
self
}
fn apply_map(&self, value: T) -> T {
match self.map {
Some(map) => map(value),
None => value,
}
}
/// The current velocity (in property units per second) of a live spring animation
fn current_velocity(&self) -> Option<f32> {
if !matches!(self.state, AnimationState::Animating { .. }) {
return None;
}
let spring = self.spring.as_ref()?;
let elapsed_secs =
crate::animations::current_tick().duration_since(self.start_time).as_millis() as f32
/ 1000.0;
let (_, rel_vel) = spring.evaluate(elapsed_secs);
let to_value = self.to_value.as_ref().expect("The animation should have a to_value");
Some(rel_vel * self.from_value.scalar_delta(to_value))
}
/// Single iteration of the animation
pub fn compute_interpolated_value(&mut self) -> (T, bool) {
// If animation is disabled, immediately return the target value
let to_value = self.to_value.clone().expect("The animation should have a to_value");
if !self.details.enabled {
return (self.apply_map(to_value), true);
}
let new_tick = crate::animations::current_tick();
let mut time_progress = new_tick.duration_since(self.start_time).as_millis() as u64;
let reversed = |iteration: u64| -> bool {
#[allow(clippy::manual_is_multiple_of)] // keep symmetry
match self.details.direction {
AnimationDirection::Normal => false,
AnimationDirection::Reverse => true,
AnimationDirection::Alternate => iteration % 2 == 1,
AnimationDirection::AlternateReverse => iteration % 2 == 0,
}
};
match self.state {
AnimationState::Delaying => {
if self.details.delay <= 0 {
self.state = AnimationState::Animating { current_iteration: 0 };
return self.compute_interpolated_value();
}
let delay = self.details.delay as u64;
if time_progress < delay {
if reversed(0) {
(self.apply_map(to_value), false)
} else {
(self.apply_map(self.from_value.clone()), false)
}
} else {
self.start_time =
new_tick - core::time::Duration::from_millis(time_progress - delay);
// Decide on next state:
self.state = AnimationState::Animating { current_iteration: 0 };
self.compute_interpolated_value()
}
}
AnimationState::Animating { current_iteration } => {
// A spring runs in real time and ends only once it settles.
if matches!(self.details.easing, crate::animations::EasingCurve::Spring(_)) {
if self.details.iteration_count == 0. {
self.state = AnimationState::Done { iteration_count: 0 };
return self.compute_interpolated_value();
}
return if let Some(spring) = self.spring.as_ref() {
let next_iteration = current_iteration + 1;
let has_more_iterations = self.details.iteration_count < 0.
|| (next_iteration as f64) < self.details.iteration_count as f64;
let duration_ms = self.details.duration as u64;
if has_more_iterations && time_progress >= duration_ms {
// Bounce into the next iteration at `duration` rather than waiting to
// settle. Velocity always carries over; position only carries over on
// a direction flip (e.g. `alternate`), to stay continuous instead of
// snapping. Otherwise it resets to the start, like a repeating easing
// curve.
let duration_secs = duration_ms as f32 / 1000.0;
let (rel_pos, rel_vel) = spring.evaluate(duration_secs);
let crate::animations::EasingCurve::Spring(bounce) =
self.details.easing
else {
unreachable!()
};
let (w_n, zeta) =
SpringDurationBounceParameters::new(duration_secs, bounce)
.to_natural_frequency_and_damping_ratio();
let x0 = if reversed(current_iteration) != reversed(next_iteration) {
-(1.0 + rel_pos)
} else {
-1.0
};
self.spring = Some(SpringRegime::new(x0, rel_vel, w_n, zeta));
self.start_time += core::time::Duration::from_millis(duration_ms);
self.state =
AnimationState::Animating { current_iteration: next_iteration };
self.compute_interpolated_value()
} else {
let elapsed_secs = time_progress as f32 / 1000.0;
let (t, settled) =
crate::animations::spring_settle_progress(spring, elapsed_secs);
if !settled
&& !self.spring_settle_clamped
&& time_progress >= duration_ms
{
// Hasn't settled by the end of `duration`: re-damp the tail (see
// `spring_settle_within`) so it's guaranteed to settle within a
// further 9 multiples of `duration`, carrying over position/velocity.
self.spring_settle_clamped = true;
let duration_secs = duration_ms as f32 / 1000.0;
let w_n = 2.0 * core::f32::consts::PI / duration_secs;
let settled_regime = crate::animations::spring_settle_within(
spring,
duration_secs,
w_n,
);
self.spring = Some(settled_regime);
self.start_time += core::time::Duration::from_millis(duration_ms);
return self.compute_interpolated_value();
}
if settled {
self.state = if has_more_iterations {
self.start_time = new_tick;
AnimationState::Animating { current_iteration: next_iteration }
} else {
AnimationState::Done { iteration_count: current_iteration }
};
self.compute_interpolated_value()
} else {
let progress = if reversed(current_iteration) { 1. - t } else { t };
let val = self.from_value.interpolate(&to_value, progress);
(self.apply_map(val), false)
}
}
} else {
self.state = AnimationState::Done { iteration_count: 0 };
self.compute_interpolated_value()
};
}
let mut current_iteration = current_iteration;
if self.details.duration <= 0 || self.details.iteration_count == 0. {
self.state = AnimationState::Done { iteration_count: 0 };
return self.compute_interpolated_value();
}
let duration = self.details.duration as u64;
if time_progress >= duration {
// wrap around
current_iteration += time_progress / duration;
time_progress %= duration;
self.start_time = new_tick - core::time::Duration::from_millis(time_progress);
}
if (self.details.iteration_count < 0.)
|| (((current_iteration * duration) + time_progress) as f64)
< ((self.details.iteration_count as f64) * (duration as f64))
{
self.state = AnimationState::Animating { current_iteration };
let progress = {
let progress =
(time_progress as f32 / self.details.duration as f32).clamp(0., 1.);
if reversed(current_iteration) { 1. - progress } else { progress }
};
let t = crate::animations::easing_curve(&self.details.easing, progress);
let val = self.from_value.interpolate(&to_value, t);
(self.apply_map(val), false)
} else {
self.state =
AnimationState::Done { iteration_count: current_iteration.max(1) - 1 };
self.compute_interpolated_value()
}
}
AnimationState::Done { iteration_count } => {
if reversed(iteration_count) {
(self.apply_map(self.from_value.clone()), true)
} else {
(self.apply_map(to_value), true)
}
}
}
}
}
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub(super) enum AnimatedBindingState {
Animating,
NotAnimating,
ShouldStart,
}
#[pin_project::pin_project]
pub(super) struct AnimatedBindingCallable<T, A> {
#[pin]
pub(super) original_binding: PropertyHandle,
pub(super) state: Cell<AnimatedBindingState>,
pub(super) animation_data: RefCell<PropertyValueAnimationData<T>>,
pub(super) compute_animation_details: A,
/// Tick captured by `mark_dirty`
pub(super) dirty_time: Cell<crate::animations::Instant>,
pub(crate) carried_velocity: Cell<f32>,
}
pub(super) type AnimationDetail = (PropertyAnimation, Option<crate::animations::Instant>);
unsafe impl<T: InterpolatedPropertyValue + Clone, A: Fn() -> AnimationDetail> BindingCallable<T>
for AnimatedBindingCallable<T, A>
{
fn evaluate(self: Pin<&Self>, value: &mut T) -> BindingResult {
let original_binding = self.project_ref().original_binding;
original_binding.register_as_dependency_to_current_binding(
#[cfg(slint_debug_property)]
"<AnimatedBindingCallable>",
);
match self.state.get() {
AnimatedBindingState::Animating => {
let (val, finished) = self.animation_data.borrow_mut().compute_interpolated_value();
*value = val;
if finished {
self.state.set(AnimatedBindingState::NotAnimating)
} else {
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.set_has_active_animations());
}
}
AnimatedBindingState::NotAnimating => {
// Safety: `value` is a valid mutable reference
unsafe { self.original_binding.update(value as *mut T) };
}
AnimatedBindingState::ShouldStart => {
let mut animation_data = self.animation_data.borrow_mut();
// Since `mark_dirty` fires when dependencies of `original_binding` changes
// if the change doesn't actually affect the computed value, it shouldn't restart
// the animation
let previous_to_value = animation_data.to_value.clone();
let mut new_to_value = T::default();
// Safety: `new_to_value` is a valid mutable reference matching the
// original binding's value type
unsafe { self.original_binding.update(&mut new_to_value as *mut T) };
animation_data.to_value = Some(new_to_value);
if animation_data.to_value != previous_to_value {
animation_data.state = AnimationState::Delaying;
// Anchor timing to when the change was first signalled
animation_data.start_time = self.dirty_time.get();
// animation_data.details.iteration_count = 1.;
animation_data.from_value = value.clone();
let (details, start_time) = (self.compute_animation_details)();
if let Some(start_time) = start_time {
animation_data.start_time = start_time;
}
animation_data.details = details;
animation_data.spring = PropertyValueAnimationData::<T>::compute_spring(
&animation_data.details,
&animation_data.from_value,
&animation_data.to_value,
self.carried_velocity.get(),
);
animation_data.spring_settle_clamped = false;
}
self.state.set(AnimatedBindingState::Animating);
let (val, finished) = animation_data.compute_interpolated_value();
*value = val;
if finished {
self.state.set(AnimatedBindingState::NotAnimating)
} else {
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.set_has_active_animations());
}
}
};
BindingResult::KeepBinding
}
fn mark_dirty(self: Pin<&Self>) {
if self.state.get() == AnimatedBindingState::ShouldStart {
return;
}
let original_dirty = self.original_binding.access(|b| b.unwrap().dirty.get());
if original_dirty {
self.carried_velocity
.set(self.animation_data.borrow().current_velocity().unwrap_or(0.0));
self.state.set(AnimatedBindingState::ShouldStart);
self.dirty_time.set(crate::animations::current_tick());
}
}
fn velocity(self: Pin<&Self>) -> Option<f32> {
self.animation_data.borrow().current_velocity()
}
}
/// InterpolatedPropertyValue is a trait used to enable properties to be used with
/// animations that interpolate values. The basic requirement is the ability to apply
/// a progress that's typically between 0 and 1 to a range.
pub trait InterpolatedPropertyValue: PartialEq + Default + 'static {
/// Returns the interpolated value between self and target_value according to the
/// progress parameter t that's usually between 0 and 1. With certain animation
/// easing curves it may over- or undershoot though.
#[must_use]
fn interpolate(&self, target_value: &Self, t: f32) -> Self;
/// Returns `target_value - self` as a scalar.
/// Types with no natural single-scalar notion of velocity keep the default `0.0`
fn scalar_delta(&self, _target_value: &Self) -> f32 {
0.0
}
}
impl InterpolatedPropertyValue for f32 {
fn interpolate(&self, target_value: &Self, t: f32) -> Self {
self + t * (target_value - self)
}
fn scalar_delta(&self, target_value: &Self) -> f32 {
target_value - self
}
}
impl InterpolatedPropertyValue for i32 {
fn interpolate(&self, target_value: &Self, t: f32) -> Self {
self + (t * (target_value - self) as f32).round() as i32
}
fn scalar_delta(&self, target_value: &Self) -> f32 {
(target_value - self) as f32
}
}
impl InterpolatedPropertyValue for i64 {
fn interpolate(&self, target_value: &Self, t: f32) -> Self {
self + (t * (target_value - self) as f32).round() as Self
}
fn scalar_delta(&self, target_value: &Self) -> f32 {
(target_value - self) as f32
}
}
impl InterpolatedPropertyValue for u8 {
fn interpolate(&self, target_value: &Self, t: f32) -> Self {
((*self as f32) + (t * ((*target_value as f32) - (*self as f32)))).round().clamp(0., 255.)
as u8
}
fn scalar_delta(&self, target_value: &Self) -> f32 {
(*target_value as f32) - (*self as f32)
}
}
impl InterpolatedPropertyValue for LogicalLength {
fn interpolate(&self, target_value: &Self, t: f32) -> Self {
LogicalLength::new(self.get().interpolate(&target_value.get(), t))
}
fn scalar_delta(&self, target_value: &Self) -> f32 {
(target_value.get() - self.get()) as f32
}
}
/// Binding installed by `Property::set_animated_value`.
/// A type so a retarget can report the current velocity
struct AnimatedValueBinding<T> {
animation_data: RefCell<PropertyValueAnimationData<T>>,
}
unsafe impl<T: InterpolatedPropertyValue + Clone + 'static> BindingCallable<T>
for AnimatedValueBinding<T>
{
fn evaluate(self: Pin<&Self>, value: &mut T) -> BindingResult {
let (val, finished) = self.animation_data.borrow_mut().compute_interpolated_value();
*value = val;
if finished {
BindingResult::RemoveBinding
} else {
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.set_has_active_animations());
BindingResult::KeepBinding
}
}
fn velocity(self: Pin<&Self>) -> Option<f32> {
self.animation_data.borrow().current_velocity()
}
}
impl<T: Clone + InterpolatedPropertyValue + 'static> Property<T> {
/// Evaluate the property and remove the (animation) binding of this property.
///
/// Note that a binding can intercept this via intercept_set_binding and still remain on the property.
/// (e.g. two-way-bindings will not be removed with this call!)
pub fn remove_binding(self: Pin<&Self>) {
// FIXME: This is a bit of a hack, set_animated_value will call set_binding on the internal handle,
// which will call intercept_set_binding, which will check if the binding should be removed or not.
// In the case of two-way bindings, we want to keep the binding, but reset the value to the current one,
// so that any animation binding is removed, but the two-way-binding is kept.
self.set_animated_value(self.get(), PropertyAnimation::default());
}
/// Change the value of this property, by animating (interpolating) from the current property's value
/// to the specified parameter value. The animation is done according to the parameters described by
/// the PropertyAnimation object.
///
/// If other properties have binding depending of this property, these properties will
/// be marked as dirty.
pub fn set_animated_value(self: Pin<&Self>, value: T, animation_data: PropertyAnimation) {
self.set_animated_value_impl(value, animation_data, None)
}
/// Like [`Self::set_animated_value`], but passes every interpolated value through `map`
/// before storing it, so a type-erased property can reproduce the interpolation
/// of the erased type (e.g. rounding for `int` properties).
pub fn set_animated_value_with_map(
self: Pin<&Self>,
value: T,
animation_data: PropertyAnimation,
map: fn(T) -> T,
) {
self.set_animated_value_impl(value, animation_data, Some(map))
}
fn set_animated_value_impl(
self: Pin<&Self>,
value: T,
animation_data: PropertyAnimation,
map: Option<fn(T) -> T>,
) {
// Carry over the outgoing binding's velocity
let carried_velocity = self.handle.current_velocity().unwrap_or(0.0);
let mut d = properties_animations::PropertyValueAnimationData::new_with_velocity(
self.get(),
Some(value),
animation_data,
carried_velocity,
);
if let Some(map) = map {
d = d.with_map(map);
}
let binding =
properties_animations::AnimatedValueBinding { animation_data: RefCell::new(d) };
// Safety: the BindingCallable will cast its argument to T
unsafe {
self.handle.set_binding(
binding,
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str(),
);
}
self.handle.mark_dirty(
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str(),
);
}
/// Set a binding to this property, providing a callback for the animation and an optional
/// start_time (relevant for state transitions).
pub fn set_animated_binding(
&self,
binding: impl Binding<T> + 'static,
compute_animation_details: impl Fn() -> (PropertyAnimation, Option<crate::animations::Instant>)
+ 'static,
) {
self.set_animated_binding_impl(binding, compute_animation_details, None)
}
/// Like [`Self::set_animated_binding`], but passes every interpolated value through `map`
/// before storing it, so a type-erased property can reproduce the interpolation
/// of the erased type (e.g. rounding for `int` properties).
pub fn set_animated_binding_with_map(
&self,
binding: impl Binding<T> + 'static,
compute_animation_details: impl Fn() -> (PropertyAnimation, Option<crate::animations::Instant>)
+ 'static,
map: fn(T) -> T,
) {
self.set_animated_binding_impl(binding, compute_animation_details, Some(map))
}
fn set_animated_binding_impl(
&self,
binding: impl Binding<T> + 'static,
compute_animation_details: impl Fn() -> (PropertyAnimation, Option<crate::animations::Instant>)
+ 'static,
map: Option<fn(T) -> T>,
) {
let mut animation_data = properties_animations::PropertyValueAnimationData::new(
T::default(),
None,
PropertyAnimation::default(),
);
if let Some(map) = map {
animation_data = animation_data.with_map(map);
}
let binding_callable = properties_animations::AnimatedBindingCallable::<T, _> {
original_binding: PropertyHandle {
handle: Cell::new(
(alloc_binding_holder(move |val: &mut T| {
*val = binding.evaluate(val);
BindingResult::KeepBinding
}) as *mut ())
.map_addr(|a| a | 0b10),
),
},
state: Cell::new(properties_animations::AnimatedBindingState::NotAnimating),
animation_data: RefCell::new(animation_data),
compute_animation_details,
dirty_time: Cell::new(crate::animations::current_tick()),
carried_velocity: Cell::new(0.0),
};
// Safety: the `AnimatedBindingCallable`'s type match the property type
unsafe {
self.handle.set_binding(
binding_callable,
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str(),
)
};
self.handle.mark_dirty(
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str(),
);
}
}
unsafe impl<Unit, S: Simulation> BindingCallable<Length<crate::Coord, Unit>>
for RefCell<PropertyPhysicsAnimationData<S>>
{
fn evaluate(self: Pin<&Self>, value: &mut Length<crate::Coord, Unit>) -> BindingResult {
let finished = self.borrow_mut().update_value(&mut value.0);
if finished {
BindingResult::RemoveBinding
} else {
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.set_has_active_animations());
BindingResult::KeepBinding
}
}
// This binding should not be removed if the value is updated externally.
fn intercept_set(self: Pin<&Self>, _value: &Length<crate::Coord, Unit>) -> bool {
true
}
}
impl<Unit> Property<Length<crate::Coord, Unit>> {
/// Change the value by using a physics animation
pub fn set_physic_animation_value<S: Simulation + 'static, AD: Parameter<Output = S>>(
&self,
limit_value: Pin<Box<Property<f32>>>,
simulation_data: AD,
) {
// Safety: the BindingCallable will cast its argument to T
unsafe {
self.handle.set_binding::<Length<crate::Coord, Unit>, core::cell::RefCell<PropertyPhysicsAnimationData<S>>>(RefCell::new(PropertyPhysicsAnimationData::new(
simulation_data.simulation(self.get_internal().0 as f32, limit_value),
)),
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str()
);
}
self.handle.mark_dirty(
#[cfg(slint_debug_property)]
self.debug_name.borrow().as_str(),
);
}
}
#[cfg(test)]
mod animation_tests {
use super::*;
use pin_weak::rc::PinWeak;
use std::rc::Rc;
#[derive(Default)]
struct Component {
width: Property<i32>,
width_times_two: Property<i32>,
feed_property: Property<i32>, // used by binding to feed values into width
}
impl Component {
fn new_test_component() -> Pin<Rc<Self>> {
let compo = Rc::pin(Component::default());
let w = PinWeak::downgrade(compo.clone());
compo.width_times_two.set_binding(move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.width) * 2
});
compo
}
}
const DURATION: std::time::Duration = std::time::Duration::from_millis(10000);
const DELAY: std::time::Duration = std::time::Duration::from_millis(800);
// Helper just for testing
fn get_prop_value<T: Clone>(prop: &Property<T>) -> T {
unsafe { Pin::new_unchecked(prop).get() }
}
// Helper just for testing: the property lives in a pinned `Rc<Component>`.
fn set_animated_value<T: Clone + InterpolatedPropertyValue + 'static>(
prop: &Property<T>,
value: T,
animation_data: PropertyAnimation,
) {
unsafe { Pin::new_unchecked(prop) }.set_animated_value(value, animation_data);
}
#[test]
fn properties_test_animation_negative_delay_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: -25,
duration: DURATION.as_millis() as _,
iteration_count: 1.,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_animation_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
duration: DURATION.as_millis() as _,
iteration_count: 1.,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: 1.,
duration: DURATION.as_millis() as _,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_fractal_iteration_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: 1.5,
duration: DURATION.as_millis() as _,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// (fractal) end of animation
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 4));
assert_eq!(get_prop_value(&compo.width), 125);
assert_eq!(get_prop_value(&compo.width_times_two), 250);
// End of animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_null_duration_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: 1.0,
duration: 0,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// No animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_negative_duration_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: 1.0,
duration: -25,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// No animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_no_iteration_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: 0.0,
duration: DURATION.as_millis() as _,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// No animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_delayed_animation_negative_iteration_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
iteration_count: -42., // loop forever!
duration: DURATION.as_millis() as _,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In animation (again):
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + 500 * DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + DELAY + 50000 * DURATION + DURATION / 2)
});
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
// the binding should not be removed as it is still animating!
compo.width.handle.access(|binding| assert!(binding.is_some()));
}
#[test]
fn properties_test_animation_direction_triggered_by_set() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
delay: -25,
duration: DURATION.as_millis() as _,
direction: AnimationDirection::AlternateReverse,
iteration_count: 1.,
..PropertyAnimation::default()
};
compo.width.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// Overshoot: Always from_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn properties_test_animation_triggered_by_binding() {
let compo = Component::new_test_component();
let start_time = crate::animations::current_tick();
let animation_details = PropertyAnimation {
duration: DURATION.as_millis() as _,
iteration_count: 1.,
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.feed_property)
},
move || (animation_details.clone(), None),
);
compo.feed_property.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
compo.feed_property.set(200);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
}
#[test]
fn properties_test_delayed_animation_triggered_by_binding() {
let compo = Component::new_test_component();
let start_time = crate::animations::current_tick();
let animation_details = PropertyAnimation {
delay: DELAY.as_millis() as _,
duration: DURATION.as_millis() as _,
iteration_count: 1.0,
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.feed_property)
},
move || (animation_details.clone(), None),
);
compo.feed_property.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
compo.feed_property.set(200);
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In delay:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY / 2));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
// In animation:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY));
assert_eq!(get_prop_value(&compo.width), 100);
assert_eq!(get_prop_value(&compo.width_times_two), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
assert_eq!(get_prop_value(&compo.width_times_two), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
// Overshoot: Always to_value.
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DELAY + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
assert_eq!(get_prop_value(&compo.width_times_two), 400);
}
#[test]
fn properties_test_animation_triggered_by_binding_with_unrelated_dirty() {
// Reproduces the dependency not changing target bug: the binding driving the
// animated property (`row.1`) never changes, but an *unrelated* field of the
// same source value (`row.0`) is rewritten every frame, like a repeated item's
// model row being touched by `VecModel::set_row_data` every tick.
#[derive(Default)]
struct Component {
width: Property<i32>,
row: Property<(i32, bool)>,
}
let compo = Rc::pin(Component::default());
let animation_details = PropertyAnimation {
duration: DURATION.as_millis() as _,
iteration_count: 1.,
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
if get_prop_value(&compo.row).1 { 200 } else { 40 }
},
move || (animation_details.clone(), None),
);
compo.row.set((0, false));
assert_eq!(get_prop_value(&compo.width), 40);
let start_time = crate::animations::current_tick();
// Flip the field the animation depends on: this should kick off a 40 -> 200
// animation over DURATION.
compo.row.set((0, true));
assert_eq!(get_prop_value(&compo.width), 40);
// Simulate ~700 real frames (16ms each -- more than DURATION worth of real time
// in total)
let tick = core::time::Duration::from_millis(16);
for i in 1..=700u32 {
compo.row.set((i as i32, true));
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + tick * i));
// Poll every frame like a real renderer repainting
let _ = get_prop_value(&compo.width);
}
// After more than DURATION worth of real time has elapsed, the animation should
// have completed regardless of the unrelated per-frame writes to `row.0`.
assert_eq!(get_prop_value(&compo.width), 200);
}
#[test]
fn test_loop() {
let compo = Component::new_test_component();
let animation_details = PropertyAnimation {
duration: DURATION.as_millis() as _,
iteration_count: 2.,
..PropertyAnimation::default()
};
compo.width.set(100);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 200, animation_details);
assert_eq!(get_prop_value(&compo.width), 100);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION * 2));
assert_eq!(get_prop_value(&compo.width), 200);
// the binding should be removed
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn test_loop_via_binding() {
// Loop twice, restart the animation and still loop twice.
let compo = Component::new_test_component();
let start_time = crate::animations::current_tick();
let animation_details = PropertyAnimation {
duration: DURATION.as_millis() as _,
iteration_count: 2.,
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.feed_property)
},
move || (animation_details.clone(), None),
);
compo.feed_property.set(100);
assert_eq!(get_prop_value(&compo.width), 100);
compo.feed_property.set(200);
assert_eq!(get_prop_value(&compo.width), 100);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 100);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 150);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + 2 * DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
// Overshoot a bit:
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + 2 * DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 200);
// Restart the animation by setting a new value.
let start_time = crate::animations::current_tick();
compo.feed_property.set(300);
assert_eq!(get_prop_value(&compo.width), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 250);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION));
assert_eq!(get_prop_value(&compo.width), 200);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 250);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + 2 * DURATION));
assert_eq!(get_prop_value(&compo.width), 300);
crate::animations::CURRENT_ANIMATION_DRIVER
.with(|driver| driver.update_animations(start_time + 2 * DURATION + DURATION / 2));
assert_eq!(get_prop_value(&compo.width), 300);
}
#[test]
fn spring_retarget_carries_velocity() {
// A retarget mid-flight must carry the outgoing spring's velocity into the new one,
// instead of restarting it at rest (which would produce a visible "pop").
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 1000,
easing: crate::animations::EasingCurve::Spring(0.0),
..PropertyAnimation::default()
};
compo.width.set(0);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 1000, spring_details.clone());
// Let the spring run for a while so it picks up meaningful velocity.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(300))
});
let before_a = get_prop_value(&compo.width) as f32;
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(310))
});
let before_b = get_prop_value(&compo.width) as f32;
let slope_before = before_b - before_a; // units per 10ms, just prior to the retarget
// Retarget to a new value while the spring is still moving.
set_animated_value(&compo.width, 2000, spring_details);
assert_eq!(
get_prop_value(&compo.width) as f32,
before_b,
"retarget must not snap the value"
);
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(320))
});
let after = get_prop_value(&compo.width) as f32;
let slope_after = after - before_b; // units per 10ms, just after the retarget
// With velocity carried over, the slope right after the retarget should be close to the
// slope right before it (same order of magnitude, same direction). Without the fix, the
// new spring starts at rest (v0 == 0), so `slope_after` would be near zero here.
assert!(slope_before > 0.5, "sanity check: spring should be moving before retarget");
assert!(
slope_after > slope_before * 0.5,
"velocity was not carried over: slope_before={slope_before}, slope_after={slope_after}"
);
}
#[test]
fn spring_retarget_via_binding_carries_velocity() {
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 1000,
easing: crate::animations::EasingCurve::Spring(0.0),
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
let details = spring_details.clone();
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.feed_property)
},
move || (details.clone(), None),
);
// Establish the dependency and a baseline value (the very first read never animates).
compo.feed_property.set(0);
assert_eq!(get_prop_value(&compo.width), 0);
let start_time = crate::animations::current_tick();
compo.feed_property.set(1000);
assert_eq!(get_prop_value(&compo.width), 0);
// Let the spring run for a while so it picks up meaningful velocity.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(300))
});
let before_a = get_prop_value(&compo.width) as f32;
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(310))
});
let before_b = get_prop_value(&compo.width) as f32;
let slope_before = before_b - before_a; // units per 10ms, just prior to the retarget
// Retarget mid-flight by changing the value the animated binding reads.
compo.feed_property.set(2000);
assert_eq!(
get_prop_value(&compo.width) as f32,
before_b,
"retarget must not snap the value"
);
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(320))
});
let after = get_prop_value(&compo.width) as f32;
let slope_after = after - before_b; // units per 10ms, just after the retarget
assert!(slope_before > 0.5, "sanity check: spring should be moving before retarget");
assert!(
slope_after > slope_before * 0.5,
"velocity was not carried over through the binding-triggered retarget path: slope_before={slope_before}, slope_after={slope_after}"
);
}
#[test]
fn spring_continuous_retarget_keeps_advancing() {
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 1000,
easing: crate::animations::EasingCurve::Spring(0.7),
..PropertyAnimation::default()
};
let w = PinWeak::downgrade(compo.clone());
let details = spring_details.clone();
compo.width.set_animated_binding(
move || {
let compo = w.upgrade().unwrap();
get_prop_value(&compo.feed_property)
},
move || (details.clone(), None),
);
compo.feed_property.set(0);
assert_eq!(get_prop_value(&compo.width), 0);
let start_time = crate::animations::current_tick();
let mut mouse_x = 0i32;
let mut final_width = 0f32;
for frame in 1..=200 {
mouse_x += 5; // simulate a steady mouse drag, 5px per frame
compo.feed_property.set(mouse_x);
let t = start_time + core::time::Duration::from_millis(frame * 16);
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| driver.update_animations(t));
// Read the property every frame, like a renderer would when painting each frame --
// this is what actually drives `evaluate()` (property evaluation is lazy).
final_width = get_prop_value(&compo.width) as f32;
}
assert!(
final_width > 500.0,
"spring should have tracked the continuously-moving target by now, got {final_width}"
);
}
#[test]
fn spring_respects_reverse_direction() {
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 200,
easing: crate::animations::EasingCurve::Spring(0.0),
direction: AnimationDirection::Reverse,
..PropertyAnimation::default()
};
compo.width.set(0);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 100, spring_details);
// Reverse: the animation starts at the target and settles back at the origin.
assert_eq!(get_prop_value(&compo.width), 100);
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(2000))
});
assert_eq!(get_prop_value(&compo.width), 0);
// the binding should be removed once settled
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn spring_respects_iteration_count_bounce() {
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 200,
easing: crate::animations::EasingCurve::Spring(0.0),
direction: AnimationDirection::Alternate,
iteration_count: 2.,
..PropertyAnimation::default()
};
compo.width.set(0);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 100, spring_details);
assert_eq!(get_prop_value(&compo.width), 0);
// The first leg (forward) switches into the second (reversed) leg at exactly
// `duration`, carrying the spring's velocity into the bounce-back.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(200))
});
let mid = get_prop_value(&compo.width);
assert!(mid > 90, "expected the first leg to have reached the target, got {mid}");
compo.width.handle.access(|binding| assert!(binding.is_some()));
// Second leg settles back at the origin, and the binding is then removed.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(600))
});
assert_eq!(get_prop_value(&compo.width), 0);
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn spring_never_settling_on_its_own_settles_within_budget() {
// bounce: 1.0 is undamped -- on its own it oscillates forever. With a finite
// iteration-count (the default, here), it must still settle within
// 10 multiples of `duration` (see `spring_settle_within`).
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 200,
easing: crate::animations::EasingCurve::Spring(1.0),
..PropertyAnimation::default()
};
compo.width.set(0);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 100, spring_details);
// An undamped spring's period equals `duration`, so right at `duration` it's back near
// its start -- nowhere near settled, and still running at full, unclamped bounce.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(200))
});
assert!(get_prop_value(&compo.width) < 20, "should still be near the start at duration");
compo.width.handle.access(|binding| assert!(binding.is_some()));
// Comfortably within the settle budget (10x duration), it must have settled.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(2000))
});
assert_eq!(get_prop_value(&compo.width), 100);
compo.width.handle.access(|binding| assert!(binding.is_none()));
}
#[test]
fn spring_never_settling_stays_infinite_with_iteration_count_minus_one() {
// The same never-settling bounce, but with `iteration-count: -1`: it must keep
// oscillating indefinitely instead of ever being re-damped.
let compo = Component::new_test_component();
let spring_details = PropertyAnimation {
duration: 200,
easing: crate::animations::EasingCurve::Spring(1.0),
iteration_count: -1.,
direction: AnimationDirection::Alternate,
..PropertyAnimation::default()
};
compo.width.set(0);
let start_time = crate::animations::current_tick();
set_animated_value(&compo.width, 100, spring_details);
// Well past what would be the settle budget for a finite iteration-count: still running.
crate::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
driver.update_animations(start_time + core::time::Duration::from_millis(3000))
});
compo.width.handle.access(|binding| assert!(binding.is_some()));
}
}