Skip to main content

frust_widgets/physics/
simulation.rs

1//! Concrete [`Simulation`] curves — the ballistic motion a
2//! [`ScrollPhysics`](super::ScrollPhysics) hands back once a gesture releases.
3//!
4//! Four ports of Flutter's scroll math, formulas and tuning constants carried
5//! over unchanged so a frust fling lands where the platform's own would:
6//! `physics/friction_simulation.dart` ([`FrictionSimulation`]),
7//! `physics/spring_simulation.dart` ([`SpringSimulation`],
8//! [`ScrollSpringSimulation`]) and `widgets/scroll_simulation.dart`
9//! ([`ClampingScrollSimulation`], [`BouncingScrollSimulation`]) — the last two
10//! being in turn ports of Android's `SplineOverScroller` curve and iOS's
11//! `UIScrollView` friction-then-rubber-band behavior. Every tuning constant is
12//! stated as the expression its source states it as (never a transcribed
13//! decimal) and pinned by a test in this file.
14//!
15//! [`TweenSimulation`] is frust's own, not a Flutter port: a plain eased
16//! interpolation between two positions over a fixed duration, built from this
17//! crate's own [`Curve`] vocabulary rather than Flutter's `Curve` class. It
18//! backs [`crate::scroll_controller::ScrollController::animate_to`], draining
19//! into the same ballistic driver a release fling runs through so paint
20//! cadence and boundary handling are shared rather than duplicated.
21//!
22//! Time is in **seconds** from each simulation's own start, per the
23//! [`Simulation`] contract; a simulation carries the [`Tolerance`] it settles
24//! within as a constructor argument rather than reading one per call.
25
26use frust_core::Curve;
27
28use super::{Simulation, SpringDescription, Tolerance};
29
30/// Flutter's `Tolerance.defaultTolerance` distance term — the fallback for a
31/// simulation constructed without a device-derived [`Tolerance`], i.e.
32/// [`FrictionSimulation::through`], which pins the velocity term to its own
33/// requested end velocity and has no device metrics to derive the rest from.
34const DEFAULT_DISTANCE_TOLERANCE: f64 = 1e-3;
35
36/// Dart's `double.sign`, which [`f64::signum`] is not: `signum` reports `1.0`
37/// for `0.0`, which would give a resting simulation a deceleration direction.
38fn signum_or_zero(value: f64) -> f64 {
39    if value == 0.0 { 0.0 } else { value.signum() }
40}
41
42/// Exponential drag decay: the iOS-style fling curve, and the friction phase of
43/// [`BouncingScrollSimulation`].
44///
45/// `drag` is the fraction of velocity surviving one second and must sit in
46/// `(0, 1)`. An optional constant deceleration subtracts a linear velocity term
47/// on top of the drag, for a caller that wants a fling to stop sooner than pure
48/// drag would; at `0.0` the curve is pure drag and only ever *approaches*
49/// [`FrictionSimulation::final_x`].
50#[derive(Debug, Clone)]
51pub struct FrictionSimulation {
52    drag: f64,
53    drag_log: f64,
54    position: f64,
55    velocity: f64,
56    /// Sign-corrected at construction so it always opposes `velocity`; `0.0`
57    /// when either input is zero.
58    constant_deceleration: f64,
59    /// When the constant-deceleration term cancels the exponential one and the
60    /// motion is over — [`f64::INFINITY`] under pure drag.
61    final_time: f64,
62    tolerance: Tolerance,
63}
64
65impl FrictionSimulation {
66    /// A drag curve from `position` at `velocity` px/s.
67    ///
68    /// `constant_deceleration` is `0.0` for pure drag; a nonzero value is
69    /// re-signed against `velocity` internally, so callers pass a magnitude.
70    pub fn new(
71        drag: f64,
72        position: f64,
73        velocity: f64,
74        tolerance: Tolerance,
75        constant_deceleration: f64,
76    ) -> Self {
77        let mut simulation = FrictionSimulation {
78            drag,
79            drag_log: drag.ln(),
80            position,
81            velocity,
82            constant_deceleration: constant_deceleration * signum_or_zero(velocity),
83            final_time: f64::INFINITY,
84            tolerance,
85        };
86        simulation.final_time = simulation.solve_final_time();
87        simulation
88    }
89
90    /// The drag curve that passes through both endpoints: at `start_x` moving
91    /// `start_velocity`, at `end_x` moving `end_velocity` (Flutter's
92    /// `FrictionSimulation.through`, whose `_dragFor` is the exponential
93    /// below). Settles within `end_velocity`, the speed the caller asked to
94    /// arrive at.
95    pub fn through(start_x: f64, end_x: f64, start_velocity: f64, end_velocity: f64) -> Self {
96        let drag = ((start_velocity - end_velocity) / (start_x - end_x)).exp();
97        FrictionSimulation::new(
98            drag,
99            start_x,
100            start_velocity,
101            Tolerance {
102                velocity: end_velocity.abs(),
103                distance: DEFAULT_DISTANCE_TOLERANCE,
104            },
105            0.0,
106        )
107    }
108
109    /// Where the curve ends up: the drag asymptote under pure drag, the
110    /// position at the final time once a constant deceleration stops it early.
111    pub fn final_x(&self) -> f64 {
112        if self.constant_deceleration == 0.0 {
113            self.position - self.velocity / self.drag_log
114        } else {
115            self.position_at(self.final_time)
116        }
117    }
118
119    /// The time at which the curve reaches `x`, or [`f64::INFINITY`] if it
120    /// never does (wrong side of the start, or beyond [`Self::final_x`]).
121    ///
122    /// Inverts the pure-drag position, so with a nonzero constant deceleration
123    /// the answer is an over-estimate of how far the curve actually gets — the
124    /// same approximation Flutter's `timeAtX` makes, and harmless for its one
125    /// caller ([`BouncingScrollSimulation`] finding its edge crossing).
126    pub fn time_at_x(&self, x: f64) -> f64 {
127        if x == self.position {
128            return 0.0;
129        }
130        let final_x = self.final_x();
131        let out_of_reach = if self.velocity > 0.0 {
132            x < self.position || x > final_x
133        } else {
134            x > self.position || x < final_x
135        };
136        if self.velocity == 0.0 || out_of_reach {
137            return f64::INFINITY;
138        }
139        ((x - self.position + self.velocity / self.drag_log) * self.drag_log / self.velocity).ln()
140            / self.drag_log
141    }
142
143    /// Position before the final-time clamp.
144    fn position_at(&self, time: f64) -> f64 {
145        let drag_term = self.position + self.velocity * self.drag.powf(time) / self.drag_log
146            - self.velocity / self.drag_log;
147        if self.constant_deceleration == 0.0 {
148            // Skipping the term rather than adding a zero: `time` is
149            // `INFINITY` under pure drag, and `0.0 * INFINITY` is NaN.
150            drag_term
151        } else {
152            drag_term - self.constant_deceleration / 2.0 * time * time
153        }
154    }
155
156    /// Velocity before the final-time clamp.
157    fn velocity_at(&self, time: f64) -> f64 {
158        let drag_term = self.velocity * self.drag.powf(time);
159        if self.constant_deceleration == 0.0 {
160            drag_term
161        } else {
162            drag_term - self.constant_deceleration * time
163        }
164    }
165
166    /// Bisect for the instant the constant-deceleration term cancels the
167    /// exponential one (velocity zero, motion over). `|v| / |a|` brackets that
168    /// root from above for any `drag < 1`: the linear term has reached `|v|`
169    /// there while the exponential one has decayed below it. A `drag` outside
170    /// `(0, 1)` never cancels, and reports no final time rather than looping.
171    fn solve_final_time(&self) -> f64 {
172        if self.constant_deceleration == 0.0 {
173            return f64::INFINITY;
174        }
175        let toward = signum_or_zero(self.velocity);
176        let mut lower = 0.0;
177        let mut upper = self.velocity.abs() / self.constant_deceleration.abs();
178        if self.velocity_at(upper) * toward > 0.0 {
179            return f64::INFINITY;
180        }
181        for _ in 0..64 {
182            let middle = 0.5 * (lower + upper);
183            if self.velocity_at(middle) * toward > 0.0 {
184                lower = middle;
185            } else {
186                upper = middle;
187            }
188        }
189        upper
190    }
191}
192
193impl Simulation for FrictionSimulation {
194    fn x(&self, time: f64) -> f64 {
195        self.position_at(time.min(self.final_time))
196    }
197
198    fn dx(&self, time: f64) -> f64 {
199        if time > self.final_time {
200            0.0
201        } else {
202            self.velocity_at(time)
203        }
204    }
205
206    /// Settled once the velocity is within tolerance — or, with a constant
207    /// deceleration, once that term has cancelled the drag term outright,
208    /// which a zero velocity tolerance (a [`Self::through`] curve asked to
209    /// arrive stopped) would otherwise never report.
210    fn is_done(&self, time: f64) -> bool {
211        time >= self.final_time || self.dx(time).abs() < self.tolerance.velocity
212    }
213}
214
215/// Which closed-form solution a [`SpringSimulation`] resolved to, decided by
216/// `damping² − 4·mass·stiffness`.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SpringType {
219    /// `damping² == 4·mass·stiffness` — returns in the shortest time that
220    /// still never crosses the end position.
221    Critical,
222    /// `damping² > 4·mass·stiffness` — slower than critical, no oscillation.
223    Overdamped,
224    /// `damping² < 4·mass·stiffness` — oscillates about the end position while
225    /// decaying.
226    Underdamped,
227}
228
229/// The solved coefficients of `m·ẍ + c·ẋ + k·x = 0` on the *displacement* from
230/// the end position, one variant per [`SpringType`].
231#[derive(Debug, Clone, Copy)]
232enum SpringSolution {
233    Critical { r: f64, c1: f64, c2: f64 },
234    Overdamped { r1: f64, r2: f64, c1: f64, c2: f64 },
235    Underdamped { w: f64, r: f64, c1: f64, c2: f64 },
236}
237
238impl SpringSolution {
239    fn new(spring: SpringDescription, distance: f64, velocity: f64) -> Self {
240        let discriminant = spring.damping * spring.damping - 4.0 * spring.mass * spring.stiffness;
241        if discriminant == 0.0 {
242            let r = -spring.damping / (2.0 * spring.mass);
243            SpringSolution::Critical {
244                r,
245                c1: distance,
246                c2: velocity - r * distance,
247            }
248        } else if discriminant > 0.0 {
249            let root = discriminant.sqrt();
250            let r1 = (-spring.damping - root) / (2.0 * spring.mass);
251            let r2 = (-spring.damping + root) / (2.0 * spring.mass);
252            let c2 = (velocity - r1 * distance) / (r2 - r1);
253            SpringSolution::Overdamped {
254                r1,
255                r2,
256                c1: distance - c2,
257                c2,
258            }
259        } else {
260            let w = (4.0 * spring.mass * spring.stiffness - spring.damping * spring.damping).sqrt()
261                / (2.0 * spring.mass);
262            // The analytically correct decay rate, `−c / 2m`.
263            let r = -spring.damping / (2.0 * spring.mass);
264            SpringSolution::Underdamped {
265                w,
266                r,
267                c1: distance,
268                c2: (velocity - r * distance) / w,
269            }
270        }
271    }
272
273    fn spring_type(&self) -> SpringType {
274        match self {
275            SpringSolution::Critical { .. } => SpringType::Critical,
276            SpringSolution::Overdamped { .. } => SpringType::Overdamped,
277            SpringSolution::Underdamped { .. } => SpringType::Underdamped,
278        }
279    }
280
281    /// Displacement from the end position at `time`.
282    fn x(&self, time: f64) -> f64 {
283        match *self {
284            SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * time) * (r * time).exp(),
285            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
286                c1 * (r1 * time).exp() + c2 * (r2 * time).exp()
287            }
288            SpringSolution::Underdamped { w, r, c1, c2 } => {
289                (r * time).exp() * (c1 * (w * time).cos() + c2 * (w * time).sin())
290            }
291        }
292    }
293
294    fn dx(&self, time: f64) -> f64 {
295        match *self {
296            SpringSolution::Critical { r, c1, c2 } => {
297                let decay = (r * time).exp();
298                r * (c1 + c2 * time) * decay + c2 * decay
299            }
300            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
301                c1 * r1 * (r1 * time).exp() + c2 * r2 * (r2 * time).exp()
302            }
303            SpringSolution::Underdamped { w, r, c1, c2 } => {
304                let decay = (r * time).exp();
305                let cosine = (w * time).cos();
306                let sine = (w * time).sin();
307                decay * (c2 * w * cosine - c1 * w * sine) + r * decay * (c2 * sine + c1 * cosine)
308            }
309        }
310    }
311}
312
313/// A damped spring settling from `start` onto `end`, solving
314/// `m·ẍ + c·ẋ + k·x = 0` on the displacement between them.
315#[derive(Debug, Clone)]
316pub struct SpringSimulation {
317    end: f64,
318    solution: SpringSolution,
319    tolerance: Tolerance,
320}
321
322impl SpringSimulation {
323    /// A spring released at `start` moving `velocity` px/s, pulling toward
324    /// `end`.
325    pub fn new(
326        spring: SpringDescription,
327        start: f64,
328        end: f64,
329        velocity: f64,
330        tolerance: Tolerance,
331    ) -> Self {
332        SpringSimulation {
333            end,
334            solution: SpringSolution::new(spring, start - end, velocity),
335            tolerance,
336        }
337    }
338
339    /// The position this spring settles onto.
340    pub fn end(&self) -> f64 {
341        self.end
342    }
343
344    /// Which solution class the spring's parameters resolved to.
345    pub fn spring_type(&self) -> SpringType {
346        self.solution.spring_type()
347    }
348}
349
350impl Simulation for SpringSimulation {
351    fn x(&self, time: f64) -> f64 {
352        self.end + self.solution.x(time)
353    }
354
355    fn dx(&self, time: f64) -> f64 {
356        self.solution.dx(time)
357    }
358
359    fn is_done(&self, time: f64) -> bool {
360        self.solution.x(time).abs() < self.tolerance.distance
361            && self.solution.dx(time).abs() < self.tolerance.velocity
362    }
363}
364
365/// A [`SpringSimulation`] that reports *exactly* `end` once settled, rather
366/// than the residual sub-tolerance displacement the closed form still carries.
367///
368/// The scroll surfaces' spring: a bounce-back must leave the position on the
369/// edge value it was springing to, not a fraction of a pixel off it, or the
370/// next gesture starts from a technically-overscrolled position.
371#[derive(Debug, Clone)]
372pub struct ScrollSpringSimulation {
373    inner: SpringSimulation,
374}
375
376impl ScrollSpringSimulation {
377    /// A spring released at `start` moving `velocity` px/s, pulling toward
378    /// `end`, snapping onto `end` once settled.
379    pub fn new(
380        spring: SpringDescription,
381        start: f64,
382        end: f64,
383        velocity: f64,
384        tolerance: Tolerance,
385    ) -> Self {
386        ScrollSpringSimulation {
387            inner: SpringSimulation::new(spring, start, end, velocity, tolerance),
388        }
389    }
390
391    /// The position this spring settles onto.
392    pub fn end(&self) -> f64 {
393        self.inner.end()
394    }
395
396    /// Which solution class the spring's parameters resolved to.
397    pub fn spring_type(&self) -> SpringType {
398        self.inner.spring_type()
399    }
400}
401
402impl Simulation for ScrollSpringSimulation {
403    fn x(&self, time: f64) -> f64 {
404        if self.inner.is_done(time) {
405            self.inner.end()
406        } else {
407            self.inner.x(time)
408        }
409    }
410
411    fn dx(&self, time: f64) -> f64 {
412        self.inner.dx(time)
413    }
414
415    fn is_done(&self, time: f64) -> bool {
416        self.inner.is_done(time)
417    }
418}
419
420/// Android's fling curve — the `SplineOverScroller` deceleration a
421/// clamping scroll surface runs after a release, which decays to a hard stop
422/// at a known duration and distance rather than an asymptote.
423#[derive(Debug, Clone)]
424pub struct ClampingScrollSimulation {
425    position: f64,
426    velocity: f64,
427    /// Seconds until the curve stops.
428    duration: f64,
429    /// Total signed travel over `duration`.
430    distance: f64,
431    tolerance: Tolerance,
432}
433
434impl ClampingScrollSimulation {
435    /// Android `ViewConfiguration`'s default scroll friction.
436    pub const DEFAULT_FRICTION: f64 = 0.015;
437
438    /// Android `SplineOverScroller.INFLEXION`, where the fling spline's two
439    /// tension segments cross, and the velocity scale in the deceleration term
440    /// below.
441    pub const INFLEXION: f64 = 0.35;
442
443    /// Android's `mPhysicalCoeff`: gravity (m/s²) × inches per meter × dpi ×
444    /// an empirical tuning factor. The dpi term is fixed at 160 — one Android
445    /// density-independent pixel, which is what this crate's logical pixel is.
446    pub const PHYSICAL_COEFF: f64 = 9.80665 * 39.37 * 160.0 * 0.84;
447
448    /// Android's `SplineOverScroller.DECELERATION_RATE` (≈ 2.358202).
449    ///
450    /// Computed from Android's own expression rather than transcribed as a
451    /// decimal; a function rather than a `const` because [`f64::ln`] is not
452    /// `const`.
453    #[inline]
454    pub fn deceleration_rate() -> f64 {
455        0.78_f64.ln() / 0.9_f64.ln()
456    }
457
458    /// A fling from `position` at `velocity` px/s under `friction`
459    /// ([`Self::DEFAULT_FRICTION`] for the platform default).
460    pub fn new(position: f64, velocity: f64, friction: f64, tolerance: Tolerance) -> Self {
461        let deceleration =
462            (Self::INFLEXION * velocity.abs() / (friction * Self::PHYSICAL_COEFF)).ln();
463        let duration = (deceleration / (Self::deceleration_rate() - 1.0)).exp();
464        ClampingScrollSimulation {
465            position,
466            velocity,
467            duration,
468            distance: velocity * duration / Self::deceleration_rate(),
469            tolerance,
470        }
471    }
472
473    /// Seconds until the fling stops.
474    pub fn duration(&self) -> f64 {
475        self.duration
476    }
477
478    /// Where the fling stops.
479    pub fn final_x(&self) -> f64 {
480        self.position + self.distance
481    }
482
483    /// Fraction of the curve elapsed, clamped into `[0, 1]`. A zero-velocity
484    /// fling has no duration at all and reads as already over, rather than
485    /// dividing by zero.
486    fn progress(&self, time: f64) -> f64 {
487        if self.duration > 0.0 {
488            (time / self.duration).clamp(0.0, 1.0)
489        } else {
490            1.0
491        }
492    }
493}
494
495impl Simulation for ClampingScrollSimulation {
496    fn x(&self, time: f64) -> f64 {
497        let remaining = 1.0 - self.progress(time);
498        self.position + self.distance * (1.0 - remaining.powf(Self::deceleration_rate()))
499    }
500
501    fn dx(&self, time: f64) -> f64 {
502        let remaining = 1.0 - self.progress(time);
503        self.velocity * remaining.powf(Self::deceleration_rate() - 1.0)
504    }
505
506    fn is_done(&self, time: f64) -> bool {
507        time >= self.duration || self.dx(time).abs() < self.tolerance.velocity
508    }
509}
510
511/// Which curve a [`BouncingScrollSimulation`] is running, and when it hands
512/// over.
513#[derive(Debug, Clone)]
514enum BouncingPhase {
515    /// Released already outside the range: the edge spring owns the whole
516    /// motion.
517    Spring(ScrollSpringSimulation),
518    /// Released in range, decaying to a stop before either edge.
519    Friction(FrictionSimulation),
520    /// Released in range on course to hit an edge: friction until
521    /// `spring_time`, the edge spring (started at that instant) after it.
522    FrictionThenSpring {
523        friction: FrictionSimulation,
524        spring: ScrollSpringSimulation,
525        spring_time: f64,
526    },
527}
528
529/// iOS's fling: exponential friction while the position is in range, handing
530/// over to a rubber-band spring at whichever edge the fling reaches.
531#[derive(Debug, Clone)]
532pub struct BouncingScrollSimulation {
533    phase: BouncingPhase,
534}
535
536impl BouncingScrollSimulation {
537    /// The fastest velocity handed to an edge spring. A fling arriving faster
538    /// transfers this instead, so an arbitrarily hard flick cannot turn into an
539    /// arbitrarily deep bounce.
540    pub const MAX_SPRING_TRANSFER_VELOCITY: f64 = 5000.0;
541
542    /// The friction phase's drag: `UIScrollView`'s normal deceleration rate is
543    /// 0.998 per millisecond, and `0.998^1000 ≈ 0.135` per second.
544    pub const FRICTION_DRAG: f64 = 0.135;
545
546    /// A fling from `position` at `velocity` px/s within
547    /// `leading_extent..=trailing_extent`, bouncing off whichever edge it
548    /// reaches (or starting mid-bounce if `position` is already outside).
549    pub fn new(
550        position: f64,
551        velocity: f64,
552        leading_extent: f64,
553        trailing_extent: f64,
554        spring: SpringDescription,
555        tolerance: Tolerance,
556        constant_deceleration: f64,
557    ) -> Self {
558        debug_assert!(
559            leading_extent <= trailing_extent,
560            "leading extent {leading_extent} must not exceed trailing extent {trailing_extent}"
561        );
562        let phase = if position < leading_extent {
563            BouncingPhase::Spring(Self::edge_spring(
564                spring,
565                position,
566                leading_extent,
567                velocity,
568                tolerance,
569            ))
570        } else if position > trailing_extent {
571            BouncingPhase::Spring(Self::edge_spring(
572                spring,
573                position,
574                trailing_extent,
575                velocity,
576                tolerance,
577            ))
578        } else {
579            let friction = FrictionSimulation::new(
580                Self::FRICTION_DRAG,
581                position,
582                velocity,
583                tolerance,
584                constant_deceleration,
585            );
586            let heading_for = if velocity > 0.0 {
587                trailing_extent
588            } else if velocity < 0.0 {
589                leading_extent
590            } else {
591                // A release with no velocity in range never reaches an edge.
592                return BouncingScrollSimulation {
593                    phase: BouncingPhase::Friction(friction),
594                };
595            };
596            // Infinite means the friction curve stops short of that edge.
597            let spring_time = friction.time_at_x(heading_for);
598            if spring_time.is_finite() {
599                let transfer = friction.dx(spring_time);
600                BouncingPhase::FrictionThenSpring {
601                    spring: Self::edge_spring(
602                        spring,
603                        heading_for,
604                        heading_for,
605                        transfer,
606                        tolerance,
607                    ),
608                    friction,
609                    spring_time,
610                }
611            } else {
612                BouncingPhase::Friction(friction)
613            }
614        };
615        BouncingScrollSimulation { phase }
616    }
617
618    /// The rubber-band spring back onto `extent`, its seed velocity clamped to
619    /// ±[`Self::MAX_SPRING_TRANSFER_VELOCITY`].
620    fn edge_spring(
621        spring: SpringDescription,
622        start: f64,
623        extent: f64,
624        velocity: f64,
625        tolerance: Tolerance,
626    ) -> ScrollSpringSimulation {
627        ScrollSpringSimulation::new(
628            spring,
629            start,
630            extent,
631            velocity.clamp(
632                -Self::MAX_SPRING_TRANSFER_VELOCITY,
633                Self::MAX_SPRING_TRANSFER_VELOCITY,
634            ),
635            tolerance,
636        )
637    }
638
639    /// The curve owning `time`, plus the offset to shift `time` by before
640    /// querying it (a spring handed over mid-fling starts its own clock at the
641    /// handover).
642    fn active(&self, time: f64) -> (&dyn Simulation, f64) {
643        match &self.phase {
644            BouncingPhase::Spring(spring) => (spring, 0.0),
645            BouncingPhase::Friction(friction) => (friction, 0.0),
646            BouncingPhase::FrictionThenSpring {
647                friction,
648                spring,
649                spring_time,
650            } => {
651                if time >= *spring_time {
652                    (spring, *spring_time)
653                } else {
654                    (friction, 0.0)
655                }
656            }
657        }
658    }
659}
660
661impl Simulation for BouncingScrollSimulation {
662    fn x(&self, time: f64) -> f64 {
663        let (simulation, offset) = self.active(time);
664        simulation.x(time - offset)
665    }
666
667    fn dx(&self, time: f64) -> f64 {
668        let (simulation, offset) = self.active(time);
669        simulation.dx(time - offset)
670    }
671
672    fn is_done(&self, time: f64) -> bool {
673        let (simulation, offset) = self.active(time);
674        simulation.is_done(time - offset)
675    }
676}
677
678/// An eased tween from `start` to `end` over a fixed `duration` (seconds) —
679/// see the module docs. Not a port: frust's own, built from [`Curve`] rather
680/// than a Flutter source.
681#[derive(Debug, Clone, Copy)]
682pub struct TweenSimulation {
683    start: f64,
684    end: f64,
685    /// `<= 0.0` (including a non-finite input, floored by
686    /// [`TweenSimulation::new`]) means "immediately done at `end`" rather
687    /// than a division by zero.
688    duration: f64,
689    curve: Curve,
690}
691
692impl TweenSimulation {
693    /// A tween from `start` to `end` over `duration` seconds, eased by
694    /// `curve`. A non-positive or non-finite `duration` (`0.0`, negative, or
695    /// `NaN`, each a caller mistake this never panics on) collapses to a
696    /// simulation that is immediately [`TweenSimulation::is_done`] at `end` —
697    /// callers that actually want an instant move should prefer
698    /// [`crate::scroll_controller::ScrollController::jump_to`], which never
699    /// constructs a simulation at all.
700    pub fn new(start: f64, end: f64, duration: f64, curve: Curve) -> Self {
701        Self {
702            start,
703            end,
704            duration: if duration.is_finite() {
705                duration.max(0.0)
706            } else {
707                0.0
708            },
709            curve,
710        }
711    }
712}
713
714impl Simulation for TweenSimulation {
715    fn x(&self, time: f64) -> f64 {
716        if self.duration <= 0.0 {
717            return self.end;
718        }
719        let t = (time / self.duration).clamp(0.0, 1.0);
720        self.start + (self.end - self.start) * self.curve.transform(t)
721    }
722
723    fn dx(&self, time: f64) -> f64 {
724        if self.duration <= 0.0 || time < 0.0 || time >= self.duration {
725            return 0.0;
726        }
727        // A numeric derivative: cheap, and the only option for an arbitrary
728        // cubic-Bézier `Curve` with no closed-form velocity. Only
729        // `ScrollWidget::ballistic_is_pinned_outward`'s sign check and a
730        // carried-momentum read (if a fling starts mid-tween) ever read this,
731        // neither of which needs analytic precision.
732        const DT: f64 = 1e-4;
733        let clamped_time = time.min(self.duration - DT);
734        (self.x(clamped_time + DT) - self.x(clamped_time)) / DT
735    }
736
737    fn is_done(&self, time: f64) -> bool {
738        self.duration <= 0.0 || time >= self.duration
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    /// A 1.0-dpr device tolerance: 20 px/s, 1 px.
747    fn tol() -> Tolerance {
748        Tolerance::for_device_pixel_ratio(1.0)
749    }
750
751    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
752        assert!(
753            (actual - expected).abs() < epsilon,
754            "{what}: {actual} is not within {epsilon} of {expected}"
755        );
756    }
757
758    #[test]
759    fn friction_final_x_matches_closed_form() {
760        let simulation = FrictionSimulation::new(0.135, 20.0, 1000.0, tol(), 0.0);
761        let expected = 20.0 - 1000.0 / 0.135_f64.ln();
762        assert_close(simulation.final_x(), expected, 1e-9, "final_x");
763        assert_close(simulation.x(f64::INFINITY), expected, 1e-9, "x at infinity");
764        assert_close(simulation.x(20.0), expected, 1e-6, "x long after release");
765        assert!(!simulation.is_done(0.0), "a live fling reported settled");
766        assert!(simulation.is_done(20.0), "a decayed fling never settled");
767    }
768
769    #[test]
770    fn friction_through_passes_endpoints() {
771        let simulation = FrictionSimulation::through(0.0, 100.0, 500.0, 100.0);
772        let arrival = simulation.time_at_x(100.0);
773        assert!(arrival.is_finite(), "end position unreachable at {arrival}");
774        assert_close(simulation.x(0.0), 0.0, 1e-9, "start position");
775        assert_close(simulation.dx(0.0), 500.0, 1e-9, "start velocity");
776        assert_close(simulation.x(arrival), 100.0, 1e-9, "end position");
777        assert_close(simulation.dx(arrival), 100.0, 1e-9, "end velocity");
778
779        // Arriving stopped is the asymptote case: the curve only approaches the
780        // end position, so `through` puts it exactly at `final_x`.
781        let asymptotic = FrictionSimulation::through(0.0, 100.0, 500.0, 0.0);
782        assert_close(asymptotic.final_x(), 100.0, 1e-9, "asymptotic final_x");
783        assert!(
784            asymptotic.time_at_x(200.0).is_infinite(),
785            "a position past final_x must be unreachable"
786        );
787        assert!(
788            asymptotic.time_at_x(-10.0).is_infinite(),
789            "a position behind the start must be unreachable"
790        );
791    }
792
793    #[test]
794    fn friction_velocity_decays_exponentially() {
795        let simulation = FrictionSimulation::new(0.135, 0.0, 800.0, tol(), 0.0);
796        let ratio = simulation.dx(0.5) / simulation.dx(0.0);
797        assert_close(ratio, 0.135_f64.powf(0.5), 1e-9, "half-second decay ratio");
798    }
799
800    #[test]
801    fn friction_constant_deceleration_ends_the_motion() {
802        let plain = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 0.0);
803        let decelerated = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 3000.0);
804        assert!(
805            decelerated.final_x() < plain.final_x(),
806            "constant deceleration travelled {}, no further than pure drag's {}",
807            decelerated.final_x(),
808            plain.final_x()
809        );
810        assert!(decelerated.is_done(2.0), "the motion never ended");
811        assert_eq!(decelerated.dx(5.0), 0.0, "velocity past the end");
812        assert_close(
813            decelerated.x(5.0),
814            decelerated.final_x(),
815            1e-9,
816            "position past the end",
817        );
818    }
819
820    #[test]
821    fn spring_critical_over_under_damped_all_converge_to_end() {
822        // `damping² − 4·mass·stiffness` picks the class: 400 − 400 == 0,
823        // 900 − 400 > 0, 25 − 400 < 0.
824        let cases = [
825            (20.0, SpringType::Critical),
826            (30.0, SpringType::Overdamped),
827            (5.0, SpringType::Underdamped),
828        ];
829        for (damping, expected_type) in cases {
830            let spring = SpringDescription {
831                mass: 1.0,
832                stiffness: 100.0,
833                damping,
834            };
835            let simulation = SpringSimulation::new(spring, 100.0, 0.0, 0.0, tol());
836            assert_eq!(simulation.spring_type(), expected_type);
837            assert_close(simulation.x(0.0), 100.0, 1e-9, "start position");
838            assert_close(simulation.dx(0.0), 0.0, 1e-9, "start velocity");
839            assert_close(simulation.x(10.0), 0.0, 1e-6, "converged position");
840            assert!(
841                !simulation.is_done(0.0),
842                "{expected_type:?} spring reported settled at release"
843            );
844            assert!(
845                simulation.is_done(10.0),
846                "{expected_type:?} spring never settled"
847            );
848        }
849    }
850
851    #[test]
852    fn spring_with_damping_ratio_1_1_is_overdamped_no_oscillation() {
853        let simulation = SpringSimulation::new(
854            SpringDescription::default_scroll_spring(),
855            100.0,
856            0.0,
857            0.0,
858            tol(),
859        );
860        assert_eq!(simulation.spring_type(), SpringType::Overdamped);
861        for step in 0..=2000 {
862            let time = f64::from(step) / 1000.0;
863            let displacement = simulation.x(time) - simulation.end();
864            assert!(
865                displacement >= 0.0,
866                "crossed the end position at t={time}s (displacement {displacement})"
867            );
868        }
869    }
870
871    #[test]
872    fn scroll_spring_snaps_to_end_once_settled() {
873        let spring = SpringDescription::default_scroll_spring();
874        let plain = SpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
875        let snapping = ScrollSpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
876
877        // Live: the two agree exactly.
878        assert_eq!(snapping.x(0.05), plain.x(0.05));
879        assert_eq!(snapping.dx(0.05), plain.dx(0.05));
880
881        // Settled: the plain spring still carries a sub-tolerance residual, the
882        // scroll spring reports the edge itself.
883        assert!(snapping.is_done(3.0));
884        assert_ne!(plain.x(3.0), 0.0);
885        assert_eq!(snapping.x(3.0), snapping.end());
886    }
887
888    #[test]
889    fn clamping_deceleration_rate_value() {
890        assert_close(
891            ClampingScrollSimulation::deceleration_rate(),
892            2.358_202,
893            1e-5,
894            "DECELERATION_RATE",
895        );
896        assert_close(
897            ClampingScrollSimulation::PHYSICAL_COEFF,
898            51_890.2,
899            0.1,
900            "PHYSICAL_COEFF",
901        );
902        assert_eq!(ClampingScrollSimulation::INFLEXION, 0.35);
903        assert_eq!(ClampingScrollSimulation::DEFAULT_FRICTION, 0.015);
904    }
905
906    #[test]
907    fn clamping_dx_at_zero_equals_initial_velocity() {
908        for velocity in [2000.0, -2000.0, 350.0] {
909            let simulation = ClampingScrollSimulation::new(
910                0.0,
911                velocity,
912                ClampingScrollSimulation::DEFAULT_FRICTION,
913                tol(),
914            );
915            assert_close(simulation.dx(0.0), velocity, 1e-9, "dx at release");
916        }
917    }
918
919    #[test]
920    fn clamping_stops_at_duration() {
921        let simulation = ClampingScrollSimulation::new(
922            10.0,
923            2000.0,
924            ClampingScrollSimulation::DEFAULT_FRICTION,
925            tol(),
926        );
927        let duration = simulation.duration();
928        let target = simulation.final_x();
929        assert!(
930            duration > 0.0 && duration.is_finite(),
931            "implausible duration {duration}"
932        );
933        assert!(target > 10.0, "a positive fling must travel forward");
934        assert!(!simulation.is_done(0.0), "a live fling reported settled");
935        assert!(simulation.is_done(duration), "the fling never stopped");
936        assert_close(simulation.x(duration), target, 1e-9, "position at duration");
937        assert_close(
938            simulation.x(duration * 2.0),
939            target,
940            1e-9,
941            "position past duration",
942        );
943        assert_close(simulation.dx(duration), 0.0, 1e-9, "velocity at duration");
944
945        let mut previous = simulation.x(0.0);
946        for step in 1..=100 {
947            let position = simulation.x(duration * f64::from(step) / 100.0);
948            assert!(position >= previous, "backtracked to {position}");
949            assert!(position <= target + 1e-9, "overshot the target: {position}");
950            previous = position;
951        }
952    }
953
954    #[test]
955    fn bouncing_switches_friction_to_spring_at_extent() {
956        let trailing = 100.0;
957        let simulation = BouncingScrollSimulation::new(
958            0.0,
959            2000.0,
960            0.0,
961            trailing,
962            SpringDescription::default_scroll_spring(),
963            tol(),
964            0.0,
965        );
966        let friction = FrictionSimulation::new(
967            BouncingScrollSimulation::FRICTION_DRAG,
968            0.0,
969            2000.0,
970            tol(),
971            0.0,
972        );
973        let handover = friction.time_at_x(trailing);
974        assert!(handover.is_finite(), "the fling must reach the extent");
975
976        // Friction phase: identical to the standalone friction curve.
977        let early = handover / 2.0;
978        assert_eq!(simulation.x(early), friction.x(early));
979        assert_eq!(simulation.dx(early), friction.dx(early));
980
981        // Handover: the spring starts at the extent carrying the friction
982        // curve's velocity.
983        assert_close(
984            simulation.x(handover),
985            trailing,
986            1e-9,
987            "position at handover",
988        );
989        assert_close(
990            simulation.dx(handover),
991            friction.dx(handover),
992            1e-9,
993            "velocity at handover",
994        );
995
996        // Spring phase: bounded overshoot, then back onto the extent.
997        let mut furthest = f64::MIN;
998        for step in 0..=500 {
999            furthest = furthest.max(simulation.x(handover + f64::from(step) / 100.0));
1000        }
1001        assert!(
1002            furthest > trailing,
1003            "the bounce never passed the extent (peaked at {furthest})"
1004        );
1005        assert!(
1006            furthest < trailing + 60.0,
1007            "the bounce ran away past the extent (peaked at {furthest})"
1008        );
1009        assert_close(
1010            simulation.x(handover + 5.0),
1011            trailing,
1012            1e-9,
1013            "settled position",
1014        );
1015        assert!(
1016            simulation.is_done(handover + 5.0),
1017            "the bounce never settled"
1018        );
1019    }
1020
1021    #[test]
1022    fn bouncing_stays_friction_when_no_extent_is_reached() {
1023        let simulation = BouncingScrollSimulation::new(
1024            0.0,
1025            100.0,
1026            0.0,
1027            10_000.0,
1028            SpringDescription::default_scroll_spring(),
1029            tol(),
1030            0.0,
1031        );
1032        let friction = FrictionSimulation::new(
1033            BouncingScrollSimulation::FRICTION_DRAG,
1034            0.0,
1035            100.0,
1036            tol(),
1037            0.0,
1038        );
1039        assert!(
1040            friction.time_at_x(10_000.0).is_infinite(),
1041            "this fling must stop short of the extent for the test to mean anything"
1042        );
1043        for step in 0..=100 {
1044            let time = f64::from(step) / 10.0;
1045            assert_eq!(simulation.x(time), friction.x(time));
1046            assert_eq!(simulation.dx(time), friction.dx(time));
1047        }
1048    }
1049
1050    #[test]
1051    fn bouncing_underscroll_starts_as_spring() {
1052        let leading = 0.0;
1053        let simulation = BouncingScrollSimulation::new(
1054            -50.0,
1055            0.0,
1056            leading,
1057            100.0,
1058            SpringDescription::default_scroll_spring(),
1059            tol(),
1060            0.0,
1061        );
1062        assert_close(simulation.x(0.0), -50.0, 1e-9, "starts where released");
1063        assert!(
1064            simulation.x(0.1) > simulation.x(0.0),
1065            "must travel back toward the leading extent"
1066        );
1067        assert!(!simulation.is_done(0.0), "an overscrolled rest is not done");
1068        assert_close(simulation.x(3.0), leading, 1e-9, "settled position");
1069        assert!(simulation.is_done(3.0), "the bounce-back never settled");
1070    }
1071
1072    #[test]
1073    fn bouncing_spring_transfer_velocity_capped() {
1074        let spring = SpringDescription::default_scroll_spring();
1075        let cap = BouncingScrollSimulation::MAX_SPRING_TRANSFER_VELOCITY;
1076
1077        // Released already past the leading edge, absurdly fast.
1078        let underscroll =
1079            BouncingScrollSimulation::new(-50.0, -20_000.0, 0.0, 100.0, spring, tol(), 0.0);
1080        assert_close(underscroll.dx(0.0), -cap, 1e-9, "clamped underscroll seed");
1081
1082        // Reaching the trailing edge under friction faster than the cap.
1083        let fling = BouncingScrollSimulation::new(0.0, 50_000.0, 0.0, 100.0, spring, tol(), 0.0);
1084        let friction = FrictionSimulation::new(
1085            BouncingScrollSimulation::FRICTION_DRAG,
1086            0.0,
1087            50_000.0,
1088            tol(),
1089            0.0,
1090        );
1091        let handover = friction.time_at_x(100.0);
1092        assert!(
1093            friction.dx(handover) > cap,
1094            "the fling must arrive above the cap for the test to mean anything"
1095        );
1096        assert_close(fling.dx(handover), cap, 1e-9, "clamped handover seed");
1097    }
1098
1099    #[test]
1100    fn tween_reaches_the_target_exactly_at_its_duration() {
1101        let tween = TweenSimulation::new(0.0, 100.0, 0.5, Curve::Linear);
1102        assert_close(tween.x(0.0), 0.0, 1e-9, "starts at the source position");
1103        assert_close(tween.x(0.25), 50.0, 1e-9, "linear curve, halfway in time");
1104        assert!(!tween.is_done(0.25));
1105        assert_close(tween.x(0.5), 100.0, 1e-9, "lands exactly on the target");
1106        assert!(tween.is_done(0.5));
1107        // Past its own duration the position holds at the target rather than
1108        // extrapolating past it.
1109        assert_close(tween.x(1.0), 100.0, 1e-9, "clamped past the duration");
1110        assert!(tween.is_done(1.0));
1111    }
1112
1113    #[test]
1114    fn tween_velocity_is_positive_mid_flight_and_zero_once_done() {
1115        let tween = TweenSimulation::new(0.0, 100.0, 1.0, Curve::EaseInOut);
1116        assert!(
1117            tween.dx(0.5) > 0.0,
1118            "moving from start to end, mid-flight velocity is positive"
1119        );
1120        assert_eq!(tween.dx(1.0), 0.0, "settled — no velocity once done");
1121        assert_eq!(tween.dx(-1.0), 0.0, "never queried before its own start");
1122    }
1123
1124    #[test]
1125    fn tween_non_positive_duration_is_immediately_done() {
1126        // A `0.0`/negative/NaN duration never divides by zero — it is
1127        // immediately done at `t = 0.0`, at the end position.
1128        for duration in [0.0, -5.0, f64::NAN] {
1129            let tween = TweenSimulation::new(10.0, 20.0, duration, Curve::Linear);
1130            assert_close(tween.x(0.0), 20.0, 1e-9, "immediately at the target");
1131            assert!(tween.is_done(0.0));
1132        }
1133    }
1134}