Skip to main content

frus_widgets/
physics.rs

1//! SingleChildScrollView physics: **how a scrollable behaves at its edges and after the finger
2//! lifts**.
3//!
4//! Two families, because the platforms disagree and users notice:
5//!
6//! - [`ScrollPhysics::BOUNCING`] — dragging past the end is allowed but grows
7//!   progressively harder, and letting go springs the content back. A fling rolls
8//!   on friction and hands over to that spring at the edge.
9//! - [`ScrollPhysics::CLAMPING`] — the offset never leaves the content at all. A
10//!   fling follows the platform's spline deceleration and stops dead at the edge.
11//!
12//! The [`ScrollBehavior`](crate::ScrollBehavior) in force picks one from the platform it
13//! follows, so an app that says nothing already feels native on each platform. A
14//! [`crate::scroll::SingleChildScrollView`] can ask for its own, and an application or a
15//! subtree can change the behaviour, for the cases where the content wants a particular
16//! feel.
17//!
18//! Everything here is a **pure function of the metrics**: no retained state, no
19//! clock. The runtime samples the returned [`Ballistic`] frame by frame; the shell
20//! calls [`ScrollPhysics::apply_user_offset`] while a finger is down. That split is
21//! what keeps the policy unit-testable without a window.
22
23use frus_core::{
24    BouncingScrollSimulation, ClampingScrollSimulation, Simulation, SpringDescription,
25    SpringSimulation, Tolerance,
26};
27
28/// The state of one scroll axis, as the physics sees it. All in logical pixels.
29///
30/// `pixels` is the current offset, `min`/`max` the offsets the content may rest
31/// at (`min` is 0 in practice, `max` the overflow), and `viewport` the visible
32/// extent along that axis — the yardstick the rubber band is measured against.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct ScrollMetrics {
35    /// The current scroll offset.
36    pub pixels: f32,
37    /// The smallest offset the content may rest at; 0 in practice.
38    pub min: f32,
39    /// The largest offset the content may rest at: how far the content overflows.
40    pub max: f32,
41    /// The visible extent along the axis.
42    pub viewport: f32,
43}
44
45impl ScrollMetrics {
46    /// Metrics for an axis scrolling from 0 to `max` in a `viewport`-long window.
47    pub fn new(pixels: f32, max: f32, viewport: f32) -> Self {
48        Self {
49            pixels,
50            min: 0.0,
51            max: max.max(0.0),
52            viewport: viewport.max(1.0),
53        }
54    }
55
56    /// Is the offset outside the range the content may rest at?
57    pub fn out_of_range(&self) -> bool {
58        self.pixels < self.min || self.pixels > self.max
59    }
60
61    /// How far past an edge the offset has been dragged; 0 while inside.
62    pub fn overscroll_past(&self) -> f32 {
63        (self.min - self.pixels)
64            .max(self.pixels - self.max)
65            .max(0.0)
66    }
67}
68
69/// The default minimum release speed, in px/s, below which nothing is flung.
70pub const MIN_FLING_VELOCITY: f32 = 50.0;
71/// The cap applied to a release speed, in px/s.
72pub const MAX_FLING_VELOCITY: f32 = 8000.0;
73
74/// **What a scroll area shows when it is pulled past its edge** and its physics refuse
75/// to move (milestone 591).
76///
77/// Current touch platforms **stretch** the content towards the edge and spring it back;
78/// others **glow**, an arc of light over the edge. The
79/// [`ScrollBehavior`](crate::ScrollBehavior) in force picks one per platform, or none.
80#[derive(Copy, Clone, PartialEq, Eq, Debug)]
81pub enum OverscrollIndicator {
82    /// An arc of light over the edge, fading as the pull lets go.
83    Glow,
84    /// The content lengthens towards the edge, and springs back.
85    Stretch,
86}
87
88/// **When a scroll area draws a scrollbar** (`app.dart:857`).
89///
90/// The reference asks this question per platform and answers it *no* on every touch
91/// screen: a finger already knows where it is on the page, and a permanent bar over the
92/// content is a desktop affordance for a pointer that cannot feel the edges. Along the
93/// **horizontal** axis the answer is no everywhere, on every platform.
94#[derive(Copy, Clone, PartialEq, Eq, Debug)]
95pub enum Scrollbars {
96    /// Never — what a touch screen gets (`app.dart:870`).
97    Never,
98    /// One bar down the inner edge (`app.dart:865`), for the platforms whose own scroll
99    /// views have one.
100    Always,
101}
102
103/// **How fast a fling's momentum is taken away** — the bouncing family's second
104/// deceleration profile.
105///
106/// It is chosen by **what is doing the scrolling**, not by how fast. A finger
107/// throws a surface and lets go of it; a trackpad or a wheel is a hand resting on a
108/// device that reports motion, and the two want different endings. So this is a
109/// property of the physics a scrollable is built with — settled once, from the
110/// platform — and not something recomputed per fling.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
112pub enum ScrollDecelerationRate {
113    /// A finger's. What a touch screen expects, and the default.
114    #[default]
115    Normal,
116    /// A trackpad's or a wheel's — what desktop software expects from a pointing
117    /// device more precise than a fingertip.
118    ///
119    /// Four things differ, not one: the fling carries a constant deceleration so
120    /// that it actually stops rather than coasting to a halt at infinity, the
121    /// rubber band is twice as stiff, a fling may be twice again as fast, and
122    /// **easing back out of an overscroll is not resisted at all** — which is a
123    /// behaviour rather than a number, and the one a reader notices.
124    Fast,
125}
126
127/// **How a scrollable behaves at its edges and after a fling** — what the reference's
128/// bouncing and clamping physics differ by.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
130pub enum ScrollFeel {
131    /// Overscroll is allowed, resisted, and springs back — at the given rate.
132    Bouncing(ScrollDecelerationRate),
133    /// Overscroll is refused; a fling stops at the edge.
134    Clamping,
135}
136
137/// **Whether the user may scroll an area** (milestone 649): the reference's
138/// `AlwaysScrollableScrollPhysics` and `NeverScrollableScrollPhysics`, and the rule they
139/// both override.
140///
141/// Whatever it says, the application still moves the area: a controller's jump, a
142/// "bring this into view", a page turned in code. This is about the user's drag, fling and
143/// wheel only.
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
145pub enum UserScroll {
146    /// When there is content outside the viewport to reveal, or a pull to refresh above —
147    /// the reference's default rule.
148    #[default]
149    WhenOverflowing,
150    /// Always, even with everything in sight: a short list still bounces, and still takes
151    /// the drag from the area around it.
152    Always,
153    /// Never: the drag, the fling and the wheel go to the area around it, as if it were not
154    /// a scrollable at all.
155    Never,
156}
157
158/// The behaviour of a scrollable: its feel at the edges and after a fling, and whether the
159/// user may scroll it.
160///
161/// The reference builds these as a chain, each physics a parent to the next —
162/// `AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics())`. Here the same chain
163/// is two settings, written the same way round:
164///
165/// ```ignore
166/// ScrollPhysics::BOUNCING.always_scrollable()   // bounces, and always takes the drag
167/// ScrollPhysics::NEVER                          // never takes it, whatever the platform
168/// ScrollPhysics::ALWAYS                         // always takes it, the platform's feel
169/// ```
170///
171/// A setting left open is the platform's: an area's physics are laid over its
172/// [`ScrollBehavior`](crate::ScrollBehavior)'s with [`apply_to`](Self::apply_to), as the
173/// reference applies a widget's physics to the scroll configuration's.
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
175pub struct ScrollPhysics {
176    /// The feel; `None` takes the platform's.
177    feel: Option<ScrollFeel>,
178    /// Whether the user may scroll; [`UserScroll::WhenOverflowing`] takes the platform's.
179    user: UserScroll,
180}
181
182impl ScrollPhysics {
183    /// Bouncing physics at a finger's deceleration — the common case, and what
184    /// `ScrollPhysics::BOUNCING` meant before there were two.
185    pub const BOUNCING: Self = Self::bouncing(ScrollDecelerationRate::Normal);
186
187    /// Clamping physics: overscroll refused, a fling stopping at the edge.
188    pub const CLAMPING: Self = Self {
189        feel: Some(ScrollFeel::Clamping),
190        user: UserScroll::WhenOverflowing,
191    };
192
193    /// The user may never scroll it — the reference's `NeverScrollableScrollPhysics` — and
194    /// the platform's feel for anything else.
195    pub const NEVER: Self = Self {
196        feel: None,
197        user: UserScroll::Never,
198    };
199
200    /// The user may always scroll it, even with everything in sight — the reference's
201    /// `AlwaysScrollableScrollPhysics` — with the platform's feel.
202    pub const ALWAYS: Self = Self {
203        feel: None,
204        user: UserScroll::Always,
205    };
206
207    /// Bouncing physics at `rate`.
208    pub const fn bouncing(rate: ScrollDecelerationRate) -> Self {
209        Self {
210            feel: Some(ScrollFeel::Bouncing(rate)),
211            user: UserScroll::WhenOverflowing,
212        }
213    }
214
215    /// These physics, and the user may always scroll.
216    pub const fn always_scrollable(self) -> Self {
217        Self {
218            user: UserScroll::Always,
219            ..self
220        }
221    }
222
223    /// These physics, and the user may never scroll.
224    pub const fn never_scrollable(self) -> Self {
225        Self {
226            user: UserScroll::Never,
227            ..self
228        }
229    }
230
231    /// These physics laid over `ancestor`'s — the reference's `applyTo`: what these
232    /// settle, they keep; what they leave open, `ancestor` decides.
233    pub fn apply_to(self, ancestor: ScrollPhysics) -> ScrollPhysics {
234        ScrollPhysics {
235            feel: self.feel.or(ancestor.feel),
236            user: match self.user {
237                UserScroll::WhenOverflowing => ancestor.user,
238                user => user,
239            },
240        }
241    }
242
243    /// The feel in force: clamping when nothing settled one.
244    pub fn feel(self) -> ScrollFeel {
245        self.feel.unwrap_or(ScrollFeel::Clamping)
246    }
247
248    /// Whether the user may scroll.
249    pub fn user(self) -> UserScroll {
250        self.user
251    }
252
253    /// The deceleration profile in force, or `None` for a family that has none.
254    pub fn deceleration_rate(self) -> Option<ScrollDecelerationRate> {
255        match self.feel() {
256            ScrollFeel::Bouncing(rate) => Some(rate),
257            ScrollFeel::Clamping => None,
258        }
259    }
260}
261
262impl ScrollPhysics {
263    /// The spring that returns overscrolled content to its edge.
264    pub fn spring(self) -> SpringDescription {
265        SpringDescription::with_damping_ratio(0.5, 100.0, 1.1)
266    }
267
268    /// The thresholds under which the motion counts as finished.
269    pub fn tolerance(self) -> Tolerance {
270        Tolerance::PIXELS
271    }
272
273    /// The release speed under which a drag does not fling at all.
274    ///
275    /// Bouncing physics decelerates far more gently, so it asks for a more
276    /// deliberate gesture before it commits to a fling.
277    pub fn min_fling_velocity(self) -> f32 {
278        match self.feel() {
279            ScrollFeel::Bouncing(_) => MIN_FLING_VELOCITY * 2.0,
280            ScrollFeel::Clamping => MIN_FLING_VELOCITY,
281        }
282    }
283
284    /// The cap on a release speed.
285    ///
286    /// A pointing device can be flicked far harder than a finger can — a wheel
287    /// notch or a two-finger flick on a trackpad reports speeds no fingertip
288    /// reaches — so the fast profile lifts the ceiling eightfold rather than
289    /// clipping what the hardware actually said.
290    pub fn max_fling_velocity(self) -> f32 {
291        match self.feel() {
292            ScrollFeel::Bouncing(ScrollDecelerationRate::Fast) => MAX_FLING_VELOCITY * 8.0,
293            _ => MAX_FLING_VELOCITY,
294        }
295    }
296
297    /// The distance a finger must cover before the content starts to move, in px.
298    ///
299    /// Bouncing physics asks for a little more, to swallow the small unintended
300    /// motion of a finger lifting off.
301    pub fn drag_start_distance_threshold(self) -> f32 {
302        match self.feel() {
303            ScrollFeel::Bouncing(_) => 3.5,
304            ScrollFeel::Clamping => 0.0,
305        }
306    }
307
308    /// The speed carried over when a fling is thrown on top of one already
309    /// running — the repeated-swipe speed-up.
310    ///
311    /// Only the bouncing family builds momentum this way; clamping starts each
312    /// fling from the gesture alone.
313    pub fn carried_momentum(self, existing_velocity: f32) -> f32 {
314        match self.feel() {
315            ScrollFeel::Bouncing(_) => {
316                existing_velocity.signum()
317                    * (0.000_816 * existing_velocity.abs().powf(1.967)).min(40_000.0)
318            }
319            ScrollFeel::Clamping => 0.0,
320        }
321    }
322
323    /// How much of a finger's `delta` actually reaches the offset.
324    ///
325    /// Inside the content it is all of it. Past an edge, the bouncing family
326    /// applies a friction that falls off quadratically with how far out we already
327    /// are: the further you pull, the less each pixel of finger buys — the rubber
328    /// band. Easing back towards the content is measured at the position the
329    /// gesture is heading *to*, so releasing tension is easier than adding it.
330    pub fn apply_user_offset(self, metrics: ScrollMetrics, delta: f32) -> f32 {
331        let Some(rate) = self.deceleration_rate() else {
332            return delta;
333        };
334        if delta == 0.0 || !metrics.out_of_range() {
335            return delta;
336        }
337        let past_start = (metrics.min - metrics.pixels).max(0.0);
338        let past_end = (metrics.pixels - metrics.max).max(0.0);
339        let past = past_start.max(past_end);
340        // `delta` is a change **to the offset**: past the start (`pixels < min`) a
341        // positive delta walks back towards the content, and past the end a
342        // negative one does. Those are the directions that release the band.
343        let easing = (past_start > 0.0 && delta > 0.0) || (past_end > 0.0 && delta < 0.0);
344        // **Easing out of an overscroll is unresisted on the fast profile**, and
345        // that is the difference a reader actually notices. A finger that pulled a
346        // list past its end is still holding the band and lets it back gradually; a
347        // trackpad has nothing to hold, so making the return sticky reads as the
348        // content refusing to come back rather than as tension.
349        if easing && rate == ScrollDecelerationRate::Fast {
350            return delta;
351        }
352        let fraction = if easing {
353            (past - delta.abs()) / metrics.viewport
354        } else {
355            past / metrics.viewport
356        };
357        delta.signum() * apply_friction(past, delta.abs(), friction_factor(rate, fraction))
358    }
359
360    /// The part of a proposed move that must be **refused**, so that the caller
361    /// can apply `value - refused`.
362    ///
363    /// Clamping physics refuses everything that would leave the content; bouncing
364    /// physics refuses nothing, and lets the spring deal with it.
365    pub fn apply_boundary_conditions(self, metrics: ScrollMetrics, value: f32) -> f32 {
366        if self.feel() != ScrollFeel::Clamping {
367            return 0.0;
368        }
369        let p = metrics.pixels;
370        if value < p && p <= metrics.min {
371            return value - p; // already under the start: refuse the whole move
372        }
373        if metrics.max <= p && p < value {
374            return value - p; // already past the end
375        }
376        if value < metrics.min && metrics.min < p {
377            return value - metrics.min; // this move hits the start
378        }
379        if p < metrics.max && metrics.max < value {
380            return value - metrics.max; // this move hits the end
381        }
382        0.0
383    }
384
385    /// May the content be dragged past its edges at all?
386    pub fn allows_overscroll(self) -> bool {
387        matches!(self.feel(), ScrollFeel::Bouncing(_))
388    }
389
390    /// The motion that follows the finger lifting off at `velocity` (px/s, positive
391    /// meaning the offset grows). `None` when nothing should move.
392    pub fn ballistic(self, metrics: ScrollMetrics, velocity: f32) -> Option<Ballistic> {
393        let tolerance = self.tolerance();
394        let velocity = velocity.clamp(-self.max_fling_velocity(), self.max_fling_velocity());
395        match self.feel() {
396            ScrollFeel::Bouncing(rate) => {
397                if velocity.abs() >= tolerance.velocity || metrics.out_of_range() {
398                    Some(Ballistic::Bouncing(
399                        BouncingScrollSimulation::with_constant_deceleration(
400                            metrics.pixels,
401                            velocity,
402                            metrics.min,
403                            metrics.max,
404                            self.spring(),
405                            tolerance,
406                            match rate {
407                                ScrollDecelerationRate::Normal => 0.0,
408                                ScrollDecelerationRate::Fast => CONSTANT_DECELERATION_FAST,
409                            },
410                        ),
411                    ))
412                } else {
413                    None
414                }
415            }
416            ScrollFeel::Clamping => {
417                // Out of range at all — after a resize, say — is a return to the
418                // edge, never a fling. The velocity may only help it home.
419                if metrics.out_of_range() {
420                    let end = if metrics.pixels > metrics.max {
421                        metrics.max
422                    } else {
423                        metrics.min
424                    };
425                    return Some(Ballistic::Spring(SpringSimulation::new(
426                        self.spring(),
427                        metrics.pixels,
428                        end,
429                        velocity.min(0.0),
430                        tolerance,
431                    )));
432                }
433                if velocity.abs() < tolerance.velocity
434                    || (velocity > 0.0 && metrics.pixels >= metrics.max)
435                    || (velocity < 0.0 && metrics.pixels <= metrics.min)
436                {
437                    return None;
438                }
439                Some(Ballistic::Clamping(ClampingScrollSimulation::new(
440                    metrics.pixels,
441                    velocity,
442                    tolerance,
443                )))
444            }
445        }
446    }
447
448    /// The motion that follows the finger lifting off a **paged** area, where the
449    /// content may not come to rest between two pages. `extent` is the distance
450    /// from one page to the next.
451    ///
452    /// This replaces the fling rather than correcting it afterwards: a paged view
453    /// never coasts. Whatever the release speed, the content springs to **one**
454    /// page — the next one on a flick, the nearer one otherwise. A fling that
455    /// crossed three pages and then had to be caught and pulled back would be a
456    /// different, worse gesture.
457    ///
458    /// Past an edge and still heading out, there is no page to go to, so the
459    /// ordinary physics takes over — which is what brings the overscroll home,
460    /// with the platform's own bounce or clamp.
461    pub fn page_ballistic(
462        self,
463        metrics: ScrollMetrics,
464        velocity: f32,
465        extent: f32,
466    ) -> Option<Ballistic> {
467        if (velocity <= 0.0 && metrics.pixels <= metrics.min)
468            || (velocity >= 0.0 && metrics.pixels >= metrics.max)
469        {
470            return self.ballistic(metrics, velocity);
471        }
472        let tolerance = self.tolerance();
473        let target = page_target(metrics, extent, velocity, tolerance.velocity);
474        if (target - metrics.pixels).abs() < tolerance.distance {
475            return None;
476        }
477        Some(Ballistic::Spring(SpringSimulation::new(
478            self.spring(),
479            metrics.pixels,
480            target,
481            velocity,
482            tolerance,
483        )))
484    }
485}
486
487/// Which page an offset sits on, as a **fraction**: `1.5` is halfway between the
488/// second and the third. `extent` is the distance from one page to the next.
489pub fn page_of(pixels: f32, extent: f32) -> f32 {
490    pixels / extent.max(1.0)
491}
492
493/// The offset the content must come to rest at after a release at `velocity`.
494///
495/// The rule is the one every paged view uses, and it is not "nearest page":
496/// **any** release with speed above the tolerance counts as a flick and moves the
497/// page half a unit *the way the finger went* before rounding. So a flick that
498/// covered a tenth of the viewport still turns the page, while letting go slowly
499/// falls back to whichever page is nearer. A gesture is an intention, not a
500/// measurement.
501pub fn page_target(metrics: ScrollMetrics, extent: f32, velocity: f32, tolerance: f32) -> f32 {
502    let mut page = page_of(metrics.pixels, extent);
503    if velocity < -tolerance {
504        page -= 0.5;
505    } else if velocity > tolerance {
506        page += 0.5;
507    }
508    // A view whose content is not a whole number of pages — every one of them once
509    // the pages are narrower than the viewport — has a last page that is not at a
510    // page boundary. Resting past the end is not a page, it is an overscroll.
511    (page.round() * extent.max(1.0)).clamp(metrics.min, metrics.max)
512}
513
514/// The resting place a **snapping sheet** heads for when the finger lets go at `position`
515/// moving at `velocity` — both in the sheet's own units, growing as it rises. `snaps`
516/// are the positions it may rest at, in increasing order.
517///
518/// The rule is the paged view's, stated on uneven stops rather than a fixed extent: a
519/// release slower than `tolerance` goes to the **nearer** of the two stops around it,
520/// and any faster one to the next stop **the way it was going**, however little of the
521/// way it had come. A position already on a stop stays there, and one outside the stops
522/// comes back to the nearest end.
523pub fn snap_target(position: f32, velocity: f32, snaps: &[f32], tolerance: f32) -> f32 {
524    let (Some(&first), Some(&last)) = (snaps.first(), snaps.last()) else {
525        return position;
526    };
527    let Some(next) = snaps.iter().position(|&snap| snap >= position) else {
528        return last;
529    };
530    if next == 0 {
531        return first;
532    }
533    let (below, above) = (snaps[next - 1], snaps[next]);
534    if above == position {
535        return above;
536    }
537    if velocity.abs() <= tolerance {
538        return if position - below < above - position {
539            below
540        } else {
541            above
542        };
543    }
544    if velocity < 0.0 {
545        below
546    } else {
547        above
548    }
549}
550
551/// The constant deceleration the **fast** profile adds on top of the drag, in
552/// px·s⁻² — the reference's own figure.
553///
554/// Drag alone is geometric: the velocity is multiplied down each second and reaches
555/// nought only at infinity, which is exactly right for a surface that was thrown.
556/// A trackpad gesture is not a throw, and a motion that never quite settles under
557/// one reads as the content drifting. This term is what gives it an end.
558pub const CONSTANT_DECELERATION_FAST: f32 = 1400.0;
559
560/// The friction applied to the first pixels of overscroll, before the band
561/// stiffens. It falls off quadratically with how far out the drag already is.
562///
563/// The **fast** profile halves the starting figure, which is to say it doubles the
564/// stiffness: a trackpad reports a great deal of motion for a small gesture, and a
565/// band as slack as a finger's would let a flick throw the content a long way out.
566fn friction_factor(rate: ScrollDecelerationRate, overscroll_fraction: f32) -> f32 {
567    let base = match rate {
568        ScrollDecelerationRate::Normal => 0.52,
569        ScrollDecelerationRate::Fast => 0.26,
570    };
571    (1.0 - overscroll_fraction).powi(2) * base
572}
573
574/// Applies `gamma` to the part of `delta` that is spent outside the content, and
575/// none of it to the part that is spent inside — a drag that crosses the edge
576/// mid-way is resisted only for the half that is out.
577fn apply_friction(extent_outside: f32, mut delta: f32, gamma: f32) -> f32 {
578    let mut total = 0.0;
579    if extent_outside > 0.0 && gamma > 0.0 {
580        let delta_to_limit = extent_outside / gamma;
581        if delta < delta_to_limit {
582            return delta * gamma;
583        }
584        total += extent_outside;
585        delta -= delta_to_limit;
586    }
587    total + delta
588}
589
590/// The motion a scrollable follows once the finger has let go.
591///
592/// One enum rather than a boxed trait object: it stays `Copy`, so the runtime can
593/// keep one per axis in a plain map and sample it without an allocation per frame.
594#[derive(Clone, Copy, Debug)]
595pub enum Ballistic {
596    /// Friction then spring-back — the bouncing family.
597    Bouncing(BouncingScrollSimulation),
598    /// The platform spline deceleration — the clamping family.
599    Clamping(ClampingScrollSimulation),
600    /// A plain return to an edge, with no fling.
601    Spring(SpringSimulation),
602}
603
604impl Simulation for Ballistic {
605    fn x(&self, time: f32) -> f32 {
606        match self {
607            Ballistic::Bouncing(s) => s.x(time),
608            Ballistic::Clamping(s) => s.x(time),
609            Ballistic::Spring(s) => s.x(time),
610        }
611    }
612
613    fn dx(&self, time: f32) -> f32 {
614        match self {
615            Ballistic::Bouncing(s) => s.dx(time),
616            Ballistic::Clamping(s) => s.dx(time),
617            Ballistic::Spring(s) => s.dx(time),
618        }
619    }
620
621    fn is_done(&self, time: f32) -> bool {
622        match self {
623            Ballistic::Bouncing(s) => s.is_done(time),
624            Ballistic::Clamping(s) => s.is_done(time),
625            Ballistic::Spring(s) => s.is_done(time),
626        }
627    }
628
629    fn tolerance(&self) -> Tolerance {
630        match self {
631            Ballistic::Bouncing(s) => s.tolerance(),
632            Ballistic::Clamping(s) => s.tolerance(),
633            Ballistic::Spring(s) => s.tolerance(),
634        }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    const STOPS: [f32; 3] = [200.0, 400.0, 800.0];
643
644    /// **Let go slowly, and a sheet rests at the nearer stop** — measured on the uneven
645    /// gaps a sheet has, not on a page extent: 290 is nearer 200, 310 nearer 400, and 700
646    /// nearer 800 although it is past the middle of nothing a page view would recognise.
647    #[test]
648    fn a_slow_release_rests_at_the_nearer_stop() {
649        assert_eq!(snap_target(290.0, 0.0, &STOPS, 1.0), 200.0);
650        assert_eq!(snap_target(310.0, 0.0, &STOPS, 1.0), 400.0);
651        assert_eq!(snap_target(550.0, 0.0, &STOPS, 1.0), 400.0);
652        assert_eq!(snap_target(700.0, 0.5, &STOPS, 1.0), 800.0);
653    }
654
655    /// **Any flick goes to the next stop the way it went**, however little of the way it
656    /// had come — and never further than the next one, however hard.
657    #[test]
658    fn a_flick_goes_to_the_next_stop_its_way_and_no_further() {
659        assert_eq!(snap_target(210.0, 50.0, &STOPS, 1.0), 400.0);
660        assert_eq!(snap_target(790.0, -50.0, &STOPS, 1.0), 400.0);
661        assert_eq!(snap_target(210.0, 9000.0, &STOPS, 1.0), 400.0);
662        assert_eq!(snap_target(390.0, -9000.0, &STOPS, 1.0), 200.0);
663    }
664
665    /// On a stop it stays, whichever way it was nudged; outside them it comes back to the
666    /// nearest end; with no stops it rests where it is.
667    #[test]
668    fn a_stop_holds_and_the_ends_bound() {
669        assert_eq!(snap_target(400.0, 500.0, &STOPS, 1.0), 400.0);
670        assert_eq!(snap_target(150.0, -500.0, &STOPS, 1.0), 200.0);
671        assert_eq!(snap_target(900.0, 500.0, &STOPS, 1.0), 800.0);
672        assert_eq!(snap_target(333.0, 500.0, &[], 1.0), 333.0);
673    }
674
675    #[test]
676    fn inside_the_content_every_pixel_of_finger_reaches_the_offset() {
677        let metrics = ScrollMetrics::new(120.0, 400.0, 600.0);
678        for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
679            assert_eq!(physics.apply_user_offset(metrics, 25.0), 25.0);
680            assert_eq!(physics.apply_user_offset(metrics, -25.0), -25.0);
681        }
682    }
683
684    #[test]
685    fn bouncing_resists_more_the_further_out_you_pull() {
686        let viewport = 600.0;
687        let near = ScrollMetrics::new(-10.0, 400.0, viewport);
688        let far = ScrollMetrics::new(-200.0, 400.0, viewport);
689        // Pulling further out (a negative delta past the start) is resisted, and
690        // resisted harder the further out we already are.
691        let a = ScrollPhysics::BOUNCING.apply_user_offset(near, -20.0);
692        let b = ScrollPhysics::BOUNCING.apply_user_offset(far, -20.0);
693        assert!(a < 0.0 && b < 0.0);
694        assert!(a.abs() < 20.0, "the band already resists: {a}");
695        assert!(b.abs() < a.abs(), "further out must be harder: {b} vs {a}");
696        // Clamping physics has no band at all.
697        assert_eq!(ScrollPhysics::CLAMPING.apply_user_offset(far, -20.0), -20.0);
698    }
699
700    #[test]
701    fn bouncing_lets_you_ease_back_more_easily_than_you_pulled() {
702        let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
703        let out = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
704        let back = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
705        assert!(
706            back.abs() > out.abs(),
707            "releasing the band ({back}) should be easier than loading it ({out})"
708        );
709    }
710
711    #[test]
712    fn clamping_refuses_exactly_what_would_leave_the_content() {
713        let physics = ScrollPhysics::CLAMPING;
714        let metrics = ScrollMetrics::new(390.0, 400.0, 600.0);
715        // A move that stays inside is untouched.
716        assert_eq!(physics.apply_boundary_conditions(metrics, 395.0), 0.0);
717        // One that crosses the end is trimmed to the end.
718        assert_eq!(physics.apply_boundary_conditions(metrics, 430.0), 30.0);
719        // Already at the end, pushing further: the whole move is refused.
720        let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
721        assert_eq!(physics.apply_boundary_conditions(at_end, 420.0), 20.0);
722        // The start behaves symmetrically.
723        let near_start = ScrollMetrics::new(5.0, 400.0, 600.0);
724        assert_eq!(physics.apply_boundary_conditions(near_start, -15.0), -15.0);
725        // Bouncing physics refuses nothing.
726        assert_eq!(
727            ScrollPhysics::BOUNCING.apply_boundary_conditions(at_end, 420.0),
728            0.0
729        );
730    }
731
732    #[test]
733    fn a_slow_release_flings_nothing() {
734        let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
735        assert!(ScrollPhysics::CLAMPING.ballistic(metrics, 1.0).is_none());
736        assert!(ScrollPhysics::BOUNCING.ballistic(metrics, 1.0).is_none());
737    }
738
739    #[test]
740    fn clamping_fling_stops_dead_inside_the_content() {
741        let metrics = ScrollMetrics::new(100.0, 400.0, 600.0);
742        let sim = ScrollPhysics::CLAMPING.ballistic(metrics, 800.0).unwrap();
743        assert!(matches!(sim, Ballistic::Clamping(_)));
744        // It travels, then finishes; the runtime clamps the tail into the content.
745        assert!(sim.x(0.1) > 100.0);
746        assert!(sim.is_done(2.0));
747        assert!(sim.dx(2.0).abs() < 1.0, "no residual speed");
748    }
749
750    #[test]
751    fn clamping_at_the_edge_pushing_outwards_does_nothing() {
752        let at_end = ScrollMetrics::new(400.0, 400.0, 600.0);
753        assert!(ScrollPhysics::CLAMPING.ballistic(at_end, 900.0).is_none());
754        let at_start = ScrollMetrics::new(0.0, 400.0, 600.0);
755        assert!(ScrollPhysics::CLAMPING
756            .ballistic(at_start, -900.0)
757            .is_none());
758    }
759
760    #[test]
761    fn clamping_out_of_range_returns_to_the_edge() {
762        // Overscroll can still happen without a drag — the content shrank under a
763        // resting offset. Clamping physics springs it home rather than flinging.
764        let past = ScrollMetrics::new(460.0, 400.0, 600.0);
765        let sim = ScrollPhysics::CLAMPING.ballistic(past, 0.0).unwrap();
766        assert!(matches!(sim, Ballistic::Spring(_)));
767        assert!(
768            (sim.x(2.0) - 400.0).abs() < 0.5,
769            "settles at {}",
770            sim.x(2.0)
771        );
772    }
773
774    #[test]
775    fn bouncing_overscroll_springs_back_even_with_no_velocity() {
776        let past = ScrollMetrics::new(-40.0, 400.0, 600.0);
777        let sim = ScrollPhysics::BOUNCING.ballistic(past, 0.0).unwrap();
778        assert!((sim.x(2.0) - 0.0).abs() < 0.5, "settles at {}", sim.x(2.0));
779        assert!(sim.is_done(2.0));
780    }
781
782    #[test]
783    fn a_fling_never_exceeds_the_velocity_cap() {
784        let metrics = ScrollMetrics::new(0.0, 100_000.0, 600.0);
785        let sim = ScrollPhysics::CLAMPING
786            .ballistic(metrics, 50_000.0)
787            .unwrap();
788        assert!(
789            sim.dx(0.0) <= MAX_FLING_VELOCITY + 1.0,
790            "clipped to the cap, got {}",
791            sim.dx(0.0)
792        );
793    }
794
795    #[test]
796    fn bouncing_asks_for_a_more_deliberate_gesture() {
797        assert!(
798            ScrollPhysics::BOUNCING.min_fling_velocity()
799                > ScrollPhysics::CLAMPING.min_fling_velocity()
800        );
801        // Repeated swipes build speed only where the platform does.
802        assert!(ScrollPhysics::BOUNCING.carried_momentum(1000.0) > 0.0);
803        assert_eq!(ScrollPhysics::CLAMPING.carried_momentum(1000.0), 0.0);
804        // …and the carry is signed like the motion it continues, and capped.
805        assert!(ScrollPhysics::BOUNCING.carried_momentum(-1000.0) < 0.0);
806        assert!(ScrollPhysics::BOUNCING.carried_momentum(100_000.0) <= 40_000.0);
807    }
808
809    #[test]
810    fn metrics_report_how_far_out_of_range_they_are() {
811        let inside = ScrollMetrics::new(50.0, 400.0, 600.0);
812        assert!(!inside.out_of_range());
813        assert_eq!(inside.overscroll_past(), 0.0);
814        let before = ScrollMetrics::new(-12.0, 400.0, 600.0);
815        assert!(before.out_of_range());
816        assert_eq!(before.overscroll_past(), 12.0);
817        let after = ScrollMetrics::new(430.0, 400.0, 600.0);
818        assert!(after.out_of_range());
819        assert_eq!(after.overscroll_past(), 30.0);
820    }
821
822    /// A three-page view, 300 px per page: offsets 0, 300, 600.
823    fn paged(pixels: f32) -> ScrollMetrics {
824        ScrollMetrics::new(pixels, 600.0, 300.0)
825    }
826
827    #[test]
828    fn letting_go_slowly_falls_back_to_the_nearer_page() {
829        assert_eq!(page_target(paged(130.0), 300.0, 0.0, 2.0), 0.0);
830        assert_eq!(page_target(paged(170.0), 300.0, 0.0, 2.0), 300.0);
831    }
832
833    #[test]
834    fn a_flick_turns_the_page_however_short_it_was() {
835        // A tenth of a page, thrown forward: the page turns.
836        assert_eq!(page_target(paged(30.0), 300.0, 900.0, 2.0), 300.0);
837        // And the same distance thrown back stays put rather than going forward.
838        assert_eq!(page_target(paged(30.0), 300.0, -900.0, 2.0), 0.0);
839        // Dragged most of the way and flicked back: the flick wins over the drag.
840        assert_eq!(page_target(paged(260.0), 300.0, -900.0, 2.0), 0.0);
841    }
842
843    #[test]
844    fn a_page_target_never_lands_outside_the_content() {
845        // Pages narrower than the viewport: the last one is not on a boundary.
846        let metrics = ScrollMetrics::new(430.0, 450.0, 300.0);
847        assert_eq!(page_target(metrics, 240.0, 900.0, 2.0), 450.0);
848        assert_eq!(
849            page_target(ScrollMetrics::new(5.0, 600.0, 300.0), 300.0, -900.0, 2.0),
850            0.0
851        );
852    }
853
854    #[test]
855    fn a_paged_release_always_settles_on_a_page() {
856        for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
857            // Released mid-page with no speed at all: it still has to go somewhere.
858            let sim = physics.page_ballistic(paged(190.0), 0.0, 300.0).unwrap();
859            assert!(
860                (sim.x(3.0) - 300.0).abs() < 1.0,
861                "settled at {}",
862                sim.x(3.0)
863            );
864            // A hard fling crosses exactly one page, not three.
865            let sim = physics.page_ballistic(paged(10.0), 6000.0, 300.0).unwrap();
866            assert!(
867                (sim.x(3.0) - 300.0).abs() < 1.0,
868                "settled at {}",
869                sim.x(3.0)
870            );
871        }
872    }
873
874    #[test]
875    fn resting_on_a_page_starts_nothing() {
876        assert!(ScrollPhysics::CLAMPING
877            .page_ballistic(paged(300.0), 0.0, 300.0)
878            .is_none());
879    }
880
881    #[test]
882    fn past_an_edge_the_ordinary_physics_takes_the_content_home() {
883        // No page out there: bouncing springs back, clamping has nothing to do.
884        let out = ScrollMetrics::new(-40.0, 600.0, 300.0);
885        let sim = ScrollPhysics::BOUNCING
886            .page_ballistic(out, -50.0, 300.0)
887            .unwrap();
888        assert!((sim.x(3.0) - 0.0).abs() < 1.0, "settled at {}", sim.x(3.0));
889        assert!(ScrollPhysics::CLAMPING
890            .page_ballistic(ScrollMetrics::new(0.0, 600.0, 300.0), -900.0, 300.0)
891            .is_none());
892    }
893
894    // --- The second deceleration profile (milestone 499) ---
895
896    /// **The profile is a property of the device, not of the speed** — which is
897    /// the decision this milestone turns on, and the opposite of what #55 asked for.
898    ///
899    /// A finger's fling coasts: drag multiplies the velocity down each second and
900    /// only reaches nought at infinity, which is exactly what a thrown surface does.
901    /// A trackpad's stops. If the two were chosen by velocity, a hard fling and a
902    /// gentle one on the same device would end differently — so this asks the same
903    /// physics at two speeds three orders of magnitude apart and requires the same
904    /// kind of ending from both.
905    #[test]
906    fn the_profile_is_the_devices_and_never_the_speeds() {
907        // Content long enough that no fling here reaches an edge: what is being
908        // compared is the deceleration, and a spring taking over would be a
909        // different motion answering a different question.
910        let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
911        // Five seconds: long past where the fast profile has come to rest, and
912        // short of where `0.135^t` underflows a 32-bit float — which it does around
913        // twenty, and which would make a coasting fling look stopped for a reason
914        // that is about the arithmetic rather than about the physics.
915        let still_moving = |physics: ScrollPhysics, v: f32| {
916            let sim = physics.ballistic(metrics, v).expect("a fling");
917            sim.dx(5.0).abs() > 0.0
918        };
919        // Both within the finger cap, so the two speeds differ by nothing but
920        // themselves — the cap is one of the things the profiles disagree about.
921        for velocity in [300.0, 7000.0] {
922            assert!(
923                still_moving(ScrollPhysics::BOUNCING, velocity),
924                "a finger coasts, at {velocity} as at any other speed"
925            );
926            assert!(
927                !still_moving(
928                    ScrollPhysics::bouncing(ScrollDecelerationRate::Fast),
929                    velocity
930                ),
931                "and a trackpad stops, at {velocity} as at any other speed"
932            );
933        }
934    }
935
936    /// And the stop is not merely eventual: the fast profile comes to rest **short
937    /// of** where coasting would have carried it, which is the whole reason a
938    /// desktop list does not drift on after the hand has left the trackpad.
939    #[test]
940    fn a_trackpad_fling_stops_short_of_where_a_finger_would_coast() {
941        let metrics = ScrollMetrics::new(200.0, 40_000.0, 600.0);
942        let travel = |physics: ScrollPhysics| {
943            let sim = physics.ballistic(metrics, 3000.0).expect("a fling");
944            sim.x(30.0) - 200.0
945        };
946        let finger = travel(ScrollPhysics::BOUNCING);
947        let trackpad = travel(ScrollPhysics::bouncing(ScrollDecelerationRate::Fast));
948        assert!(finger > 0.0 && trackpad > 0.0, "both travel forwards");
949        assert!(
950            trackpad < finger,
951            "the trackpad stops short: {trackpad} vs {finger}"
952        );
953    }
954
955    /// **The fast band is twice as stiff.** A trackpad reports a great deal of
956    /// motion for a small gesture, and a band as slack as a finger's would let one
957    /// flick throw the content a long way out of its content.
958    #[test]
959    fn the_fast_band_resists_twice_as_hard() {
960        let metrics = ScrollMetrics::new(-10.0, 400.0, 600.0);
961        let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, -20.0);
962        let trackpad =
963            ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, -20.0);
964        assert!(finger < 0.0 && trackpad < 0.0, "both pull further out");
965        assert!(
966            (trackpad / finger - 0.5).abs() < 0.05,
967            "half as much movement for the same gesture: {trackpad} vs {finger}"
968        );
969    }
970
971    /// **Easing back out of an overscroll is free on a trackpad**, and this is the
972    /// one difference that is a behaviour rather than a number.
973    ///
974    /// A finger that pulled a list past its end is still holding the band, and
975    /// letting it back gradually is what tension feels like. A trackpad is holding
976    /// nothing; the same stickiness reads as the content refusing to come back.
977    #[test]
978    fn easing_out_of_an_overscroll_is_unresisted_on_a_trackpad() {
979        let metrics = ScrollMetrics::new(-100.0, 400.0, 600.0);
980        // Past the start, a positive delta walks back towards the content.
981        let finger = ScrollPhysics::BOUNCING.apply_user_offset(metrics, 20.0);
982        let trackpad =
983            ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).apply_user_offset(metrics, 20.0);
984        assert_eq!(trackpad, 20.0, "every pixel of the gesture comes back");
985        assert!(
986            finger < 20.0,
987            "where a finger still meets some resistance: {finger}"
988        );
989        // And tensioning is still resisted on both — it is the direction that
990        // differs, not the family.
991        assert!(
992            ScrollPhysics::bouncing(ScrollDecelerationRate::Fast)
993                .apply_user_offset(metrics, -20.0)
994                .abs()
995                < 20.0,
996            "pulling further out is still resisted"
997        );
998    }
999
1000    /// A pointing device reports speeds no fingertip reaches, so the fast profile
1001    /// lifts the ceiling rather than clipping what the hardware actually said.
1002    #[test]
1003    fn a_trackpad_may_be_flung_eight_times_as_hard() {
1004        assert_eq!(
1005            ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).max_fling_velocity(),
1006            ScrollPhysics::BOUNCING.max_fling_velocity() * 8.0
1007        );
1008        assert_eq!(
1009            ScrollPhysics::CLAMPING.max_fling_velocity(),
1010            ScrollPhysics::BOUNCING.max_fling_velocity(),
1011            "and the clamping family is untouched"
1012        );
1013    }
1014
1015    /// **The finger profile is exactly what it always was.** Everything above adds
1016    /// a second answer; none of it may change the first, which is what every
1017    /// hand-held build of this framework runs.
1018    #[test]
1019    fn the_finger_profile_is_unchanged() {
1020        assert_eq!(friction_factor(ScrollDecelerationRate::Normal, 0.0), 0.52);
1021        assert_eq!(
1022            ScrollPhysics::BOUNCING.max_fling_velocity(),
1023            MAX_FLING_VELOCITY
1024        );
1025        // Its fling carries no constant deceleration, so it coasts — the property
1026        // the closed-form friction has always had.
1027        let metrics = ScrollMetrics::new(200.0, 200_000.0, 600.0);
1028        let sim = ScrollPhysics::BOUNCING
1029            .ballistic(metrics, 3000.0)
1030            .expect("a fling");
1031        assert!(
1032            sim.dx(5.0).abs() > 0.0,
1033            "still creeping at five seconds, as before"
1034        );
1035    }
1036
1037    /// **Never and always lie over the platform's physics** (milestone 649), as the
1038    /// reference's `applyTo` does: what a physics settles it keeps, what it leaves open the
1039    /// platform decides.
1040    #[test]
1041    fn never_and_always_lie_over_the_platform() {
1042        let ios = ScrollPhysics::BOUNCING;
1043        let android = ScrollPhysics::CLAMPING;
1044        assert_eq!(ScrollPhysics::NEVER.user(), UserScroll::Never);
1045        assert_eq!(ScrollPhysics::ALWAYS.user(), UserScroll::Always);
1046        assert_eq!(ios.user(), UserScroll::WhenOverflowing, "the default rule");
1047
1048        let never = ScrollPhysics::NEVER.apply_to(ios);
1049        assert_eq!(never.user(), UserScroll::Never);
1050        assert_eq!(
1051            never.feel(),
1052            ScrollFeel::Bouncing(ScrollDecelerationRate::Normal)
1053        );
1054        assert!(
1055            never.allows_overscroll(),
1056            "a programmatic overshoot still bounces"
1057        );
1058        let always = ScrollPhysics::ALWAYS.apply_to(android);
1059        assert_eq!(
1060            (always.user(), always.feel()),
1061            (UserScroll::Always, ScrollFeel::Clamping)
1062        );
1063
1064        // A feel given keeps itself, and takes the platform's rule.
1065        let given = ScrollPhysics::BOUNCING.apply_to(android.always_scrollable());
1066        assert_eq!(
1067            given.feel(),
1068            ScrollFeel::Bouncing(ScrollDecelerationRate::Normal)
1069        );
1070        assert_eq!(given.user(), UserScroll::Always);
1071        // A rule given keeps itself over the platform's.
1072        let kept = ScrollPhysics::CLAMPING
1073            .never_scrollable()
1074            .apply_to(ios.always_scrollable());
1075        assert_eq!(
1076            (kept.user(), kept.feel()),
1077            (UserScroll::Never, ScrollFeel::Clamping)
1078        );
1079        // Nothing settled at all is clamping, and the default rule.
1080        let open = ScrollPhysics::NEVER;
1081        assert_eq!(open.feel(), ScrollFeel::Clamping);
1082        assert_eq!(
1083            ScrollPhysics::bouncing(ScrollDecelerationRate::Fast).deceleration_rate(),
1084            Some(ScrollDecelerationRate::Fast)
1085        );
1086    }
1087}