Skip to main content

frust_widgets/physics/
parity.rs

1//! The platform-parity physics: ports of Flutter's `BouncingScrollPhysics`
2//! (iOS), `ClampingScrollPhysics` (Android), `AlwaysScrollableScrollPhysics`
3//! and `NeverScrollableScrollPhysics` from `widgets/scroll_physics.dart`,
4//! driving the simulations in [`super::simulation`].
5//!
6//! Every tuning constant is Flutter's own, stated as the expression its source
7//! states it as and pinned by a test below. The two behavioral ones — the
8//! bouncing friction curve and the clamping hard stop — are what make a fling
9//! land where the platform's own would.
10//!
11//! # How a drag reaches [`Bouncing`]
12//!
13//! Flutter feeds `applyPhysicsToUserOffset` a *per-frame delta* against a
14//! position that may itself be out of range, so its friction tightens as the
15//! pull deepens. Both frust scroll surfaces now use that same convention
16//! ([`crate::scroll::ScrollWidget`]'s drag path, its module docs' *Drag
17//! convention*): each `Move` hands over its own raw finger delta with metrics
18//! reporting the live position, displacement and all. [`Bouncing`] derives the
19//! resisted portion from the metrics it is handed rather than assuming a
20//! convention, so what it reads is a real depth and the factor it applies
21//! genuinely tightens from [`DecelerationRate::NORMAL_FRICTION`] at the edge
22//! toward zero a viewport out.
23//!
24//! # Why [`Clamping`] needs no clamped simulation adapter
25//!
26//! Its ballistic curve ([`ClampingScrollSimulation`]) is free to run far past
27//! an edge — Android's spline knows nothing about extents. Nothing here clamps
28//! it, because the widget's generic ballistic driver subtracts what
29//! [`ScrollPhysics::apply_boundary_conditions`] rejects from *every* simulation
30//! position it paints (`ScrollWidget`'s `drive_ballistic`), and this physics
31//! rejects the whole excess — so the painted offset stops dead at the edge
32//! while the raw curve keeps going. `clamping_ballistic_returns_android_curve_sim`
33//! re-derives that contract in-file so a driver change cannot silently break it.
34//!
35//! # Deviations from the Dart source
36//!
37//! Four, each narrow and deliberate:
38//!
39//! * **Easing back out of an overscroll is unresisted at both rates.** Flutter
40//!   returns the delta untouched only at [`DecelerationRate::Fast`]; at
41//!   `Normal` it still applies a reduced-depth friction factor. One rule for
42//!   both rates keeps the asymmetry that carries the feel (resist deepening,
43//!   never resist returning) without the second curve.
44//! * **[`Clamping::apply_boundary_conditions`] is the two-branch form** (past
45//!   an extent → reject the whole excess) rather than Flutter's four-branch
46//!   one. The two differ only for a position that is *already* out of range,
47//!   which this physics never produces: there, Flutter rejects only further
48//!   outward travel and lets the position linger outside, while this form pulls
49//!   it back to the extent on the next proposal.
50//! * **A fling starts at [`ScrollPhysics::min_fling_velocity`], not at the
51//!   tolerance velocity.** Flutter gates `createBallisticSimulation` on
52//!   `tolerance.velocity` (~20 px/s at 1.0 dpr). The surfaces here already zero
53//!   any release below the physics' own minimum before asking, so the two gates
54//!   answer identically at that seam; stating the real threshold is honest
55//!   about what a direct caller gets.
56//! * **[`Clamping`]'s out-of-range spring keeps the release velocity.** Flutter
57//!   passes `min(0.0, velocity)`, which is only meaningful at a trailing edge;
58//!   the actual velocity is symmetric and is what the bouncing port already
59//!   does.
60
61use super::simulation::{
62    BouncingScrollSimulation, ClampingScrollSimulation, ScrollSpringSimulation,
63};
64use super::{ScrollMetrics, ScrollPhysics, Simulation, SpringDescription};
65
66/// How quickly a [`Bouncing`] surface's fling decays — Flutter's
67/// `ScrollDecelerationRate`.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum DecelerationRate {
70    /// iOS's own rate: a long, low-friction glide with no constant
71    /// deceleration term and the default edge spring.
72    #[default]
73    Normal,
74    /// A shorter, snappier glide: half the overscroll friction, a stiffer edge
75    /// spring, and a constant deceleration on top of the drag curve.
76    Fast,
77}
78
79impl DecelerationRate {
80    /// The overscroll friction factor at zero depth for
81    /// [`DecelerationRate::Normal`] — the `0.52` in Flutter's
82    /// `frictionFactor`.
83    pub const NORMAL_FRICTION: f64 = 0.52;
84
85    /// The same factor for [`DecelerationRate::Fast`]: half as much of a
86    /// past-edge pull reaches the position.
87    pub const FAST_FRICTION: f64 = 0.26;
88
89    /// The constant deceleration (px/s²) [`DecelerationRate::Fast`] adds to its
90    /// fling's drag curve, which is what stops it early instead of letting it
91    /// approach an asymptote. `Normal` adds none.
92    pub const FAST_CONSTANT_DECELERATION: f64 = 1400.0;
93
94    /// The factor a past-edge pull is scaled by at zero overscroll depth.
95    fn friction_coefficient(self) -> f64 {
96        match self {
97            DecelerationRate::Normal => Self::NORMAL_FRICTION,
98            DecelerationRate::Fast => Self::FAST_FRICTION,
99        }
100    }
101
102    /// The constant deceleration term this rate's ballistic curve carries.
103    fn constant_deceleration(self) -> f64 {
104        match self {
105            DecelerationRate::Normal => 0.0,
106            DecelerationRate::Fast => Self::FAST_CONSTANT_DECELERATION,
107        }
108    }
109
110    /// The edge spring this rate bounces back on.
111    ///
112    /// `Normal` has no spring of its own in the Dart source —
113    /// `BouncingScrollPhysics.spring` overrides only the fast case, so the
114    /// normal one inherits `ScrollPhysics.spring`, i.e. `_kDefaultSpring`
115    /// ([`SpringDescription::default_scroll_spring`], mass 0.5 / stiffness 100
116    /// / damping ratio 1.1). A mass 0.3 / stiffness 100 / ratio 0.7 pairing
117    /// circulates for it second-hand; the inheritance above is the primary
118    /// source and refutes it.
119    fn spring(self) -> SpringDescription {
120        match self {
121            DecelerationRate::Normal => SpringDescription::default_scroll_spring(),
122            DecelerationRate::Fast => SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
123        }
124    }
125}
126
127/// Split out-of-range travel between two signed past-edge displacements into
128/// the part that eases back toward the range and the part that deepens the
129/// overscroll. Both terms are signed like the travel itself and sum to
130/// `end − start`.
131fn split_overscroll_travel(start: f64, end: f64) -> (f64, f64) {
132    if start * end < 0.0 {
133        // Off one edge and past the other in a single delta: all the way back
134        // in first, then the whole of the new excursion is tensioning.
135        (-start, end)
136    } else if end.abs() >= start.abs() {
137        (0.0, end - start)
138    } else {
139        (end - start, 0.0)
140    }
141}
142
143/// iOS's scroll feel — Flutter's `BouncingScrollPhysics`: a drag may pull the
144/// position past an edge against a friction factor that tightens with depth,
145/// nothing is ever boundary-rejected, and a release runs an exponential
146/// friction curve that hands over to a rubber-band spring at whichever edge it
147/// reaches.
148///
149/// See the [module docs](self) for the drag convention the surfaces hand this
150/// curve its depth through, and for the deviations from the Dart source.
151#[derive(Debug, Default)]
152pub struct Bouncing {
153    rate: DecelerationRate,
154    parent: Option<Box<dyn ScrollPhysics>>,
155}
156
157impl Bouncing {
158    /// Flutter's `BouncingScrollPhysics.minFlingVelocity`: twice
159    /// [`super::MIN_FLING_VELOCITY`], because this physics' ballistic curve
160    /// decelerates more slowly than the clamping one, so a fling has to be a
161    /// more deliberate flick to be worth starting.
162    pub const MIN_FLING_VELOCITY: f64 = super::MIN_FLING_VELOCITY * 2.0;
163
164    /// The scale of Flutter's power-curve fit for momentum carried into a
165    /// re-fling (`carriedMomentum`), fitted against superimposed platform
166    /// scroll views rather than derived.
167    const MOMENTUM_COEFFICIENT: f64 = 0.000_816;
168
169    /// That fit's exponent.
170    const MOMENTUM_EXPONENT: f64 = 1.967;
171
172    /// The most momentum (px/s) a re-fling can carry over, so a fast enough
173    /// existing motion cannot compound without bound.
174    const MOMENTUM_CAP: f64 = 40_000.0;
175
176    /// A standalone bouncing physics at [`DecelerationRate::Normal`].
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// A standalone bouncing physics at `rate`.
182    pub fn with_rate(rate: DecelerationRate) -> Self {
183        Self { rate, parent: None }
184    }
185
186    /// Chain `parent` behind this physics, the module-wide composition
187    /// convention ([`ScrollPhysics`]' *Chaining*).
188    pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
189        Self {
190            rate: self.rate,
191            parent: Some(Box::new(parent)),
192        }
193    }
194
195    /// The fraction of a past-edge pull that reaches the position at an
196    /// overscroll of `overscroll_fraction` viewports — Flutter's
197    /// `frictionFactor`: `0.52·(1 − f)²` at [`DecelerationRate::Normal`],
198    /// `0.26·(1 − f)²` at [`DecelerationRate::Fast`].
199    pub fn friction_factor(&self, overscroll_fraction: f64) -> f64 {
200        let remaining = 1.0 - overscroll_fraction;
201        self.rate.friction_coefficient() * remaining * remaining
202    }
203
204    /// How deep the metrics' position already sits past an edge, as a fraction
205    /// of the viewport — the argument to [`Self::friction_factor`]. A surface
206    /// with no viewport yet reports `0.0` rather than dividing by zero.
207    fn overscroll_fraction(metrics: &ScrollMetrics, displacement: f64) -> f64 {
208        if metrics.viewport_dimension > 0.0 {
209            displacement.abs() / metrics.viewport_dimension
210        } else {
211            0.0
212        }
213    }
214}
215
216impl ScrollPhysics for Bouncing {
217    fn parent(&self) -> Option<&dyn ScrollPhysics> {
218        self.parent.as_deref()
219    }
220
221    /// Travel inside the range passes through untouched, travel that *deepens*
222    /// an overscroll is scaled by [`Self::friction_factor`] at the depth
223    /// already held, and travel easing back toward the range passes through
224    /// untouched too. That asymmetry is the signature of the feel: pulling
225    /// further out gets progressively heavier, letting it back never fights
226    /// the finger.
227    fn apply_physics_to_user_offset(&self, metrics: &ScrollMetrics, offset: f64) -> f64 {
228        let min = metrics.min_scroll_extent;
229        let max = metrics.max_scroll_extent;
230        let start = metrics.pixels;
231        let end = start + offset;
232        // The three parts of the delta: travel within the range, travel that
233        // reduces an out-of-range displacement, and travel that grows one.
234        let in_range = end.clamp(min, max) - start.clamp(min, max);
235        let displacement_start = start - start.clamp(min, max);
236        let displacement_end = end - end.clamp(min, max);
237        let (easing, tensioning) = split_overscroll_travel(displacement_start, displacement_end);
238        let friction = self.friction_factor(Self::overscroll_fraction(metrics, displacement_start));
239        in_range + easing + tensioning * friction
240    }
241
242    /// Nothing is ever rejected — holding an out-of-range position is the whole
243    /// point of a bouncing surface, and the edge spring (not a boundary rule)
244    /// is what returns it.
245    fn apply_boundary_conditions(&self, _metrics: &ScrollMetrics, _value: f64) -> f64 {
246        0.0
247    }
248
249    /// An overscrolled surface always gets its spring back, in range a fling
250    /// needs [`Self::MIN_FLING_VELOCITY`], and anything else is no motion at
251    /// all.
252    fn create_ballistic_simulation(
253        &self,
254        metrics: &ScrollMetrics,
255        velocity: f64,
256    ) -> Option<Box<dyn Simulation>> {
257        if !metrics.out_of_range() && velocity.abs() < self.min_fling_velocity() {
258            return None;
259        }
260        Some(Box::new(BouncingScrollSimulation::new(
261            metrics.pixels,
262            velocity,
263            metrics.min_scroll_extent,
264            metrics.max_scroll_extent,
265            self.spring(),
266            self.tolerance_for(metrics),
267            self.rate.constant_deceleration(),
268        )))
269    }
270
271    /// Always `true`: an iOS surface whose content fits its viewport still
272    /// scrolls — it just bounces straight back.
273    fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
274        true
275    }
276
277    /// Flutter's fitted power curve, capped at `MOMENTUM_CAP` (40000 px/s) and
278    /// signed like the motion it carries over.
279    fn carried_momentum(&self, existing_velocity: f64) -> f64 {
280        let magnitude =
281            Self::MOMENTUM_COEFFICIENT * existing_velocity.abs().powf(Self::MOMENTUM_EXPONENT);
282        existing_velocity.signum() * magnitude.min(Self::MOMENTUM_CAP)
283    }
284
285    fn min_fling_velocity(&self) -> f64 {
286        Self::MIN_FLING_VELOCITY
287    }
288
289    fn spring(&self) -> SpringDescription {
290        self.rate.spring()
291    }
292}
293
294/// Android's scroll feel — Flutter's `ClampingScrollPhysics`: a drag never
295/// leaves the range (the excess is rejected outright, for the surface to show
296/// as a stretch or a glow instead), and a release runs Android's own
297/// `SplineOverScroller` deceleration to a hard stop.
298///
299/// See the [module docs](self) for why nothing here clamps that curve itself.
300#[derive(Debug, Default)]
301pub struct Clamping {
302    parent: Option<Box<dyn ScrollPhysics>>,
303}
304
305impl Clamping {
306    /// A standalone clamping physics (no chained parent).
307    pub fn new() -> Self {
308        Self::default()
309    }
310
311    /// Chain `parent` behind this physics, the module-wide composition
312    /// convention ([`ScrollPhysics`]' *Chaining*).
313    pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
314        Self {
315            parent: Some(Box::new(parent)),
316        }
317    }
318}
319
320impl ScrollPhysics for Clamping {
321    fn parent(&self) -> Option<&dyn ScrollPhysics> {
322        self.parent.as_deref()
323    }
324
325    /// The whole of any past-extent excess is rejected, so the position itself
326    /// never leaves `[min, max]`. The caller still learns how far the proposal
327    /// went — that rejected distance is what a stretch or glow effect reads
328    /// (this module's parent's *design ruling*).
329    fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
330        if value < metrics.min_scroll_extent {
331            value - metrics.min_scroll_extent
332        } else if value > metrics.max_scroll_extent {
333            value - metrics.max_scroll_extent
334        } else {
335            0.0
336        }
337    }
338
339    /// Android's fling curve for a release with room to travel, and — as a
340    /// safety arm this physics' own boundary rule makes unreachable — a spring
341    /// back to the nearest extent for a position that starts out of range.
342    fn create_ballistic_simulation(
343        &self,
344        metrics: &ScrollMetrics,
345        velocity: f64,
346    ) -> Option<Box<dyn Simulation>> {
347        let tolerance = self.tolerance_for(metrics);
348        if metrics.out_of_range() {
349            let end = if metrics.pixels > metrics.max_scroll_extent {
350                metrics.max_scroll_extent
351            } else {
352                metrics.min_scroll_extent
353            };
354            return Some(Box::new(ScrollSpringSimulation::new(
355                self.spring(),
356                metrics.pixels,
357                end,
358                velocity,
359                tolerance,
360            )));
361        }
362        if velocity.abs() < self.min_fling_velocity() {
363            return None;
364        }
365        // Already pinned against the edge the release is heading for: there is
366        // nothing to fling into.
367        if velocity > 0.0 && metrics.pixels >= metrics.max_scroll_extent {
368            return None;
369        }
370        if velocity < 0.0 && metrics.pixels <= metrics.min_scroll_extent {
371            return None;
372        }
373        Some(Box::new(ClampingScrollSimulation::new(
374            metrics.pixels,
375            velocity,
376            ClampingScrollSimulation::DEFAULT_FRICTION,
377            tolerance,
378        )))
379    }
380}
381
382/// Flutter's `AlwaysScrollableScrollPhysics`: accept a drag whether or not
383/// there is anything to scroll, and defer everything else to the chained
384/// parent.
385///
386/// Composed rather than used alone — `AlwaysScrollable::new().chain(Clamping::new())`
387/// is a clamping surface that a pull-to-refresh gesture can still reach on a
388/// short page.
389#[derive(Debug, Default)]
390pub struct AlwaysScrollable {
391    parent: Option<Box<dyn ScrollPhysics>>,
392}
393
394impl AlwaysScrollable {
395    /// A standalone always-scrollable physics (no chained parent).
396    pub fn new() -> Self {
397        Self::default()
398    }
399
400    /// Chain `parent` behind this physics, the module-wide composition
401    /// convention ([`ScrollPhysics`]' *Chaining*).
402    pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
403        Self {
404            parent: Some(Box::new(parent)),
405        }
406    }
407}
408
409impl ScrollPhysics for AlwaysScrollable {
410    fn parent(&self) -> Option<&dyn ScrollPhysics> {
411        self.parent.as_deref()
412    }
413
414    /// The one thing this physics has an opinion about.
415    fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
416        true
417    }
418}
419
420/// Flutter's `NeverScrollableScrollPhysics`: refuse every drag, deferring
421/// everything else to the chained parent — an inner list inside an outer
422/// scroller, or a temporarily locked surface.
423#[derive(Debug, Default)]
424pub struct NeverScrollable {
425    parent: Option<Box<dyn ScrollPhysics>>,
426}
427
428impl NeverScrollable {
429    /// A standalone never-scrollable physics (no chained parent).
430    pub fn new() -> Self {
431        Self::default()
432    }
433
434    /// Chain `parent` behind this physics, the module-wide composition
435    /// convention ([`ScrollPhysics`]' *Chaining*).
436    pub fn chain(self, parent: impl ScrollPhysics + 'static) -> Self {
437        Self {
438            parent: Some(Box::new(parent)),
439        }
440    }
441}
442
443impl ScrollPhysics for NeverScrollable {
444    fn parent(&self) -> Option<&dyn ScrollPhysics> {
445        self.parent.as_deref()
446    }
447
448    /// The one thing this physics has an opinion about.
449    fn should_accept_user_offset(&self, _metrics: &ScrollMetrics) -> bool {
450        false
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::physics::Tolerance;
458
459    /// A 100px viewport at 1.0 dpr, so an overscroll fraction reads as
460    /// hundredths and the tolerance is a round 20 px/s.
461    fn metrics(pixels: f64, max: f64) -> ScrollMetrics {
462        ScrollMetrics {
463            pixels,
464            min_scroll_extent: 0.0,
465            max_scroll_extent: max,
466            viewport_dimension: 100.0,
467            device_pixel_ratio: 1.0,
468        }
469    }
470
471    fn tol() -> Tolerance {
472        Tolerance::for_device_pixel_ratio(1.0)
473    }
474
475    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
476        assert!(
477            (actual - expected).abs() < epsilon,
478            "{what}: {actual} is not within {epsilon} of {expected}"
479        );
480    }
481
482    #[test]
483    fn bouncing_friction_factor_curve() {
484        let normal = Bouncing::new();
485        assert_close(normal.friction_factor(0.0), 0.52, 1e-12, "normal at rest");
486        assert_close(normal.friction_factor(0.5), 0.13, 1e-12, "normal half out");
487        assert_close(
488            normal.friction_factor(1.0),
489            0.0,
490            1e-12,
491            "normal a viewport out",
492        );
493
494        let fast = Bouncing::with_rate(DecelerationRate::Fast);
495        assert_close(fast.friction_factor(0.0), 0.26, 1e-12, "fast at rest");
496        assert_close(fast.friction_factor(0.5), 0.065, 1e-12, "fast half out");
497
498        // The two coefficients the curve is built from, stated directly.
499        assert_eq!(DecelerationRate::NORMAL_FRICTION, 0.52);
500        assert_eq!(DecelerationRate::FAST_FRICTION, 0.26);
501    }
502
503    #[test]
504    fn bouncing_resists_increasing_overscroll_only() {
505        let physics = Bouncing::new();
506
507        // Wholly in range: the identity mapping, both directions.
508        assert_eq!(
509            physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), 30.0),
510            30.0
511        );
512        assert_eq!(
513            physics.apply_physics_to_user_offset(&metrics(100.0, 500.0), -50.0),
514            -50.0
515        );
516
517        // At the leading edge, pulled 20px past it: the whole delta is
518        // tensioning, at zero depth (0.52).
519        assert_close(
520            physics.apply_physics_to_user_offset(&metrics(0.0, 500.0), -20.0),
521            -20.0 * 0.52,
522            1e-12,
523            "tensioning off the leading edge",
524        );
525        // Same at the trailing edge.
526        assert_close(
527            physics.apply_physics_to_user_offset(&metrics(500.0, 500.0), 20.0),
528            20.0 * 0.52,
529            1e-12,
530            "tensioning off the trailing edge",
531        );
532
533        // Already 30px out of a 100px viewport, pulled 10px deeper: the factor
534        // has tightened to 0.52·(1 − 0.3)².
535        assert_close(
536            physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), -10.0),
537            -10.0 * 0.52 * 0.49,
538            1e-12,
539            "tensioning deeper",
540        );
541
542        // The same 30px out, easing back: untouched, in both the partial and
543        // the all-the-way-back-into-range cases.
544        assert_eq!(
545            physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 10.0),
546            10.0
547        );
548        assert_eq!(
549            physics.apply_physics_to_user_offset(&metrics(-30.0, 500.0), 50.0),
550            50.0
551        );
552
553        // A delta straddling the edge resists only its past-edge part: 5px of
554        // in-range travel, then 20px tensioned.
555        assert_close(
556            physics.apply_physics_to_user_offset(&metrics(5.0, 500.0), -25.0),
557            -5.0 + -20.0 * 0.52,
558            1e-12,
559            "straddling the leading edge",
560        );
561    }
562
563    #[test]
564    fn bouncing_carried_momentum_formula() {
565        let physics = Bouncing::new();
566        let expected = 0.000_816 * 1000.0_f64.powf(1.967);
567        let carried = physics.carried_momentum(1000.0);
568        assert!(
569            (carried - expected).abs() / expected < 1e-6,
570            "carried {carried} is not within 1e-6 relative of {expected}"
571        );
572        // Sign follows the motion being carried over.
573        assert_close(
574            physics.carried_momentum(-1000.0),
575            -expected,
576            1e-9,
577            "negative carry",
578        );
579        assert_eq!(physics.carried_momentum(0.0), 0.0);
580        // Fast enough and the fit saturates rather than compounding.
581        assert_eq!(physics.carried_momentum(1e6), 40_000.0);
582        assert_eq!(physics.carried_momentum(-1e6), -40_000.0);
583    }
584
585    #[test]
586    fn bouncing_min_fling_is_100() {
587        assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
588        assert_eq!(
589            Bouncing::MIN_FLING_VELOCITY,
590            crate::physics::MIN_FLING_VELOCITY * 2.0
591        );
592        // …twice what the clamping physics (the trait default) asks for.
593        assert_eq!(Clamping::new().min_fling_velocity(), 50.0);
594    }
595
596    #[test]
597    fn bouncing_spring_per_rate() {
598        let normal = Bouncing::new().spring();
599        assert_eq!(normal.mass, 0.5);
600        assert_eq!(normal.stiffness, 100.0);
601        assert_close(
602            normal.damping,
603            2.0 * 1.1 * (0.5 * 100.0_f64).sqrt(),
604            1e-12,
605            "normal damping",
606        );
607        assert_eq!(normal, SpringDescription::default_scroll_spring());
608
609        let fast = Bouncing::with_rate(DecelerationRate::Fast).spring();
610        assert_eq!(fast.mass, 0.3);
611        assert_eq!(fast.stiffness, 75.0);
612        assert_close(
613            fast.damping,
614            2.0 * 1.3 * (0.3 * 75.0_f64).sqrt(),
615            1e-12,
616            "fast damping",
617        );
618    }
619
620    #[test]
621    fn bouncing_ballistic_in_range_low_velocity_is_none() {
622        let physics = Bouncing::new();
623        let m = metrics(100.0, 500.0);
624        assert!(physics.create_ballistic_simulation(&m, 0.0).is_none());
625        assert!(physics.create_ballistic_simulation(&m, 99.0).is_none());
626        // Exactly at the threshold is a fling.
627        assert!(physics.create_ballistic_simulation(&m, 100.0).is_some());
628    }
629
630    #[test]
631    fn bouncing_ballistic_out_of_range_is_some() {
632        let physics = Bouncing::new();
633        // Resting 30px past the leading edge: no fling velocity at all, but the
634        // spring still has to bring it home.
635        let sim = physics
636            .create_ballistic_simulation(&metrics(-30.0, 500.0), 0.0)
637            .expect("an overscrolled release must spring back");
638        assert_close(sim.x(0.0), -30.0, 1e-9, "starts where released");
639        assert!(
640            sim.x(0.1) > sim.x(0.0),
641            "must travel back toward the leading extent"
642        );
643        assert_close(sim.x(3.0), 0.0, 1e-9, "settles on the extent");
644        assert!(sim.is_done(3.0), "the bounce-back never settled");
645    }
646
647    #[test]
648    fn bouncing_fast_rate_carries_the_constant_deceleration() {
649        assert_eq!(DecelerationRate::FAST_CONSTANT_DECELERATION, 1400.0);
650        let m = metrics(100.0, 5000.0);
651        let fast = Bouncing::with_rate(DecelerationRate::Fast)
652            .create_ballistic_simulation(&m, 2000.0)
653            .expect("a fast fling must be ballistic");
654        // The same curve, spelled out: the fast spring plus a 1400 px/s²
655        // constant deceleration on top of the drag.
656        let expected = BouncingScrollSimulation::new(
657            100.0,
658            2000.0,
659            0.0,
660            5000.0,
661            SpringDescription::with_damping_ratio(0.3, 75.0, 1.3),
662            tol(),
663            1400.0,
664        );
665        assert_close(fast.x(0.3), expected.x(0.3), 1e-9, "fast position");
666        assert_close(fast.dx(0.3), expected.dx(0.3), 1e-9, "fast velocity");
667
668        // …and it is genuinely slower than the normal rate's pure-drag curve.
669        let normal = Bouncing::new()
670            .create_ballistic_simulation(&m, 2000.0)
671            .expect("a normal fling must be ballistic");
672        assert!(
673            normal.x(0.3) > fast.x(0.3),
674            "the fast rate reached {}, no nearer than the normal rate's {}",
675            fast.x(0.3),
676            normal.x(0.3)
677        );
678    }
679
680    #[test]
681    fn clamping_boundary_conditions_reject_excess() {
682        let physics = Clamping::new();
683        let m = metrics(0.0, 500.0);
684        assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
685        assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
686        assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
687        // The extents themselves are in range.
688        assert_eq!(physics.apply_boundary_conditions(&m, 0.0), 0.0);
689        assert_eq!(physics.apply_boundary_conditions(&m, 500.0), 0.0);
690    }
691
692    #[test]
693    fn clamping_ballistic_returns_android_curve_sim() {
694        let physics = Clamping::new();
695        let m = metrics(0.0, 500.0);
696        let sim = physics
697            .create_ballistic_simulation(&m, 3000.0)
698            .expect("a fast in-range fling must be ballistic");
699
700        // Android's own spline, at this crate's default friction.
701        let expected = ClampingScrollSimulation::new(
702            0.0,
703            3000.0,
704            ClampingScrollSimulation::DEFAULT_FRICTION,
705            tol(),
706        );
707        assert_close(sim.dx(0.0), 3000.0, 1e-9, "release velocity");
708        assert_close(sim.x(0.1), expected.x(0.1), 1e-9, "position on the curve");
709        assert_close(sim.x(0.5), expected.x(0.5), 1e-9, "position later on");
710
711        // The curve itself overshoots the extent — nothing in this physics
712        // clamps it. What keeps the surface in range is the driver contract:
713        // `ScrollWidget::drive_ballistic` paints `x − apply_boundary_conditions(x)`
714        // for every simulation position, and this physics rejects the whole
715        // excess. Re-derived here so a driver change cannot silently break it.
716        let mut overshot = false;
717        for step in 0..=200 {
718            let time = f64::from(step) / 100.0;
719            let raw = sim.x(time);
720            overshot |= raw > 500.0;
721            let painted = raw - physics.apply_boundary_conditions(&m, raw);
722            assert!(
723                (0.0..=500.0).contains(&painted),
724                "painted offset {painted} left the range at t={time}s (raw {raw})"
725            );
726        }
727        assert!(
728            overshot,
729            "the raw curve must overshoot for this test to mean anything"
730        );
731
732        // A release with nowhere to go is not a fling.
733        assert!(
734            physics
735                .create_ballistic_simulation(&metrics(500.0, 500.0), 3000.0)
736                .is_none()
737        );
738        assert!(
739            physics
740                .create_ballistic_simulation(&metrics(0.0, 500.0), -3000.0)
741                .is_none()
742        );
743        assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
744    }
745
746    #[test]
747    fn clamping_out_of_range_springs_back() {
748        // The safety arm: this physics' own boundary rule never lets a position
749        // get here, but a metrics change (or a physics swap) can.
750        let sim = Clamping::new()
751            .create_ballistic_simulation(&metrics(560.0, 500.0), 0.0)
752            .expect("an out-of-range release must spring back");
753        assert_close(sim.x(0.0), 560.0, 1e-9, "starts where released");
754        assert_close(sim.x(3.0), 500.0, 1e-9, "settles on the nearest extent");
755    }
756
757    #[test]
758    fn always_scrollable_accepts_on_short_content() {
759        // Content that fits: the trait default (and a bare clamping physics)
760        // refuses the drag outright.
761        let short = metrics(0.0, 0.0);
762        assert!(!Clamping::new().should_accept_user_offset(&short));
763        assert!(AlwaysScrollable::new().should_accept_user_offset(&short));
764    }
765
766    #[test]
767    fn never_scrollable_rejects_user_offset() {
768        let physics = NeverScrollable::new();
769        assert!(!physics.should_accept_user_offset(&metrics(0.0, 500.0)));
770        // Even chained onto a physics that would accept.
771        let chained = NeverScrollable::new().chain(Bouncing::new());
772        assert!(!chained.should_accept_user_offset(&metrics(0.0, 500.0)));
773    }
774
775    #[test]
776    fn chain_composition_defers_boundary_to_parent() {
777        let physics = AlwaysScrollable::new().chain(Clamping::new());
778
779        // Its own domain: a drag is accepted even with nothing to scroll.
780        assert!(physics.should_accept_user_offset(&metrics(0.0, 0.0)));
781
782        // Everything else walks up to the clamping parent — the boundary rule…
783        let m = metrics(0.0, 500.0);
784        assert_eq!(physics.apply_boundary_conditions(&m, -10.0), -10.0);
785        assert_eq!(physics.apply_boundary_conditions(&m, 510.0), 10.0);
786        assert_eq!(physics.apply_boundary_conditions(&m, 250.0), 0.0);
787
788        // …the ballistic curve…
789        assert!(physics.create_ballistic_simulation(&m, 3000.0).is_some());
790        assert!(physics.create_ballistic_simulation(&m, 40.0).is_none());
791
792        // …and the fling threshold, which the parent leaves at the default.
793        assert_eq!(physics.min_fling_velocity(), 50.0);
794
795        // Chained the other way the bouncing parent's answers come through
796        // instead, including its doubled minimum.
797        let bouncing = AlwaysScrollable::new().chain(Bouncing::new());
798        assert_eq!(bouncing.min_fling_velocity(), Bouncing::MIN_FLING_VELOCITY);
799        assert_eq!(bouncing.apply_boundary_conditions(&m, 510.0), 0.0);
800    }
801}