Skip to main content

teksilo_core/kinetic/
simulation.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The two scroll physics, and the rubber band.
5//!
6//! A fling is a closed-form function of time: given a release position and a
7//! release velocity, [`ScrollSimulation`] answers where the content is at any
8//! `t`, how fast it is going, and whether it has come to rest. Nothing here
9//! holds a clock, a widget or a frame; the driver in
10//! [`scroller`](super::scroller) advances `t` and reads the answers.
11//!
12//! Two families ship, because the two desktop conventions genuinely differ and
13//! neither is a tuning of the other:
14//!
15//! - [`ClampingSimulation`] — **Android `OverScroller`**. A friction spline
16//!   that decelerates to a stop, hard-stopping at the boundary. This is the
17//!   Windows / GTK / Android feel, and it is what
18//!   [`OverscrollStyle::Clamp`](teksilo_tokens::OverscrollStyle::Clamp)
19//!   selects.
20//! - [`BouncingSimulation`] — **Flutter `BouncingScrollSimulation`**, i.e. the
21//!   iOS `UIScrollView` feel. Exponential decay while the content is in range,
22//!   handing off to a spring the moment it crosses a boundary, so the content
23//!   overshoots and is pulled back. Selected by
24//!   [`OverscrollStyle::RubberBand`](teksilo_tokens::OverscrollStyle::RubberBand).
25//!
26//! [`rubber_band`] is the third piece and belongs to neither: it is the
27//! *drag-time* companion to the bouncing simulation, the falling-gain curve
28//! that makes content resist the finger past the edge. The fling never uses
29//! it — past the edge, the spring is the return.
30//!
31//! # Why the constants are reproduced rather than invented
32//!
33//! Every number below is traceable to Android's `OverScroller.java` or
34//! Flutter's `scroll_simulation.dart` / `spring_simulation.dart`, and each is
35//! cited at its definition with the arithmetic that produces it. Scroll feel is
36//! a thing users have twenty years of muscle memory for; a curve that is merely
37//! *plausible* reads as wrong, and there is no way to review "plausible". A
38//! reader must be able to put this file next to the upstream source and check
39//! it line by line, which is why the Android spline is rebuilt by the same
40//! bisection rather than approximated by a cubic fit.
41//!
42//! Reference: `docs/kinetic-scrolling.md`.
43
44use std::sync::OnceLock;
45use std::time::Duration;
46
47use teksilo_tokens::ScrollPhysicsTokens;
48
49// ---------------------------------------------------------------------------
50// The trait
51// ---------------------------------------------------------------------------
52
53/// A closed-form scroll animation over one axis.
54///
55/// Implementors are pure: `position(t)` for the same `t` always answers the
56/// same thing, and nothing is mutated by asking. That is what lets a test
57/// evaluate a whole fling without a frame loop, and what lets the driver skip
58/// frames without drift.
59///
60/// `Debug` is a supertrait so the driver types that box a simulation stay
61/// printable — the same bargain [`Widget`](crate::widget::Widget) makes.
62pub trait ScrollSimulation: std::fmt::Debug {
63    /// Where the content is at `t`, in logical pixels.
64    fn position(&self, t: Duration) -> f32;
65    /// How fast the content is moving at `t`, in logical pixels per second.
66    fn velocity(&self, t: Duration) -> f32;
67    /// Whether the content has come to rest at or before `t`.
68    fn is_done(&self, t: Duration) -> bool;
69}
70
71// ---------------------------------------------------------------------------
72// Shared tolerances
73// ---------------------------------------------------------------------------
74
75/// Below this displacement the content is considered to have arrived, in
76/// logical pixels.
77///
78/// Flutter derives its scroll tolerance as
79/// `Tolerance(distance: 1.0 / (devicePixelRatio * 10.0), …)`
80/// (`ScrollPhysics.tolerance` via `ScrollContext`). Teksilo works in *logical*
81/// pixels, so the right substitution is `devicePixelRatio == 1`, giving 0.1.
82/// Flutter's `Tolerance.defaultTolerance` (1e-3) is deliberately **not** used:
83/// it is a generic physics tolerance, and at scroll scale it keeps a
84/// simulation nominally alive for seconds after the last visible movement.
85pub const SETTLE_DISTANCE_TOLERANCE: f32 = 0.1;
86
87/// Below this speed the content is considered stopped, in logical pixels per
88/// second.
89///
90/// Same derivation: Flutter's `Tolerance(velocity: 1.0 / (0.050 *
91/// devicePixelRatio), …)` at `devicePixelRatio == 1` is 20 px/s — about a
92/// third of a pixel per 60 Hz frame.
93pub const SETTLE_VELOCITY_TOLERANCE: f32 = 20.0;
94
95/// Turn a `Duration` into seconds without losing sub-millisecond resolution.
96fn secs(t: Duration) -> f32 {
97    t.as_secs_f32()
98}
99
100/// Normalise a possibly-inverted range. An empty range (`min > max`) means the
101/// content fits and there is nowhere to scroll, which pins the offset at `min`.
102fn normalize(min: f32, max: f32) -> (f32, f32) {
103    if min > max { (min, min) } else { (min, max) }
104}
105
106// ---------------------------------------------------------------------------
107// Android OverScroller — the clamping spline
108// ---------------------------------------------------------------------------
109
110/// `SensorManager.GRAVITY_EARTH`, in m/s². Android `OverScroller`'s
111/// `mPhysicalCoeff` opens with this.
112const GRAVITY_EARTH: f64 = 9.806_65;
113
114/// Inches per metre, spelled as Android spells it (`39.37f`, not 39.3701).
115const INCHES_PER_METER: f64 = 39.37;
116
117/// Pixels per inch at Android's density 1.0.
118///
119/// Android computes `ppi = displayMetrics.density * 160`. Teksilo's logical
120/// pixel *is* the density-1 Android pixel — that is what "dp" means — so the
121/// right substitution is density 1.0, i.e. 160 ppi. Flutter makes the same
122/// substitution when it hardcodes `61774.04968` (= 9.80665 × 39.37 × 160) in
123/// `ClampingScrollSimulation._decelerationForFriction`.
124const PPI_AT_DENSITY_ONE: f64 = 160.0;
125
126/// Android's "look and feel tuning" factor, the trailing `0.84f` of
127/// `mPhysicalCoeff`.
128const PHYSICAL_TUNING: f64 = 0.84;
129
130/// `mPhysicalCoeff` = 9.80665 × 39.37 × 160 × 0.84 ≈ 51890.2017.
131fn physical_coeff() -> f64 {
132    GRAVITY_EARTH * INCHES_PER_METER * PPI_AT_DENSITY_ONE * PHYSICAL_TUNING
133}
134
135/// Number of spline samples. Android `NB_SAMPLES`.
136const NB_SAMPLES: usize = 100;
137
138/// Android `START_TENSION`.
139const START_TENSION: f64 = 0.5;
140
141/// Android `END_TENSION`.
142const END_TENSION: f64 = 1.0;
143
144/// Safety valve on the bisection below. Android has none; a bisection that
145/// fails to converge in a GUI framework is a hang, and 64 halvings take an
146/// `f64` interval far below the 1e-5 acceptance band, so the cap can only ever
147/// fire on a pathological float.
148const MAX_BISECTION_STEPS: usize = 64;
149
150/// Android's `SPLINE_POSITION` table, rebuilt by the same bisection.
151///
152/// From the static initialiser of `OverScroller.SplineOverScroller`
153/// (`frameworks/base/core/java/android/widget/OverScroller.java`): for each of
154/// 100 evenly spaced `alpha`, bisect for the `x` whose tension-blended
155/// parameter equals `alpha`, then record the position that `x` implies.
156/// `SPLINE_POSITION[NB_SAMPLES]` is pinned to exactly 1.0, which is what makes
157/// a fling land on its computed distance to the pixel.
158///
159/// Android's companion `SPLINE_TIME` table is **not** reproduced: it serves
160/// `startScroll`'s viscous-fluid interpolator, not the fling, and nothing in
161/// Teksilo reads it.
162///
163/// The tension constants use `ScrollPhysicsTokens::DEFAULT.clamping_inflexion`
164/// (0.35, Android's `INFLEXION`). The table is built once per process and is
165/// *not* rebuilt when a theme overrides that token — Android hardcodes it too,
166/// and the token's live effect is on the deceleration formula below, which is
167/// where it actually changes the feel.
168fn spline_position_table() -> &'static [f32; NB_SAMPLES + 1] {
169    static TABLE: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
170    TABLE.get_or_init(|| {
171        let inflexion = f64::from(ScrollPhysicsTokens::DEFAULT.clamping_inflexion);
172        let p1 = START_TENSION * inflexion;
173        let p2 = 1.0 - END_TENSION * (1.0 - inflexion);
174
175        let mut table = [0.0f32; NB_SAMPLES + 1];
176        // `x_min` deliberately carries between iterations, as in Android: the
177        // solution is monotone in `alpha`, so the previous answer is a valid
178        // lower bound for the next.
179        let mut x_min = 0.0f64;
180        for (i, slot) in table.iter_mut().take(NB_SAMPLES).enumerate() {
181            let alpha = i as f64 / NB_SAMPLES as f64;
182            let mut x_max = 1.0f64;
183            let mut x = 0.0f64;
184            let mut coef = 0.0f64;
185            for _ in 0..MAX_BISECTION_STEPS {
186                x = x_min + (x_max - x_min) / 2.0;
187                coef = 3.0 * x * (1.0 - x);
188                let tx = coef * ((1.0 - x) * p1 + x * p2) + x * x * x;
189                if (tx - alpha).abs() < 1e-5 {
190                    break;
191                }
192                if tx > alpha {
193                    x_max = x;
194                } else {
195                    x_min = x;
196                }
197            }
198            *slot = (coef * ((1.0 - x) * START_TENSION + x) + x * x * x) as f32;
199        }
200        // Both ends are pinned. Android pins only the far one
201        // (`SPLINE_POSITION[NB_SAMPLES] = 1.0`), which is what makes a fling
202        // land exactly on its computed distance; the near end is left at
203        // whatever the bisection's 1e-5 acceptance band produced, about
204        // 2.3e-5. That residue is invisible in Android because it starts the
205        // fling from the current scroll position rather than evaluating the
206        // curve at t = 0 — but a `ScrollSimulation` is asked for `position(0)`
207        // directly, and 2.3e-5 of a 2157 dp fling is a 0.05 dp jump at the
208        // instant the finger lifts. Pinning the near end is the same move
209        // Android already makes at the far one, for the same reason.
210        table[0] = 0.0;
211        table[NB_SAMPLES] = 1.0;
212        table
213    })
214}
215
216/// Android's `update()` interpolation: the fraction of the total distance
217/// covered at `frac ∈ [0, 1]`, and the local slope that yields the velocity.
218fn spline_coefficients(frac: f32) -> (f32, f32) {
219    let table = spline_position_table();
220    // At or past the end — and for NaN, which the explicit arm catches since
221    // every comparison against NaN is false: fully travelled, zero slope.
222    if frac.is_nan() || frac >= 1.0 {
223        return (1.0, 0.0);
224    }
225    let frac = frac.max(0.0);
226    let index = (NB_SAMPLES as f32 * frac) as usize;
227    if index >= NB_SAMPLES {
228        return (1.0, 0.0);
229    }
230    let t_inf = index as f32 / NB_SAMPLES as f32;
231    let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
232    let d_inf = table[index];
233    let d_sup = table[index + 1];
234    let velocity_coef = (d_sup - d_inf) / (t_sup - t_inf);
235    let distance_coef = d_inf + (frac - t_inf) * velocity_coef;
236    (distance_coef, velocity_coef)
237}
238
239/// Android `getSplineDeceleration(velocity)` — `ln(INFLEXION · |v| /
240/// (friction · mPhysicalCoeff))`.
241fn spline_deceleration(velocity: f64, friction: f64, inflexion: f64) -> f64 {
242    (inflexion * velocity.abs() / (friction * physical_coeff())).ln()
243}
244
245/// How long a fling at `velocity` lasts, in seconds.
246///
247/// Android `getSplineFlingDuration`: `exp(l / (DECELERATION_RATE − 1))`.
248/// Returns zero for a velocity that cannot fling (zero, non-finite, or a
249/// non-positive friction token).
250pub fn fling_duration(velocity: f32, tokens: &ScrollPhysicsTokens) -> Duration {
251    let Some(l) = spline_l(velocity, tokens) else {
252        return Duration::ZERO;
253    };
254    let rate = f64::from(tokens.clamping_deceleration_rate);
255    if rate <= 1.0 {
256        return Duration::ZERO;
257    }
258    let seconds = (l / (rate - 1.0)).exp();
259    if seconds.is_finite() && seconds > 0.0 {
260        Duration::from_secs_f64(seconds.min(60.0))
261    } else {
262        Duration::ZERO
263    }
264}
265
266/// How far a fling at `velocity` travels, in logical pixels (unsigned).
267///
268/// Android `getSplineFlingDistance`: `friction · mPhysicalCoeff ·
269/// exp(DECELERATION_RATE / (DECELERATION_RATE − 1) · l)`.
270pub fn fling_distance(velocity: f32, tokens: &ScrollPhysicsTokens) -> f32 {
271    let Some(l) = spline_l(velocity, tokens) else {
272        return 0.0;
273    };
274    let rate = f64::from(tokens.clamping_deceleration_rate);
275    if rate <= 1.0 {
276        return 0.0;
277    }
278    let friction = f64::from(tokens.clamping_friction);
279    let distance = friction * physical_coeff() * (rate / (rate - 1.0) * l).exp();
280    if distance.is_finite() && distance > 0.0 {
281        distance as f32
282    } else {
283        0.0
284    }
285}
286
287/// The shared `l` of the two formulas above, or `None` when the inputs cannot
288/// produce a fling.
289fn spline_l(velocity: f32, tokens: &ScrollPhysicsTokens) -> Option<f64> {
290    let friction = f64::from(tokens.clamping_friction);
291    let inflexion = f64::from(tokens.clamping_inflexion);
292    if !velocity.is_finite() || velocity == 0.0 || friction <= 0.0 || inflexion <= 0.0 {
293        return None;
294    }
295    let l = spline_deceleration(f64::from(velocity), friction, inflexion);
296    l.is_finite().then_some(l)
297}
298
299/// Android `OverScroller`'s friction fling, bounded by a scroll range.
300///
301/// The content decelerates along Android's spline and stops dead at `min` or
302/// `max` — no overshoot, no bounce. This is the [`Clamp`] half of the pair, and
303/// the physics every Teksilo scrollable gets unless its theme or its owner asks
304/// for the other one.
305///
306/// [`Clamp`]: teksilo_tokens::OverscrollStyle::Clamp
307///
308/// ```
309/// use std::time::Duration;
310/// use teksilo_core::kinetic::{ClampingSimulation, ScrollSimulation};
311/// use teksilo_tokens::ScrollPhysicsTokens;
312///
313/// let tokens = ScrollPhysicsTokens::DEFAULT;
314/// let sim = ClampingSimulation::new(0.0, 4000.0, -1.0e6, 1.0e6, &tokens);
315/// // Android's getSplineFlingDistance(4000) — see the tests for the arithmetic.
316/// assert!((sim.position(sim.duration()) - 2156.95).abs() < 1.0);
317/// assert!(sim.is_done(sim.duration()));
318/// ```
319#[derive(Clone, Debug)]
320pub struct ClampingSimulation {
321    start: f32,
322    /// Signed total travel; the spline covers exactly this by `duration`.
323    distance: f32,
324    duration: Duration,
325    duration_secs: f32,
326    min: f32,
327    max: f32,
328}
329
330impl ClampingSimulation {
331    /// A fling released at `position` with `velocity`, confined to
332    /// `[min, max]`.
333    ///
334    /// Pass a very wide range for an unbounded coast — that is what
335    /// [`FlingDriver`](super::FlingDriver) does, since the widget receiving the
336    /// re-dispatched deltas owns the real bounds.
337    pub fn new(
338        position: f32,
339        velocity: f32,
340        min: f32,
341        max: f32,
342        tokens: &ScrollPhysicsTokens,
343    ) -> Self {
344        let (min, max) = normalize(min, max);
345        let duration = fling_duration(velocity, tokens);
346        let magnitude = fling_distance(velocity, tokens);
347        let distance = if velocity < 0.0 {
348            -magnitude
349        } else {
350            magnitude
351        };
352        Self {
353            start: position,
354            distance,
355            duration,
356            duration_secs: duration.as_secs_f32(),
357            min,
358            max,
359        }
360    }
361
362    /// How long the fling runs before the spline itself is spent. The
363    /// simulation may finish earlier by hitting a bound.
364    pub fn duration(&self) -> Duration {
365        self.duration
366    }
367
368    /// The signed distance the spline covers over its whole duration.
369    pub fn distance(&self) -> f32 {
370        self.distance
371    }
372
373    /// Where the spline would be, ignoring the bounds.
374    fn unbounded_position(&self, t: Duration) -> f32 {
375        if self.duration_secs <= 0.0 {
376            return self.start + self.distance;
377        }
378        let frac = secs(t) / self.duration_secs;
379        let (distance_coef, _) = spline_coefficients(frac);
380        self.start + self.distance * distance_coef
381    }
382}
383
384impl ScrollSimulation for ClampingSimulation {
385    fn position(&self, t: Duration) -> f32 {
386        self.unbounded_position(t).clamp(self.min, self.max)
387    }
388
389    fn velocity(&self, t: Duration) -> f32 {
390        if self.duration_secs <= 0.0 || self.is_done(t) {
391            return 0.0;
392        }
393        let frac = secs(t) / self.duration_secs;
394        let (_, velocity_coef) = spline_coefficients(frac);
395        velocity_coef * self.distance / self.duration_secs
396    }
397
398    fn is_done(&self, t: Duration) -> bool {
399        if t >= self.duration {
400            return true;
401        }
402        // A bound reached mid-flight ends the fling: clamping physics does not
403        // bounce, so there is nothing left to animate. Only the bound *in the
404        // direction of travel* counts — a fling released while already sitting
405        // on the near bound is starting, not finishing.
406        let raw = self.unbounded_position(t);
407        if self.distance > 0.0 {
408            raw >= self.max
409        } else if self.distance < 0.0 {
410            raw <= self.min
411        } else {
412            true
413        }
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Flutter's friction simulation
419// ---------------------------------------------------------------------------
420
421/// Flutter `FrictionSimulation` with `constantDeceleration: 0`.
422///
423/// `x(t) = p + v·(dragᵗ − 1)/ln(drag)`, `dx(t) = v·dragᵗ`. The drag constant is
424/// `bouncing_decay_per_second` — 0.135, which Flutter documents as
425/// `UIScrollView.decelerationRate.normal` (0.998) raised to the 1000th power,
426/// i.e. iOS's per-millisecond retention re-expressed per second.
427#[derive(Clone, Copy, Debug)]
428struct FrictionSimulation {
429    drag: f32,
430    drag_log: f32,
431    position: f32,
432    velocity: f32,
433}
434
435impl FrictionSimulation {
436    fn new(drag: f32, position: f32, velocity: f32) -> Self {
437        // A drag outside (0, 1) has no decay; pin it to something inert rather
438        // than producing an infinite or growing coast.
439        let drag = if drag.is_finite() && drag > 0.0 && drag < 1.0 {
440            drag
441        } else {
442            ScrollPhysicsTokens::DEFAULT.bouncing_decay_per_second
443        };
444        Self {
445            drag,
446            drag_log: drag.ln(),
447            position,
448            velocity,
449        }
450    }
451
452    fn x(&self, t: f32) -> f32 {
453        self.position + self.velocity * (self.drag.powf(t) - 1.0) / self.drag_log
454    }
455
456    fn dx(&self, t: f32) -> f32 {
457        self.velocity * self.drag.powf(t)
458    }
459
460    /// Where the coast ends if nothing interrupts it — Flutter's `finalX`.
461    fn final_x(&self) -> f32 {
462        self.position - self.velocity / self.drag_log
463    }
464
465    /// When the coast passes `x`, or `INFINITY` if it never does. Flutter's
466    /// `timeAtX`.
467    fn time_at_x(&self, x: f32) -> f32 {
468        if x == self.position {
469            return 0.0;
470        }
471        let final_x = self.final_x();
472        let unreachable = if self.velocity > 0.0 {
473            x < self.position || x > final_x
474        } else {
475            x > self.position || x < final_x
476        };
477        if self.velocity == 0.0 || unreachable {
478            return f32::INFINITY;
479        }
480        ((self.drag_log * (x - self.position) / self.velocity + 1.0).ln() / self.drag_log).max(0.0)
481    }
482
483    fn is_done(&self, t: f32) -> bool {
484        self.dx(t).abs() < SETTLE_VELOCITY_TOLERANCE
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Flutter's spring simulation
490// ---------------------------------------------------------------------------
491
492/// The closed-form solution of a damped harmonic oscillator, in the three
493/// regimes Flutter's `_SpringSolution` distinguishes.
494#[derive(Clone, Copy, Debug)]
495enum SpringSolution {
496    /// `cmk > 0` — the regime Teksilo's shipped tokens select (damping ratio
497    /// 1.1). Flutter `_OverdampedSolution`.
498    Overdamped { r1: f32, r2: f32, c1: f32, c2: f32 },
499    /// `cmk == 0`. Flutter `_CriticalSolution`.
500    Critical { r: f32, c1: f32, c2: f32 },
501    /// `cmk < 0` — reachable only if an app sets a damping ratio below 1.0.
502    ///
503    /// **Deliberate divergence from Flutter**: Flutter spells the decay rate
504    /// `-(damping / 2.0 * mass)`, i.e. `−(c/2)·m`, where the damped-oscillator
505    /// solution calls for `−c/(2m)`. The two agree only at `mass == 1`, and
506    /// Flutter's default spring has `mass == 1`, which is why the divergence
507    /// has survived there. Teksilo's spring has `mass == 0.5`, so reproducing
508    /// the expression would give a visibly wrong decay; the textbook form is
509    /// used instead. Not exercised by the shipped tokens.
510    Underdamped { w: f32, r: f32, c1: f32, c2: f32 },
511}
512
513impl SpringSolution {
514    fn new(mass: f32, stiffness: f32, damping: f32, distance: f32, velocity: f32) -> Self {
515        let cmk = damping * damping - 4.0 * mass * stiffness;
516        if cmk > 0.0 {
517            let root = cmk.sqrt();
518            let r1 = (-damping - root) / (2.0 * mass);
519            let r2 = (-damping + root) / (2.0 * mass);
520            let c2 = (velocity - r1 * distance) / (r2 - r1);
521            let c1 = distance - c2;
522            SpringSolution::Overdamped { r1, r2, c1, c2 }
523        } else if cmk < 0.0 {
524            let w = (4.0 * mass * stiffness - damping * damping).sqrt() / (2.0 * mass);
525            // See the variant's docs for why this is not Flutter's expression.
526            let r = -damping / (2.0 * mass);
527            SpringSolution::Underdamped {
528                w,
529                r,
530                c1: distance,
531                c2: (velocity - r * distance) / w,
532            }
533        } else {
534            let r = -damping / (2.0 * mass);
535            SpringSolution::Critical {
536                r,
537                c1: distance,
538                c2: velocity - r * distance,
539            }
540        }
541    }
542
543    fn x(&self, t: f32) -> f32 {
544        match *self {
545            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
546                c1 * (r1 * t).exp() + c2 * (r2 * t).exp()
547            }
548            SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * t) * (r * t).exp(),
549            SpringSolution::Underdamped { w, r, c1, c2 } => {
550                (r * t).exp() * (c1 * (w * t).cos() + c2 * (w * t).sin())
551            }
552        }
553    }
554
555    fn dx(&self, t: f32) -> f32 {
556        match *self {
557            SpringSolution::Overdamped { r1, r2, c1, c2 } => {
558                c1 * r1 * (r1 * t).exp() + c2 * r2 * (r2 * t).exp()
559            }
560            SpringSolution::Critical { r, c1, c2 } => {
561                let power = (r * t).exp();
562                r * (c1 + c2 * t) * power + c2 * power
563            }
564            SpringSolution::Underdamped { w, r, c1, c2 } => {
565                let power = (r * t).exp();
566                let cos = (w * t).cos();
567                let sin = (w * t).sin();
568                power * (c2 * w * cos - c1 * w * sin) + r * power * (c2 * sin + c1 * cos)
569            }
570        }
571    }
572}
573
574/// Flutter `ScrollSpringSimulation`: pulls the content back to `end`.
575#[derive(Clone, Copy, Debug)]
576struct SpringSimulation {
577    end: f32,
578    solution: SpringSolution,
579}
580
581impl SpringSimulation {
582    fn new(tokens: &ScrollPhysicsTokens, start: f32, end: f32, velocity: f32) -> Self {
583        let mass = if tokens.spring_mass > 0.0 {
584            tokens.spring_mass
585        } else {
586            ScrollPhysicsTokens::DEFAULT.spring_mass
587        };
588        let stiffness = if tokens.spring_stiffness > 0.0 {
589            tokens.spring_stiffness
590        } else {
591            ScrollPhysicsTokens::DEFAULT.spring_stiffness
592        };
593        // Flutter `SpringDescription.withDampingRatio`:
594        // `damping = ratio · 2 · sqrt(mass · stiffness)`.
595        let damping = tokens.spring_damping_ratio * 2.0 * (mass * stiffness).sqrt();
596        Self {
597            end,
598            solution: SpringSolution::new(mass, stiffness, damping, start - end, velocity),
599        }
600    }
601
602    fn x(&self, t: f32) -> f32 {
603        self.end + self.solution.x(t)
604    }
605
606    fn dx(&self, t: f32) -> f32 {
607        self.solution.dx(t)
608    }
609
610    fn is_done(&self, t: f32) -> bool {
611        self.solution.x(t).abs() < SETTLE_DISTANCE_TOLERANCE
612            && self.solution.dx(t).abs() < SETTLE_VELOCITY_TOLERANCE
613    }
614}
615
616// ---------------------------------------------------------------------------
617// The bouncing simulation
618// ---------------------------------------------------------------------------
619
620/// Flutter `BouncingScrollSimulation` — the iOS `UIScrollView` fling.
621///
622/// While the content is inside `[min, max]` it coasts on exponential decay.
623/// The moment the coast would cross a bound, the simulation hands off to a
624/// spring anchored at that side's rest target, so the content overshoots and is
625/// pulled back. A release that *starts* outside the range skips the coast and
626/// springs immediately.
627///
628/// # The two pairs of bounds
629///
630/// `min`/`max` and `leading`/`trailing` are different questions, and conflating
631/// them is the reason both are parameters:
632///
633/// - **`min` / `max`** — the *scroll extent*: the offsets past which the
634///   content is overscrolled. This is where the coast gives way to the spring.
635/// - **`leading` / `trailing`** — the spring's *rest targets*: where
636///   overscrolled content comes to rest. Normally `leading == min` and
637///   `trailing == max`, which is what [`within`](Self::within) fills in. They
638///   differ when the resting place is not the crossing place — content that
639///   snaps to a sticky header, or to a paged boundary, past the edge it
640///   crossed.
641///
642/// ```
643/// use std::time::Duration;
644/// use teksilo_core::kinetic::{BouncingSimulation, ScrollSimulation};
645///
646/// // Released mid-range with plenty of room: pure exponential decay.
647/// let sim = BouncingSimulation::within(0.0, 1000.0, -1.0e6, 1.0e6);
648/// // Flutter FrictionSimulation: x(1s) = v·(0.135 − 1)/ln(0.135).
649/// assert!((sim.position(Duration::from_secs(1)) - 431.96).abs() < 0.5);
650/// ```
651#[derive(Clone, Copy, Debug)]
652pub struct BouncingSimulation {
653    friction: FrictionSimulation,
654    spring: Option<SpringSimulation>,
655    /// Seconds at which the spring takes over. `INFINITY` = never; `-INFINITY`
656    /// = from the start (the release was already out of range).
657    spring_time: f32,
658}
659
660impl BouncingSimulation {
661    /// The common case: the spring rests exactly at the bound it crossed.
662    pub fn within(position: f32, velocity: f32, min: f32, max: f32) -> Self {
663        Self::new(position, velocity, min, max, min, max)
664    }
665
666    /// A bouncing fling with explicit spring rest targets, using the shipped
667    /// physics tokens. See the type docs for what the two pairs mean.
668    pub fn new(
669        position: f32,
670        velocity: f32,
671        min: f32,
672        max: f32,
673        leading: f32,
674        trailing: f32,
675    ) -> Self {
676        Self::with_tokens(
677            position,
678            velocity,
679            min,
680            max,
681            leading,
682            trailing,
683            &ScrollPhysicsTokens::DEFAULT,
684        )
685    }
686
687    /// [`new`](Self::new) against a theme's own physics tokens.
688    pub fn with_tokens(
689        position: f32,
690        velocity: f32,
691        min: f32,
692        max: f32,
693        leading: f32,
694        trailing: f32,
695        tokens: &ScrollPhysicsTokens,
696    ) -> Self {
697        let (min, max) = normalize(min, max);
698        let friction =
699            FrictionSimulation::new(tokens.bouncing_decay_per_second, position, velocity);
700
701        // Released already out of range: spring from here, no coast at all.
702        if position < min {
703            return Self {
704                friction,
705                spring: Some(SpringSimulation::new(tokens, position, leading, velocity)),
706                spring_time: f32::NEG_INFINITY,
707            };
708        }
709        if position > max {
710            return Self {
711                friction,
712                spring: Some(SpringSimulation::new(tokens, position, trailing, velocity)),
713                spring_time: f32::NEG_INFINITY,
714            };
715        }
716
717        // In range: coast, and hand off only if the coast would leave it.
718        let final_x = friction.final_x();
719        if velocity > 0.0 && final_x > max {
720            let t = friction.time_at_x(max);
721            if t.is_finite() {
722                let transfer = cap_magnitude(friction.dx(t), MAX_SPRING_TRANSFER_VELOCITY);
723                return Self {
724                    friction,
725                    spring: Some(SpringSimulation::new(tokens, max, trailing, transfer)),
726                    spring_time: t,
727                };
728            }
729        } else if velocity < 0.0 && final_x < min {
730            let t = friction.time_at_x(min);
731            if t.is_finite() {
732                let transfer = cap_magnitude(friction.dx(t), MAX_SPRING_TRANSFER_VELOCITY);
733                return Self {
734                    friction,
735                    spring: Some(SpringSimulation::new(tokens, min, leading, transfer)),
736                    spring_time: t,
737                };
738            }
739        }
740
741        Self {
742            friction,
743            spring: None,
744            spring_time: f32::INFINITY,
745        }
746    }
747
748    /// When the spring takes over, in seconds; `INFINITY` when it never does.
749    pub fn spring_time(&self) -> f32 {
750        self.spring_time
751    }
752
753    /// Whether `t` is in the spring phase, and the time offset to apply.
754    fn phase(&self, t: f32) -> (bool, f32) {
755        if self.spring.is_some() && t > self.spring_time {
756            let offset = if self.spring_time.is_finite() {
757                self.spring_time
758            } else {
759                0.0
760            };
761            (true, offset)
762        } else {
763            (false, 0.0)
764        }
765    }
766}
767
768/// Flutter `BouncingScrollSimulation.maxSpringTransferVelocity`: however fast
769/// the content is going when it reaches the edge, the spring is never handed
770/// more than this, so a violent flick does not launch the content into the
771/// next county before it comes back.
772///
773/// Flutter applies it with a bare `math.min`, which reads as a cap on a
774/// quantity it has already made positive; Teksilo caps the **magnitude** of the
775/// signed velocity instead, so the leading and trailing hand-offs are one
776/// expression and the spring is always launched in the direction the content
777/// was actually travelling.
778const MAX_SPRING_TRANSFER_VELOCITY: f32 = 5000.0;
779
780/// `v`, with its magnitude limited to `limit` and its sign preserved.
781fn cap_magnitude(v: f32, limit: f32) -> f32 {
782    v.clamp(-limit, limit)
783}
784
785impl ScrollSimulation for BouncingSimulation {
786    fn position(&self, t: Duration) -> f32 {
787        let t = secs(t);
788        match self.phase(t) {
789            (true, offset) => self
790                .spring
791                .expect("phase() only reports the spring when there is one")
792                .x(t - offset),
793            (false, _) => self.friction.x(t),
794        }
795    }
796
797    fn velocity(&self, t: Duration) -> f32 {
798        let t = secs(t);
799        match self.phase(t) {
800            (true, offset) => self
801                .spring
802                .expect("phase() only reports the spring when there is one")
803                .dx(t - offset),
804            (false, _) => self.friction.dx(t),
805        }
806    }
807
808    fn is_done(&self, t: Duration) -> bool {
809        let t = secs(t);
810        match self.phase(t) {
811            (true, offset) => self
812                .spring
813                .expect("phase() only reports the spring when there is one")
814                .is_done(t - offset),
815            (false, _) => self.friction.is_done(t),
816        }
817    }
818}
819
820// ---------------------------------------------------------------------------
821// Rubber band
822// ---------------------------------------------------------------------------
823
824/// How far content actually moves when the finger drags it `offset` past the
825/// edge of a viewport `extent` pixels long.
826///
827/// This is the *drag-time* half of the bouncing feel; the fling never uses it
828/// (past the edge, the spring is the return). It is what makes the content
829/// resist: the first pixel past the boundary moves the content about half a
830/// pixel, and the resistance grows until the content asymptotically refuses to
831/// move at all.
832///
833/// # The formula, and why the two upstream statements agree
834///
835/// Flutter's `BouncingScrollPhysics` states the rule as a *gain*:
836/// `frictionFactor(f) = 0.52 · (1 − f)²`, where `f` is how far past the edge
837/// the content already is as a fraction of the viewport. Integrating that gain
838/// over the finger's travel `x`:
839///
840/// ```text
841///   du/dx = k·(1 − u)² / extent          u = damped/extent, k = 0.52
842///   ⇒ 1/(1 − u) − 1 = k·x/extent
843///   ⇒ damped = extent · (1 − 1/(1 + k·x/extent))
844/// ```
845///
846/// which is exactly the closed form iOS `UIScrollView` is documented to use,
847/// `(1 − 1/(x·c/d + 1))·d`, with Flutter's `c = 0.52` in place of Apple's 0.55.
848/// So the two upstream descriptions are one curve, and this function is it.
849///
850/// # Guarantees
851///
852/// - `rubber_band(0, e) == 0` — the identity at zero, so a drag that has not
853///   left the range is untouched.
854/// - Monotone non-decreasing in `offset`, and odd: `f(−x) == −f(x)`.
855/// - `|rubber_band(x, e)| < e` for every finite `x` — the content can never be
856///   dragged a full viewport past the edge.
857/// - A non-finite `offset`, or a non-positive or non-finite `extent`, yields
858///   zero rather than a NaN. (A NaN here would propagate straight into a
859///   layout offset; the workspace has two recorded bugs of exactly that shape,
860///   see `docs/property-testing.md`.)
861pub fn rubber_band(offset: f32, extent: f32) -> f32 {
862    rubber_band_with(
863        offset,
864        extent,
865        ScrollPhysicsTokens::DEFAULT.rubber_band_factor,
866    )
867}
868
869/// [`rubber_band`] against a theme's own `rubber_band_factor`.
870pub fn rubber_band_with(offset: f32, extent: f32, factor: f32) -> f32 {
871    if offset.is_nan()
872        || !extent.is_finite()
873        || extent <= 0.0
874        || !factor.is_finite()
875        || factor <= 0.0
876    {
877        return 0.0;
878    }
879    if offset.is_infinite() {
880        // The limit of the curve, so the function stays continuous rather than
881        // falling off a cliff at infinity.
882        return extent.copysign(offset);
883    }
884    // In `f64`: `1 - 1/(1 + small)` cancels catastrophically in single
885    // precision, and the error lands squarely on the gain near the origin —
886    // which is the one place the curve's agreement with Flutter's
887    // `frictionFactor` is checkable.
888    let x = f64::from(offset.abs());
889    let extent = f64::from(extent);
890    let factor = f64::from(factor);
891    let damped = (extent * (1.0 - 1.0 / (factor * x / extent + 1.0))) as f32;
892    if offset < 0.0 { -damped } else { damped }
893}
894
895/// The inverse of [`rubber_band`]: how far the finger travelled to produce a
896/// damped displacement of `damped`.
897///
898/// Needed whenever a drag *resumes* from a position the physics put there — a
899/// fling that left the content overscrolled, then a finger grabbing it — since
900/// the drag has to continue from the right point on the curve rather than
901/// restart at the origin of it.
902///
903/// ```text
904///   damped = e·(1 − 1/(k·x/e + 1))   ⇒   x = e·damped / (k·(e − damped))
905/// ```
906///
907/// Saturates: `|damped|` is treated as at most `extent·(1 − 1e-4)` so the pole
908/// at the asymptote cannot return an infinity.
909pub fn rubber_band_inverse(damped: f32, extent: f32) -> f32 {
910    rubber_band_inverse_with(
911        damped,
912        extent,
913        ScrollPhysicsTokens::DEFAULT.rubber_band_factor,
914    )
915}
916
917/// [`rubber_band_inverse`] against a theme's own `rubber_band_factor`.
918pub fn rubber_band_inverse_with(damped: f32, extent: f32, factor: f32) -> f32 {
919    if !damped.is_finite()
920        || !extent.is_finite()
921        || extent <= 0.0
922        || !factor.is_finite()
923        || factor <= 0.0
924    {
925        return 0.0;
926    }
927    let y = f64::from(damped.abs().min(extent * (1.0 - 1e-4)));
928    let extent = f64::from(extent);
929    let factor = f64::from(factor);
930    let raw = (extent * y / (factor * (extent - y))) as f32;
931    if damped < 0.0 { -raw } else { raw }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    fn tokens() -> ScrollPhysicsTokens {
939        ScrollPhysicsTokens::DEFAULT
940    }
941
942    // --- The spline table -------------------------------------------------
943
944    /// Android pins both ends of `SPLINE_POSITION`, and the curve between them
945    /// is a distance fraction, so it must be monotone and stay in `[0, 1]`.
946    #[test]
947    fn the_spline_table_runs_from_zero_to_one_monotonically() {
948        let table = spline_position_table();
949        // Both ends pinned — see the table's construction for why the near
950        // one is pinned here but not in Android.
951        assert_eq!(table[0], 0.0, "SPLINE_POSITION[0]");
952        assert_eq!(table[NB_SAMPLES], 1.0, "SPLINE_POSITION[NB_SAMPLES]");
953        // The pin must not be papering over a wrong curve: the first sample
954        // Android computes is ~2.3e-5, so the second entry has to be small.
955        assert!(table[1] < 0.05, "SPLINE_POSITION[1] = {}", table[1]);
956        for w in table.windows(2) {
957            assert!(
958                w[1] >= w[0],
959                "the distance fraction went backwards: {} then {}",
960                w[0],
961                w[1]
962            );
963        }
964        assert!(table.iter().all(|d| (0.0..=1.0).contains(d)));
965    }
966
967    // --- Closed-form Android reference values -----------------------------
968
969    /// The one input for which Android's spline formulas have an *exact*
970    /// answer, so it needs no tolerance argument at all.
971    ///
972    /// `getSplineDeceleration(v) = ln(INFLEXION·|v| / (friction·mPhysicalCoeff))`
973    /// is zero exactly when `INFLEXION·v == friction·mPhysicalCoeff`. At that
974    /// velocity:
975    ///
976    /// - `getSplineFlingDuration = exp(0 / (DECELERATION_RATE − 1)) = 1` second;
977    /// - `getSplineFlingDistance = friction·mPhysicalCoeff · exp(0) =
978    ///   friction·mPhysicalCoeff`.
979    ///
980    /// With Teksilo's tokens: `mPhysicalCoeff = 9.80665 × 39.37 × 160 × 0.84 =
981    /// 51890.2017`, so `friction·mPhysicalCoeff = 0.015 × 51890.2017 =
982    /// 778.3530`, and the velocity is `778.3530 / 0.35 = 2223.8658` dp/s.
983    #[test]
984    fn the_spline_formulas_match_android_at_their_exact_point() {
985        let t = tokens();
986        // 0.015 × 51890.2017312 = 778.353025968 in exact arithmetic; the
987        // token is an `f32`, whose nearest value to 0.015 is 0.0149999997, so
988        // the product lands 1.7e-8 relative below the decimal.
989        let scaled_friction = f64::from(t.clamping_friction) * physical_coeff();
990        assert!(
991            (scaled_friction - 778.353_025_968).abs() < 1e-4,
992            "friction · mPhysicalCoeff = {scaled_friction}, expected 778.353025968"
993        );
994
995        let velocity = (scaled_friction / f64::from(t.clamping_inflexion)) as f32;
996        assert!(
997            (velocity - 2223.8658).abs() < 0.01,
998            "the exact-point velocity is {velocity}"
999        );
1000
1001        let duration = fling_duration(velocity, &t).as_secs_f64();
1002        assert!(
1003            (duration - 1.0).abs() < 1e-4,
1004            "getSplineFlingDuration must be exactly 1 s here, got {duration}"
1005        );
1006        let distance = f64::from(fling_distance(velocity, &t));
1007        assert!(
1008            (distance - scaled_friction).abs() < 0.05,
1009            "getSplineFlingDistance must be friction·mPhysicalCoeff = {scaled_friction}, got {distance}"
1010        );
1011    }
1012
1013    /// A second Android reference point, worked out by hand so a reader can
1014    /// re-derive it from `OverScroller.java` without running anything.
1015    ///
1016    /// For `v = 4000` dp/s, friction 0.015, `mPhysicalCoeff = 51890.2017`:
1017    ///
1018    /// ```text
1019    ///   l        = ln(0.35 × 4000 / 778.3530) = ln(1.7986697) = 0.5870473
1020    ///   DECEL    = ln(0.78)/ln(0.9)           = 2.3582018
1021    ///   duration = exp(l / (DECEL − 1))       = exp(0.4322239) = 1.54068 s
1022    ///   distance = 778.3530 × exp(DECEL/(DECEL−1) × l)
1023    ///            = 778.3530 × exp(1.0192697)  = 2156.95 dp
1024    /// ```
1025    ///
1026    /// The identity `distance == friction·mPhysicalCoeff · duration^DECEL`
1027    /// falls straight out of the two formulas and is asserted alongside, so a
1028    /// future edit cannot break one without breaking the other.
1029    #[test]
1030    fn a_four_thousand_dp_per_second_fling_matches_the_hand_derivation() {
1031        let t = tokens();
1032        let duration = fling_duration(4000.0, &t).as_secs_f64();
1033        assert!(
1034            (duration - 1.540_68).abs() < 1e-3,
1035            "getSplineFlingDuration(4000) = {duration}, expected 1.54068 s"
1036        );
1037
1038        let distance = f64::from(fling_distance(4000.0, &t));
1039        assert!(
1040            (distance - 2156.95).abs() < 1.0,
1041            "getSplineFlingDistance(4000) = {distance}, expected 2156.95 dp"
1042        );
1043
1044        let scaled_friction = f64::from(t.clamping_friction) * physical_coeff();
1045        let rate = f64::from(t.clamping_deceleration_rate);
1046        let implied = scaled_friction * duration.powf(rate);
1047        assert!(
1048            (implied - distance).abs() < 0.5,
1049            "distance {distance} must equal friction·coeff·duration^DECEL = {implied}"
1050        );
1051    }
1052
1053    /// `ln(0.78) / ln(0.9)`, the exponent every clamping formula shares.
1054    #[test]
1055    fn the_deceleration_rate_token_is_the_android_ratio() {
1056        let expected = 0.78f64.ln() / 0.9f64.ln();
1057        assert!(
1058            (f64::from(tokens().clamping_deceleration_rate) - expected).abs() < 1e-5,
1059            "DECELERATION_RATE token vs ln(0.78)/ln(0.9) = {expected}"
1060        );
1061    }
1062
1063    // --- ClampingSimulation ----------------------------------------------
1064
1065    #[test]
1066    fn a_clamping_fling_lands_on_its_computed_distance() {
1067        let t = tokens();
1068        let sim = ClampingSimulation::new(0.0, 4000.0, -1.0e6, 1.0e6, &t);
1069        let end = sim.position(sim.duration());
1070        assert!(
1071            (end - 2156.95).abs() < 1.0,
1072            "landed at {end}, expected 2156.95"
1073        );
1074        assert!(sim.is_done(sim.duration()));
1075        assert_eq!(sim.position(Duration::ZERO), 0.0, "starts where released");
1076    }
1077
1078    /// The spline's fraction is monotone, so the fling never reverses.
1079    #[test]
1080    fn a_clamping_fling_is_monotone_and_decelerating() {
1081        let t = tokens();
1082        let sim = ClampingSimulation::new(0.0, 3000.0, -1.0e6, 1.0e6, &t);
1083        let mut previous = sim.position(Duration::ZERO);
1084        let mut previous_speed = sim.velocity(Duration::ZERO);
1085        for ms in (0..1600).step_by(16) {
1086            let now = Duration::from_millis(ms);
1087            let p = sim.position(now);
1088            let v = sim.velocity(now);
1089            assert!(
1090                p >= previous - 1e-3,
1091                "reversed at {ms} ms: {previous} → {p}"
1092            );
1093            assert!(
1094                v <= previous_speed + 1.0,
1095                "accelerated at {ms} ms: {previous_speed} → {v}"
1096            );
1097            previous = p;
1098            previous_speed = v;
1099        }
1100    }
1101
1102    /// Clamping physics stops dead at the boundary: no overshoot, and the
1103    /// simulation reports itself finished the moment it arrives.
1104    #[test]
1105    fn a_clamping_fling_stops_dead_at_the_boundary() {
1106        let t = tokens();
1107        let sim = ClampingSimulation::new(0.0, 4000.0, 0.0, 100.0, &t);
1108        let late = Duration::from_millis(900);
1109        assert_eq!(sim.position(late), 100.0, "must not pass the boundary");
1110        assert!(sim.is_done(late), "arriving at the bound ends the fling");
1111        assert_eq!(sim.velocity(late), 0.0, "and leaves no residual velocity");
1112    }
1113
1114    #[test]
1115    fn a_zero_velocity_clamping_fling_is_already_over() {
1116        let t = tokens();
1117        let sim = ClampingSimulation::new(42.0, 0.0, -1.0e6, 1.0e6, &t);
1118        assert_eq!(sim.duration(), Duration::ZERO);
1119        assert_eq!(sim.position(Duration::ZERO), 42.0);
1120        assert!(sim.is_done(Duration::ZERO));
1121    }
1122
1123    #[test]
1124    fn a_negative_clamping_fling_travels_backwards() {
1125        let t = tokens();
1126        let sim = ClampingSimulation::new(0.0, -4000.0, -1.0e6, 1.0e6, &t);
1127        let end = sim.position(sim.duration());
1128        assert!(
1129            (end + 2156.95).abs() < 1.0,
1130            "landed at {end}, expected -2156.95"
1131        );
1132    }
1133
1134    // --- Closed-form Flutter reference values -----------------------------
1135
1136    /// Flutter `FrictionSimulation` with `drag = 0.135`:
1137    /// `x(t) = p + v·(0.135ᵗ − 1)/ln(0.135)` and `dx(t) = v·0.135ᵗ`.
1138    ///
1139    /// With `p = 0`, `v = 1000`, `ln(0.135) = −2.0024805`:
1140    ///
1141    /// ```text
1142    ///   x(1 s)  = 1000 × (0.135 − 1) / −2.0024805 = 431.964 dp
1143    ///   dx(1 s) = 1000 × 0.135                    = 135 dp/s
1144    ///   finalX  = −v / ln(0.135) = 1000 / 2.0024805 = 499.381 dp
1145    /// ```
1146    #[test]
1147    fn the_in_range_coast_matches_flutters_friction_simulation() {
1148        let ln_drag = 0.135f64.ln();
1149        assert!(
1150            (ln_drag + 2.002_480_5).abs() < 1e-6,
1151            "ln(0.135) = {ln_drag}, expected -2.0024805"
1152        );
1153
1154        let sim = BouncingSimulation::within(0.0, 1000.0, -1.0e6, 1.0e6);
1155        let x1 = sim.position(Duration::from_secs(1));
1156        assert!(
1157            (x1 - 431.964).abs() < 0.05,
1158            "x(1 s) = {x1}, expected 431.964"
1159        );
1160
1161        let v1 = sim.velocity(Duration::from_secs(1));
1162        assert!((v1 - 135.0).abs() < 0.05, "dx(1 s) = {v1}, expected 135");
1163
1164        // The coast's asymptote, approached but never crossed.
1165        let far = sim.position(Duration::from_secs(30));
1166        assert!(
1167            (far - 499.381).abs() < 0.05,
1168            "finalX = {far}, expected 499.381"
1169        );
1170    }
1171
1172    /// Flutter `_OverdampedSolution` with Teksilo's shipped spring: mass 0.5,
1173    /// stiffness 100, damping ratio 1.1.
1174    ///
1175    /// ```text
1176    ///   damping = 1.1 × 2 × √(0.5 × 100) = 15.556349
1177    ///   cmk     = damping² − 4·m·k = 242 − 200 = 42            (overdamped)
1178    ///   r1 = (−15.556349 − √42) / (2 × 0.5) = −22.037090
1179    ///   r2 = (−15.556349 + √42) / (2 × 0.5) =  −9.075609
1180    ///   for distance 50, velocity 0:
1181    ///     c2 = (0 − r1·50)/(r2 − r1) = 1101.8545 / 12.961481 = 85.00991
1182    ///     c1 = 50 − c2                                        = −35.00991
1183    ///   x(0.1) = c1·e^(0.1·r1) + c2·e^(0.1·r2)
1184    ///          = −35.00991 × 0.1103939 + 85.00991 × 0.4035080
1185    ///          = 30.437
1186    /// ```
1187    #[test]
1188    fn the_settle_spring_matches_flutters_overdamped_solution() {
1189        let t = tokens();
1190        let damping = t.spring_damping_ratio * 2.0 * (t.spring_mass * t.spring_stiffness).sqrt();
1191        assert!(
1192            (damping - 15.556_349).abs() < 1e-3,
1193            "damping = {damping}, expected 15.556349"
1194        );
1195        let cmk = damping * damping - 4.0 * t.spring_mass * t.spring_stiffness;
1196        assert!(
1197            cmk > 0.0,
1198            "the shipped ratio 1.1 must be overdamped, cmk={cmk}"
1199        );
1200        assert!((cmk - 42.0).abs() < 1e-2, "cmk = {cmk}, expected 42");
1201
1202        // Released 50 dp past the trailing edge with no velocity.
1203        let sim = BouncingSimulation::within(150.0, 0.0, 0.0, 100.0);
1204        assert_eq!(sim.spring_time(), f32::NEG_INFINITY, "springs immediately");
1205        assert!(
1206            (sim.position(Duration::ZERO) - 150.0).abs() < 1e-3,
1207            "starts where released"
1208        );
1209        let x = sim.position(Duration::from_millis(100));
1210        assert!(
1211            (x - 130.437).abs() < 0.05,
1212            "x(0.1 s) = {x}, expected 100 + 30.437"
1213        );
1214    }
1215
1216    /// Flutter `FrictionSimulation.timeAtX`:
1217    /// `t = ln(ln(drag)·(x − p)/v + 1) / ln(drag)`. With `p = 0`, `v = 1000`,
1218    /// `x = 100`: `t = ln(1 − 0.20024805)/−2.0024805 = 0.111600 s`.
1219    #[test]
1220    fn the_coast_hands_off_to_the_spring_at_the_boundary_crossing() {
1221        let sim = BouncingSimulation::within(0.0, 1000.0, -1.0e6, 100.0);
1222        let handoff = sim.spring_time();
1223        assert!(
1224            (handoff - 0.111_600).abs() < 1e-4,
1225            "handoff at {handoff} s, expected 0.111600"
1226        );
1227        // At the handoff the two phases agree on the position — that is what
1228        // makes the seam invisible.
1229        let at = sim.position(Duration::from_secs_f32(handoff));
1230        assert!((at - 100.0).abs() < 0.1, "position at handoff = {at}");
1231    }
1232
1233    /// A bouncing fling that never reaches a bound is pure friction and
1234    /// declares itself done once it drops below the scroll velocity tolerance.
1235    #[test]
1236    fn an_in_range_bouncing_fling_settles_without_a_spring() {
1237        let sim = BouncingSimulation::within(0.0, 300.0, -1.0e6, 1.0e6);
1238        assert_eq!(sim.spring_time(), f32::INFINITY, "no boundary is crossed");
1239        assert!(!sim.is_done(Duration::ZERO), "300 dp/s is still moving");
1240        assert!(
1241            sim.is_done(Duration::from_secs(2)),
1242            "must be below {SETTLE_VELOCITY_TOLERANCE} dp/s after 2 s"
1243        );
1244    }
1245
1246    /// The overshoot must come back. A fling into a bound overshoots, then the
1247    /// spring returns the content to rest at the bound.
1248    #[test]
1249    fn an_overscrolling_bouncing_fling_overshoots_then_returns() {
1250        let sim = BouncingSimulation::within(0.0, 2000.0, 0.0, 100.0);
1251        let peak = (0..200)
1252            .map(|i| sim.position(Duration::from_millis(i * 10)))
1253            .fold(f32::MIN, f32::max);
1254        assert!(peak > 100.0, "the content must overshoot, peaked at {peak}");
1255
1256        let rest = sim.position(Duration::from_secs(3));
1257        assert!(
1258            (rest - 100.0).abs() < 1.0,
1259            "must come back to the bound, rested at {rest}"
1260        );
1261        assert!(sim.is_done(Duration::from_secs(3)));
1262    }
1263
1264    /// A damping ratio just over 1 is chosen so the return never overshoots
1265    /// *back* past the bound — an oscillating scroll edge reads as a bug.
1266    #[test]
1267    fn the_return_does_not_oscillate_back_past_the_bound() {
1268        let sim = BouncingSimulation::within(150.0, 0.0, 0.0, 100.0);
1269        for i in 0..300 {
1270            let p = sim.position(Duration::from_millis(i * 10));
1271            assert!(
1272                p >= 100.0 - SETTLE_DISTANCE_TOLERANCE,
1273                "undershot to {p} at {} ms",
1274                i * 10
1275            );
1276        }
1277    }
1278
1279    /// The rest target need not be the bound that was crossed — the reason
1280    /// `leading`/`trailing` are separate parameters from `min`/`max`.
1281    #[test]
1282    fn a_spring_can_rest_somewhere_other_than_the_crossed_bound() {
1283        // Crosses at 100, but snaps to rest at 120.
1284        let sim = BouncingSimulation::new(150.0, 0.0, 0.0, 100.0, 0.0, 120.0);
1285        let rest = sim.position(Duration::from_secs(3));
1286        assert!(
1287            (rest - 120.0).abs() < 0.5,
1288            "expected the snap target 120, rested at {rest}"
1289        );
1290    }
1291
1292    // --- Rubber band ------------------------------------------------------
1293
1294    /// `rubber_band(100, 400) = 400·(1 − 1/(0.52 × 100/400 + 1))
1295    ///                        = 400 × 0.13/1.13 = 46.0177`.
1296    #[test]
1297    fn the_rubber_band_matches_its_closed_form() {
1298        let y = rubber_band(100.0, 400.0);
1299        assert!((y - 46.0177).abs() < 1e-3, "got {y}, expected 46.0177");
1300        assert_eq!(rubber_band(-100.0, 400.0), -y, "the curve is odd");
1301    }
1302
1303    /// The gain at the origin is Flutter's `frictionFactor(0) = 0.52` — the
1304    /// bridge between the two upstream statements of the same curve.
1305    #[test]
1306    fn the_initial_gain_is_the_flutter_friction_factor() {
1307        let extent = 800.0;
1308        let gain = rubber_band(0.01, extent) / 0.01;
1309        assert!(
1310            (gain - 0.52).abs() < 1e-3,
1311            "d(damped)/d(offset) at 0 = {gain}, expected 0.52"
1312        );
1313    }
1314
1315    /// The `0.52·(1 − f)²` form must hold at every point of the curve, not just
1316    /// at the origin — that is the whole claim that the integral is right.
1317    #[test]
1318    fn the_gain_everywhere_is_the_flutter_friction_factor_of_the_current_fraction() {
1319        let extent = 500.0;
1320        for raw in [10.0f32, 60.0, 150.0, 400.0, 900.0] {
1321            // A forward difference measures the gain at the midpoint of the
1322            // step, so the friction factor is evaluated there too — otherwise
1323            // the O(h) truncation swamps the agreement being tested.
1324            let h = 0.5;
1325            let gain = (rubber_band(raw + h, extent) - rubber_band(raw, extent)) / h;
1326            let fraction = rubber_band(raw + h / 2.0, extent) / extent;
1327            let expected = 0.52 * (1.0 - fraction).powi(2);
1328            assert!(
1329                (gain - expected).abs() < 1e-3,
1330                "at raw={raw}: measured gain {gain}, frictionFactor {expected}"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn the_rubber_band_is_the_identity_at_zero_and_never_reaches_the_extent() {
1337        assert_eq!(rubber_band(0.0, 400.0), 0.0);
1338        for raw in [1.0f32, 100.0, 10_000.0, 1.0e9] {
1339            let y = rubber_band(raw, 400.0);
1340            assert!(y < 400.0, "raw={raw} produced {y}, at or past the extent");
1341            assert!(y > 0.0);
1342        }
1343    }
1344
1345    #[test]
1346    fn the_rubber_band_refuses_degenerate_inputs_without_producing_nan() {
1347        for (offset, extent) in [
1348            (f32::NAN, 400.0f32),
1349            (100.0, f32::NAN),
1350            (100.0, 0.0),
1351            (100.0, -5.0),
1352            (100.0, f32::INFINITY),
1353        ] {
1354            let y = rubber_band(offset, extent);
1355            assert_eq!(y, 0.0, "rubber_band({offset}, {extent}) = {y}");
1356        }
1357        assert_eq!(rubber_band(f32::INFINITY, 400.0), 400.0);
1358        assert_eq!(rubber_band(f32::NEG_INFINITY, 400.0), -400.0);
1359    }
1360
1361    #[test]
1362    fn the_rubber_band_inverse_undoes_the_rubber_band() {
1363        let extent = 640.0;
1364        for raw in [0.0f32, 3.0, 55.0, 200.0, 1200.0] {
1365            let back = rubber_band_inverse(rubber_band(raw, extent), extent);
1366            assert!(
1367                (back - raw).abs() <= 1e-2 * raw.max(1.0),
1368                "raw={raw} round-tripped to {back}"
1369            );
1370        }
1371        assert_eq!(rubber_band_inverse(0.0, extent), 0.0);
1372        // Saturated input must stay finite rather than hitting the pole.
1373        assert!(rubber_band_inverse(extent, extent).is_finite());
1374        assert!(rubber_band_inverse(extent * 10.0, extent).is_finite());
1375    }
1376
1377    /// The four constant tables in `docs/kinetic-scrolling.md` §2 that this
1378    /// module and `scroller` own are the shipped values.
1379    ///
1380    /// The tests above pin the *derived* quantities — `mPhysicalCoeff`, the
1381    /// exact spline point, the overdamped solution — which is the stronger
1382    /// check where it applies, because it fails if any input moved. It does not
1383    /// reach every published row: the settle tolerances, the frame interval, the
1384    /// spring hand-off cap and the spline scaffolding were each documented with
1385    /// no assertion behind them. Six of the rows name **private** constants, so
1386    /// this cannot live in `tests/`.
1387    #[test]
1388    fn the_documented_physics_constants_are_the_shipped_ones() {
1389        use crate::kinetic::doc_table::{assert_value, rows};
1390        const PAGE: &str = "kinetic-scrolling.md";
1391        let t = teksilo_tokens::ScrollPhysicsTokens::DEFAULT;
1392
1393        let mut seen = Vec::new();
1394        for row in rows(PAGE, "### Clamping physics") {
1395            let key = row[0].trim_matches('`').to_string();
1396            let cell = &row[1];
1397            match key.as_str() {
1398                "clamping_deceleration_rate" => {
1399                    assert_value(PAGE, cell, t.clamping_deceleration_rate as f64, &key)
1400                }
1401                "clamping_inflexion" => assert_value(PAGE, cell, t.clamping_inflexion as f64, &key),
1402                "clamping_friction" => assert_value(PAGE, cell, t.clamping_friction as f64, &key),
1403                "START_TENSION" => assert_value(PAGE, cell, START_TENSION, &key),
1404                "END_TENSION" => assert_value(PAGE, cell, END_TENSION, &key),
1405                "NB_SAMPLES" => assert_value(PAGE, cell, NB_SAMPLES as f64, &key),
1406                "gravity" => assert_value(PAGE, cell, GRAVITY_EARTH, &key),
1407                "inches per metre" => assert_value(PAGE, cell, INCHES_PER_METER, &key),
1408                "pixels per inch" => assert_value(PAGE, cell, PPI_AT_DENSITY_ONE, &key),
1409                "tuning factor" => assert_value(PAGE, cell, PHYSICAL_TUNING, &key),
1410                other => panic!("an unchecked clamping row {other:?}"),
1411            }
1412            seen.push(key);
1413        }
1414        assert_eq!(seen.len(), 10, "the clamping table lost a row: {seen:?}");
1415
1416        seen.clear();
1417        for row in rows(PAGE, "### Bouncing physics") {
1418            let key = row[0].trim_matches('`').to_string();
1419            let cell = &row[1];
1420            match key.as_str() {
1421                "bouncing_decay_per_second" => {
1422                    assert_value(PAGE, cell, t.bouncing_decay_per_second as f64, &key)
1423                }
1424                "spring_mass" => assert_value(PAGE, cell, t.spring_mass as f64, &key),
1425                "spring_stiffness" => assert_value(PAGE, cell, t.spring_stiffness as f64, &key),
1426                "spring_damping_ratio" => {
1427                    assert_value(PAGE, cell, t.spring_damping_ratio as f64, &key)
1428                }
1429                "maxSpringTransferVelocity" => {
1430                    assert_value(PAGE, cell, MAX_SPRING_TRANSFER_VELOCITY as f64, &key)
1431                }
1432                "rubber_band_factor" => assert_value(PAGE, cell, t.rubber_band_factor as f64, &key),
1433                other => panic!("an unchecked bouncing row {other:?}"),
1434            }
1435            seen.push(key);
1436        }
1437        assert_eq!(seen.len(), 6, "the bouncing table lost a row: {seen:?}");
1438
1439        seen.clear();
1440        for row in rows(PAGE, "### Settle tolerances") {
1441            let key = row[0].trim_matches('`').to_string();
1442            let cell = &row[1];
1443            match key.as_str() {
1444                "SETTLE_DISTANCE_TOLERANCE" => {
1445                    assert_value(PAGE, cell, SETTLE_DISTANCE_TOLERANCE as f64, &key)
1446                }
1447                "SETTLE_VELOCITY_TOLERANCE" => {
1448                    assert_value(PAGE, cell, SETTLE_VELOCITY_TOLERANCE as f64, &key)
1449                }
1450                "FLING_FRAME_INTERVAL" => assert_value(
1451                    PAGE,
1452                    cell,
1453                    crate::kinetic::scroller::FLING_FRAME_INTERVAL.as_micros() as f64,
1454                    &key,
1455                ),
1456                other => panic!("an unchecked settle row {other:?}"),
1457            }
1458            seen.push(key);
1459        }
1460        assert_eq!(seen.len(), 3, "the settle table lost a row: {seen:?}");
1461
1462        // The gates are the same value on all three profiles; the page's table
1463        // has one column, so it is asserted against every profile rather than
1464        // against a chosen one.
1465        seen.clear();
1466        for row in rows(PAGE, "### Fling velocity gates") {
1467            let key = row[0].trim_matches('`').to_string();
1468            let cell = &row[1];
1469            for profile in [
1470                teksilo_tokens::GestureProfile::MOUSE,
1471                teksilo_tokens::GestureProfile::TOUCH,
1472                teksilo_tokens::GestureProfile::PEN,
1473            ] {
1474                match key.as_str() {
1475                    "min_fling_velocity" => {
1476                        assert_value(PAGE, cell, profile.min_fling_velocity as f64, &key)
1477                    }
1478                    "max_fling_velocity" => {
1479                        assert_value(PAGE, cell, profile.max_fling_velocity as f64, &key)
1480                    }
1481                    other => panic!("an unchecked fling-gate row {other:?}"),
1482                }
1483            }
1484            seen.push(key);
1485        }
1486        assert_eq!(seen.len(), 2, "the fling-gate table lost a row: {seen:?}");
1487    }
1488}