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}