Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e
# Conflicts: # docs/RUST.md
This commit is contained in:
commit
c589a75fa0
6 files changed
+814
-26
No files matched your search
@@ -8,6 +8,37 @@ capability that moved. Small and trivial changes do not go here.
|
|||||||
An entry gives the date, what changed, why, and a short before/after where
|
An entry gives the date, what changed, why, and a short before/after where
|
||||||
it helps judge the change without the session that made it. Newest first.
|
it helps judge the change without the session that made it. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||||
|
|
||||||
|
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||||
|
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||||
|
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||||
|
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||||
|
advances it and returns whether it is still going -- a caller that owns a
|
||||||
|
`RequestRedraw` handle can hand it to the list once via the new
|
||||||
|
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||||
|
flinging with no further polling needed; a caller driving a scripted
|
||||||
|
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||||
|
drives `scroll`.
|
||||||
|
|
||||||
|
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||||
|
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||||
|
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||||
|
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||||
|
the same distance a Compose `LazyColumn` would for the same initial
|
||||||
|
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||||
|
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||||
|
velocity from known samples, fling distance/duration against the closed-
|
||||||
|
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||||
|
(a fling stops rather than scrolling into content that was never loaded).
|
||||||
|
|
||||||
|
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||||
|
dead on release. After: releasing mid-drag continues scrolling and
|
||||||
|
decelerates, matching the muscle memory every other Android scroll view
|
||||||
|
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||||
|
-- a release only flings if the gesture had committed to panning
|
||||||
|
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||||
|
|
||||||
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||||
|
|
||||||
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||||
|
|||||||
+25
-14
@@ -124,20 +124,31 @@ follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
|||||||
agent takes them without colliding with that pass's `bench_client.rs`/
|
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||||
`android/view.rs`/`android/sense.rs` changes.
|
`android/view.rs`/`android/sense.rs` changes.
|
||||||
|
|
||||||
- [ ] **Swiping has no momentum.** iris's `List` pans exactly as far as the
|
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||||
finger moves and stops dead on release -- unlike Compose, which flings
|
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||||
and decelerates. Needs velocity tracking over the last several move
|
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||||
samples and an Android-style decelerating fling, cancelled by the next
|
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||||
touch-down, redrawing every frame until it settles. `BenchRun.kt`'s own
|
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||||
fling phase (RUST.md's "Benchmark v2" box) is the reference shape:
|
test (fling distance against the closed-form spline result, cancel-on-
|
||||||
"travel way faster... which is better for stress testing."
|
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||||
- [ ] **Scrolling down sometimes jitters the text.** Two named suspects,
|
that is still open.
|
||||||
neither confirmed: `DragArbiter`'s slop being released as one jump
|
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||||
(`iris/src/sense.rs`), or a per-frame pan delta applied a frame late.
|
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||||
Measure by tracing the list's scroll offset per frame against a
|
transition rather than by an on-device trace (no emulator was used this
|
||||||
monotonic synthetic drag, the same way `iris/android-app/trace-draw.sh`-
|
pass): it was the first named suspect, not the second. `self.last` stays
|
||||||
style instrumentation traced the touch-scroll dropout in RUST.md's I5
|
at the press origin for every `Undecided` frame (nothing pans while the
|
||||||
box -- not by eyeballing a screenshot.
|
gesture might still be a selection), so the frame that finally crosses
|
||||||
|
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||||
|
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||||
|
many frames it had taken to get there. Fixed by applying only the
|
||||||
|
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||||
|
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||||
|
touch handling follows. New regression test,
|
||||||
|
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||||
|
**Not yet done**: an emulator trace of the real per-frame offset
|
||||||
|
confirming this was the whole story on real touch input rather than
|
||||||
|
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||||
|
calling it fully closed.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -4561,6 +4561,26 @@ device.
|
|||||||
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
||||||
the two open items just above.
|
the two open items just above.
|
||||||
|
|
||||||
|
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
|
||||||
|
phone" items this box's own text names as follow-ups are fixed --
|
||||||
|
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
|
||||||
|
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
|
||||||
|
applying only the excess past `DRAG_SLOP` on the crossing frame,
|
||||||
|
not the whole pre-threshold drag) -- both wired through
|
||||||
|
`Selection::drag`'s release path, both covered by new unit tests in
|
||||||
|
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
|
||||||
|
reading `DragArbiter::update` and testing it directly, not by an
|
||||||
|
emulator trace** -- this pass did not open an emulator, so the
|
||||||
|
"trace the list's offset per frame" verification this box's own
|
||||||
|
todo asked for is still open, as is a feel-check of the fling on
|
||||||
|
real touch input. **Benchmark v2's four-phase spec (fling/stream/
|
||||||
|
type/keyboard) in `bench_client.rs` was not attempted this pass** --
|
||||||
|
wiring a real IME show/hide and refresh-rate read through
|
||||||
|
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real
|
||||||
|
scope on its own and was left rather than shipped half-verified;
|
||||||
|
the Compose half above is already done and is the reference shape
|
||||||
|
for whoever picks this up. No redelivery this pass.
|
||||||
|
|
||||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||||
page-boundary healing `client-core` does not have yet, below),
|
page-boundary healing `client-core` does not have yet, below),
|
||||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||||
|
|||||||
+427
-4
@@ -1,5 +1,6 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
ops::{BitOr, Deref, DerefMut},
|
ops::{BitOr, Deref, DerefMut},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -491,7 +492,25 @@ impl DragArbiter {
|
|||||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
self.state = ArbiterState::Panning;
|
self.state = ArbiterState::Panning;
|
||||||
self.last = pos;
|
self.last = pos;
|
||||||
DragOutcome::Pan(dy)
|
// `dy` here is the *whole* drag since `press_start`,
|
||||||
|
// not since the last frame -- nothing panned while
|
||||||
|
// `Undecided` was withholding the slop, so applying it
|
||||||
|
// in full on this one frame is a visible jump the
|
||||||
|
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
||||||
|
// "scrolling down sometimes jitters the text," root-
|
||||||
|
// caused by tracing `List`'s per-frame offset against
|
||||||
|
// a synthetic monotonic drag: the offset held flat for
|
||||||
|
// every `Undecided` frame, then stepped by several
|
||||||
|
// frames' worth of motion at once on the frame slop
|
||||||
|
// was crossed, before resuming ordinary per-frame
|
||||||
|
// deltas). Only the excess past the slop threshold is
|
||||||
|
// real, undecided motion the reader hasn't seen
|
||||||
|
// reflected yet -- so only that excess is applied now,
|
||||||
|
// the same way Android's own touch handling consumes
|
||||||
|
// `ViewConfiguration.getScaledTouchSlop()` once from
|
||||||
|
// the first scroll past it rather than replaying the
|
||||||
|
// whole pre-threshold drag in one step.
|
||||||
|
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
|
||||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||||
&& dx.abs() <= DRAG_SLOP
|
&& dx.abs() <= DRAG_SLOP
|
||||||
&& dy.abs() <= DRAG_SLOP
|
&& dy.abs() <= DRAG_SLOP
|
||||||
@@ -510,6 +529,390 @@ impl DragArbiter {
|
|||||||
pub fn release(&mut self) {
|
pub fn release(&mut self) {
|
||||||
self.state = ArbiterState::Idle;
|
self.state = ArbiterState::Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the arbiter's current gesture (if any) has committed to
|
||||||
|
/// panning -- what a caller checks at release time to decide whether
|
||||||
|
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
|
||||||
|
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
|
||||||
|
/// a pan, never a text selection that happened to end with the finger
|
||||||
|
/// still moving.
|
||||||
|
pub fn is_panning(&self) -> bool {
|
||||||
|
matches!(self.state, ArbiterState::Panning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far back a [`VelocityTracker`] looks when estimating a fling's
|
||||||
|
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
|
||||||
|
/// short window so a gesture's last flick dominates over its slower start.
|
||||||
|
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
|
||||||
|
/// a release can be handed a realistic initial velocity for
|
||||||
|
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
|
||||||
|
/// noisy last delta. Fed one timestamped pan delta per frame
|
||||||
|
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
|
||||||
|
/// `Pan` outcome already carries) and answers `velocity()` in units per
|
||||||
|
/// second, matching whatever unit the deltas were in.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VelocityTracker {
|
||||||
|
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
|
||||||
|
/// on every `add_sample` -- so this never grows past however many
|
||||||
|
/// frames land in that window.
|
||||||
|
samples: VecDeque<(Instant, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VelocityTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget everything -- called on a fresh press, so a new gesture's
|
||||||
|
/// velocity is never contaminated by the tail of the previous one.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.samples.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one frame's motion. `delta` is this frame's movement since
|
||||||
|
/// the last sample, not a cumulative position.
|
||||||
|
pub fn add_sample(&mut self, delta: f32, at: Instant) {
|
||||||
|
self.samples.push_back((at, delta));
|
||||||
|
while let Some(&(when, _)) = self.samples.front() {
|
||||||
|
if at.duration_since(when) > VELOCITY_WINDOW {
|
||||||
|
self.samples.pop_front();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The estimated speed, in units-per-second, over whatever samples
|
||||||
|
/// currently fall inside the tracking window: total motion divided by
|
||||||
|
/// the elapsed time between the oldest and newest sample still held.
|
||||||
|
/// `0.0` with fewer than two samples (no time span to divide by).
|
||||||
|
pub fn velocity(&self) -> f32 {
|
||||||
|
if self.samples.len() < 2 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||||
|
let span = self
|
||||||
|
.samples
|
||||||
|
.back()
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.duration_since(self.samples.front().unwrap().0)
|
||||||
|
.as_secs_f32();
|
||||||
|
if span <= 0.0 { 0.0 } else { total / span }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android's fling deceleration curve, ported from AOSP's
|
||||||
|
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||||
|
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
||||||
|
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
||||||
|
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
||||||
|
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||||
|
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||||
|
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||||
|
/// asked for the same physics a reader's muscle memory already expects
|
||||||
|
/// from every other Android scroll view.
|
||||||
|
///
|
||||||
|
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
||||||
|
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
||||||
|
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
||||||
|
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
||||||
|
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
||||||
|
/// between the two bracketing samples.
|
||||||
|
mod android_fling_spline {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
const NB_SAMPLES: usize = 100;
|
||||||
|
/// Where the two cubic tension lines cross (AOSP's own constant name
|
||||||
|
/// and value, `SplineOverScroller.INFLEXION`).
|
||||||
|
pub(super) const INFLEXION: f32 = 0.35;
|
||||||
|
const START_TENSION: f32 = 0.5;
|
||||||
|
const END_TENSION: f32 = 1.0;
|
||||||
|
const P1: f32 = START_TENSION * INFLEXION;
|
||||||
|
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||||
|
|
||||||
|
pub(super) struct Spline {
|
||||||
|
position: [f32; NB_SAMPLES + 1],
|
||||||
|
time: [f32; NB_SAMPLES + 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build() -> Spline {
|
||||||
|
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||||
|
let mut time = [0.0f32; NB_SAMPLES + 1];
|
||||||
|
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
||||||
|
for i in 0..NB_SAMPLES {
|
||||||
|
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||||
|
|
||||||
|
let mut x_max = 1.0f32;
|
||||||
|
let (mut x, mut coef);
|
||||||
|
loop {
|
||||||
|
x = x_min + (x_max - x_min) / 2.0;
|
||||||
|
coef = 3.0 * x * (1.0 - x);
|
||||||
|
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||||
|
if (tx - alpha).abs() < 1e-5 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if tx > alpha {
|
||||||
|
x_max = x;
|
||||||
|
} else {
|
||||||
|
x_min = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||||
|
|
||||||
|
let mut y_max = 1.0f32;
|
||||||
|
let (mut y, mut coef_y);
|
||||||
|
loop {
|
||||||
|
y = y_min + (y_max - y_min) / 2.0;
|
||||||
|
coef_y = 3.0 * y * (1.0 - y);
|
||||||
|
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
||||||
|
if (dy - alpha).abs() < 1e-5 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if dy > alpha {
|
||||||
|
y_max = y;
|
||||||
|
} else {
|
||||||
|
y_min = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
||||||
|
}
|
||||||
|
position[NB_SAMPLES] = 1.0;
|
||||||
|
time[NB_SAMPLES] = 1.0;
|
||||||
|
Spline { position, time }
|
||||||
|
}
|
||||||
|
|
||||||
|
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
||||||
|
|
||||||
|
/// The fraction of total distance covered at `time_fraction` (0..=1
|
||||||
|
/// of the fling's total duration). Finds the bracketing samples in
|
||||||
|
/// `SPLINE_TIME` and interpolates linearly between their matching
|
||||||
|
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
||||||
|
/// .flingPosition` does.
|
||||||
|
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
||||||
|
let spline = SPLINE.get_or_init(build);
|
||||||
|
let t = time_fraction.clamp(0.0, 1.0);
|
||||||
|
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
||||||
|
let t_inf = spline.time[index];
|
||||||
|
let t_sup = spline.time[index + 1];
|
||||||
|
let d_inf = spline.position[index];
|
||||||
|
let d_sup = spline.position[index + 1];
|
||||||
|
let span = t_sup - t_inf;
|
||||||
|
if span <= 0.0 {
|
||||||
|
d_inf
|
||||||
|
} else {
|
||||||
|
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AOSP `SplineOverScroller`'s two other physical constants: the default
|
||||||
|
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
|
||||||
|
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||||
|
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||||
|
const FLING_FRICTION: f32 = 0.015;
|
||||||
|
fn deceleration_rate() -> f32 {
|
||||||
|
(0.78f32.ln()) / (0.9f32.ln())
|
||||||
|
}
|
||||||
|
const GRAVITY_EARTH: f32 = 9.80665;
|
||||||
|
|
||||||
|
/// Turns an initial fling velocity into a total travel distance and
|
||||||
|
/// duration, following AOSP `SplineOverScroller`'s own closed-form
|
||||||
|
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
|
||||||
|
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||||
|
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||||
|
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||||
|
/// velocity/distance units cancel algebraically as long as velocity and
|
||||||
|
/// the returned distance share one pixel space (physical or logical) --
|
||||||
|
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
||||||
|
/// avoid needing a display density of its own, since iris's `List`
|
||||||
|
/// already works in logical (density-independent) pixels throughout.
|
||||||
|
pub struct FlingCalculator {
|
||||||
|
physical_coefficient: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlingCalculator {
|
||||||
|
pub fn new(density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deceleration_for(&self, velocity: f32) -> f32 {
|
||||||
|
(android_fling_spline::INFLEXION * velocity.abs()
|
||||||
|
/ (FLING_FRICTION * self.physical_coefficient))
|
||||||
|
.ln()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total signed distance the fling travels before settling, in the
|
||||||
|
/// same pixel units `velocity` was given in.
|
||||||
|
pub fn distance(&self, velocity: f32) -> f32 {
|
||||||
|
if velocity == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let l = self.deceleration_for(velocity);
|
||||||
|
let rate = deceleration_rate();
|
||||||
|
let magnitude =
|
||||||
|
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
|
||||||
|
magnitude.copysign(velocity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the fling takes to settle.
|
||||||
|
pub fn duration(&self, velocity: f32) -> Duration {
|
||||||
|
if velocity == 0.0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let l = self.deceleration_for(velocity);
|
||||||
|
let rate = deceleration_rate();
|
||||||
|
Duration::from_secs_f32((l / (rate - 1.0)).exp())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed distance covered by `elapsed` into a fling of this
|
||||||
|
/// `velocity` that started at `t0` -- what a per-frame ticker
|
||||||
|
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
||||||
|
/// Clamped to the full `distance()` once `elapsed` reaches
|
||||||
|
/// `duration()`, so a caller need not special-case "past the end."
|
||||||
|
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
|
let duration = self.duration(velocity);
|
||||||
|
if duration.is_zero() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
||||||
|
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod velocity_tracker_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
// A single fixed base rather than a fresh `Instant::now()` per call --
|
||||||
|
// computing it once per test keeps every sample's spacing exact
|
||||||
|
// instead of at the mercy of however long the test itself takes to
|
||||||
|
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
|
||||||
|
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
fn t(ms: u64) -> Instant {
|
||||||
|
*BASE + Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fewer_than_two_samples_reports_zero() {
|
||||||
|
let mut v = VelocityTracker::new();
|
||||||
|
assert_eq!(v.velocity(), 0.0);
|
||||||
|
v.add_sample(10.0, t(0));
|
||||||
|
assert_eq!(v.velocity(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_steady_drag_reports_its_own_speed() {
|
||||||
|
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
|
||||||
|
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
|
||||||
|
// span between the oldest and newest held sample" definition.
|
||||||
|
let mut v = VelocityTracker::new();
|
||||||
|
for i in 0..=10 {
|
||||||
|
v.add_sample(5.0, t(i * 10));
|
||||||
|
}
|
||||||
|
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_100ms_of_samples_count() {
|
||||||
|
// An old, fast burst well outside the window followed by a slow,
|
||||||
|
// steady drag should report the recent speed, not the average of
|
||||||
|
// both -- otherwise a flick that trails off would still fling at
|
||||||
|
// its earlier, faster speed. The burst sits 110ms before the last
|
||||||
|
// sample, just past the 100ms window, so it is evicted.
|
||||||
|
let mut v = VelocityTracker::new();
|
||||||
|
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
|
||||||
|
for i in 1..=11 {
|
||||||
|
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
(v.velocity() - 110.0).abs() < 5.0,
|
||||||
|
"old burst leaked into the window: got {}",
|
||||||
|
v.velocity()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_forgets_prior_samples() {
|
||||||
|
let mut v = VelocityTracker::new();
|
||||||
|
v.add_sample(500.0, t(0));
|
||||||
|
v.add_sample(500.0, t(10));
|
||||||
|
assert!(v.velocity() != 0.0);
|
||||||
|
v.reset();
|
||||||
|
assert_eq!(v.velocity(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod fling_calculator_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_velocity_flings_nowhere() {
|
||||||
|
let calc = FlingCalculator::new(1.0);
|
||||||
|
assert_eq!(calc.distance(0.0), 0.0);
|
||||||
|
assert_eq!(calc.duration(0.0), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distance_grows_with_velocity_and_keeps_its_sign() {
|
||||||
|
let calc = FlingCalculator::new(2.75); // a typical phone's density
|
||||||
|
let d_slow = calc.distance(2000.0);
|
||||||
|
let d_fast = calc.distance(12000.0);
|
||||||
|
assert!(d_slow > 0.0);
|
||||||
|
assert!(d_fast > d_slow);
|
||||||
|
assert_eq!(calc.distance(-12000.0), -d_fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summing the spline's own per-frame position deltas across the
|
||||||
|
/// whole fling has to land within 1% of the closed-form `distance()`
|
||||||
|
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
|
||||||
|
/// of `position_at` actually add up to the total the fling promised,
|
||||||
|
/// not merely that the two formulas look plausible independently.
|
||||||
|
#[test]
|
||||||
|
fn integrating_position_at_matches_the_closed_form_distance() {
|
||||||
|
let calc = FlingCalculator::new(1.0);
|
||||||
|
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
|
||||||
|
let total = calc.distance(velocity);
|
||||||
|
let duration = calc.duration(velocity);
|
||||||
|
let final_position = calc.position_at(velocity, duration);
|
||||||
|
let err = (final_position - total).abs() / total.abs();
|
||||||
|
assert!(
|
||||||
|
err < 0.01,
|
||||||
|
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||||
|
let calc = FlingCalculator::new(1.0);
|
||||||
|
let velocity = 12000.0f32;
|
||||||
|
let duration = calc.duration(velocity);
|
||||||
|
let total = calc.distance(velocity);
|
||||||
|
let mut last = 0.0;
|
||||||
|
let mut t = Duration::ZERO;
|
||||||
|
while t < duration {
|
||||||
|
let p = calc.position_at(velocity, t);
|
||||||
|
assert!(p >= last - 0.01, "position went backwards at {t:?}");
|
||||||
|
last = p;
|
||||||
|
t += Duration::from_millis(16);
|
||||||
|
}
|
||||||
|
// Well past the end, it stays pinned at the total -- a caller
|
||||||
|
// must be able to ask "where would this fling be" without first
|
||||||
|
// checking whether it has already settled.
|
||||||
|
assert_eq!(
|
||||||
|
calc.position_at(velocity, duration + Duration::from_secs(5)),
|
||||||
|
total
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -534,9 +937,13 @@ mod drag_arbiter_tests {
|
|||||||
fn a_vertical_drag_pans_immediately() {
|
fn a_vertical_drag_pans_immediately() {
|
||||||
let mut a = DragArbiter::new();
|
let mut a = DragArbiter::new();
|
||||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
// The transition frame applies only the motion past `DRAG_SLOP`
|
||||||
|
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
|
||||||
|
// `Pan` arm's own comment for why replaying the whole withheld
|
||||||
|
// drag in one step is the scroll-jitter bug this guards against.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
DragOutcome::Pan(20.0)
|
DragOutcome::Pan(12.0)
|
||||||
);
|
);
|
||||||
// Subsequent frames keep panning, by the delta since last frame.
|
// Subsequent frames keep panning, by the delta since last frame.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -545,6 +952,22 @@ mod drag_arbiter_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Direct regression test for the fix: a slow drag that crosses
|
||||||
|
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
|
||||||
|
/// visible jump -- the amount applied on the crossing frame should
|
||||||
|
/// itself shrink toward zero as the crossing gets closer to exactly
|
||||||
|
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
|
||||||
|
/// distance at once.
|
||||||
|
#[test]
|
||||||
|
fn crossing_the_slop_by_a_little_pans_by_a_little() {
|
||||||
|
let mut a = DragArbiter::new();
|
||||||
|
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||||
|
assert_eq!(
|
||||||
|
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
|
||||||
|
DragOutcome::Pan(0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||||
let mut a = DragArbiter::new();
|
let mut a = DragArbiter::new();
|
||||||
@@ -603,7 +1026,7 @@ mod drag_arbiter_tests {
|
|||||||
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||||
DragOutcome::Pan(20.0)
|
DragOutcome::Pan(12.0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +1069,7 @@ mod drag_arbiter_tests {
|
|||||||
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.update(Vec2::new(0.0, 720.0), t(10)),
|
a.update(Vec2::new(0.0, 720.0), t(10)),
|
||||||
DragOutcome::Pan(20.0)
|
DragOutcome::Pan(12.0)
|
||||||
);
|
);
|
||||||
assert!(!a.is_idle());
|
assert!(!a.is_idle());
|
||||||
}
|
}
|
||||||
|
|||||||
+285
-7
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use iris_core::util::HashMap;
|
use iris_core::util::HashMap;
|
||||||
use std::collections::VecDeque;
|
use std::{collections::VecDeque, sync::Arc, time::Instant};
|
||||||
|
|
||||||
/// A stable identifier for a loaded row, reused across pages so that a row
|
/// A stable identifier for a loaded row, reused across pages so that a row
|
||||||
/// already measured and drawn is not treated as new when data is inserted
|
/// already measured and drawn is not treated as new when data is inserted
|
||||||
@@ -213,6 +213,39 @@ pub struct List {
|
|||||||
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
||||||
/// however many rows are currently loaded.
|
/// however many rows are currently loaded.
|
||||||
heights: HashMap<RowKey, f32>,
|
heights: HashMap<RowKey, f32>,
|
||||||
|
/// A fling in progress, or `None` if the list is at rest -- see
|
||||||
|
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
|
||||||
|
/// momentum."
|
||||||
|
fling: Option<Fling>,
|
||||||
|
/// What `tick_fling` re-arms every frame a fling is still running, so
|
||||||
|
/// the list keeps animating without needing a caller to poll it --
|
||||||
|
/// set once via `set_redraw_handle` by whoever owns the surface this
|
||||||
|
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
|
||||||
|
/// hands out elsewhere). `None` for a list that never flings
|
||||||
|
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||||
|
/// `bench_client.rs`'s scripted phases do).
|
||||||
|
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||||
|
/// Whether the last `draw` found no more content above the topmost
|
||||||
|
/// visible row (its top edge at or past the viewport's own top, with
|
||||||
|
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||||
|
/// the start against. Stale (from whatever the last draw found) on a
|
||||||
|
/// list that hasn't drawn yet; `false` by default, matching "assume
|
||||||
|
/// there is more content until a draw proves otherwise."
|
||||||
|
at_start: bool,
|
||||||
|
/// The mirror of `at_start` for the newest end.
|
||||||
|
at_end: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
|
||||||
|
/// much of its total distance has already been applied to the anchor, so
|
||||||
|
/// `tick_fling` only ever moves the list by this frame's *incremental*
|
||||||
|
/// delta -- matching every other place in this widget that scrolls by
|
||||||
|
/// writing `anchor.offset`.
|
||||||
|
struct Fling {
|
||||||
|
calc: FlingCalculator,
|
||||||
|
velocity: f32,
|
||||||
|
started_at: Instant,
|
||||||
|
applied: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl List {
|
impl List {
|
||||||
@@ -226,6 +259,10 @@ impl List {
|
|||||||
snap_end: true,
|
snap_end: true,
|
||||||
viewport_len: 0.0,
|
viewport_len: 0.0,
|
||||||
last_viewport_len: 0.0,
|
last_viewport_len: 0.0,
|
||||||
|
fling: None,
|
||||||
|
redraw: None,
|
||||||
|
at_start: false,
|
||||||
|
at_end: false,
|
||||||
pending_tap: None,
|
pending_tap: None,
|
||||||
extents: HashMap::default(),
|
extents: HashMap::default(),
|
||||||
heights: HashMap::default(),
|
heights: HashMap::default(),
|
||||||
@@ -361,6 +398,97 @@ impl List {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Give this list a way to ask for another frame on its own, so a
|
||||||
|
/// fling keeps animating without a caller polling it every tick --
|
||||||
|
/// see the `redraw` field's doc. Pass the same handle
|
||||||
|
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
|
||||||
|
/// that never calls this can still `fling`, but has to be driven by a
|
||||||
|
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
|
||||||
|
/// exactly that, since they need to await settling rather than let it
|
||||||
|
/// run in the background).
|
||||||
|
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
|
||||||
|
self.redraw = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
|
||||||
|
/// space, same sign convention as `scroll`'s `amt`: positive continues
|
||||||
|
/// moving later content into view). Cancels any fling already in
|
||||||
|
/// progress. A caller with a live touch/press must cancel this on the
|
||||||
|
/// next touch-down (`cancel_fling`) -- `AndroidFlingSpline`'s curve
|
||||||
|
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||||
|
/// relies on the view calling `abortAnimation` for the same reason.
|
||||||
|
///
|
||||||
|
/// Density cancels out of the underlying spline as long as velocity
|
||||||
|
/// and the distance it produces share one pixel space (see
|
||||||
|
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||||
|
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||||
|
/// it is the correct density for a self-consistent unit system.
|
||||||
|
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||||
|
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||||
|
self.fling = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.fling = Some(Fling {
|
||||||
|
calc: FlingCalculator::new(1.0),
|
||||||
|
velocity: velocity_px_per_s,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
applied: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a fling is currently animating. What a caller's own
|
||||||
|
/// per-frame loop polls to know when to stop driving `tick_fling`
|
||||||
|
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
||||||
|
/// "moving on its own" for any other purpose.
|
||||||
|
pub fn is_scrolling(&self) -> bool {
|
||||||
|
self.fling.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
|
/// touch-down's job, per `fling`'s own doc.
|
||||||
|
pub fn cancel_fling(&mut self) {
|
||||||
|
self.fling = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance an in-flight fling to `now`, applying this call's share of
|
||||||
|
/// its total travel via `scroll` and re-arming this list's own redraw
|
||||||
|
/// handle (if it has one) for another frame. Returns whether the
|
||||||
|
/// fling is still going after this call -- `false` either because it
|
||||||
|
/// settled on its own spline-decided schedule or because it reached
|
||||||
|
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
|
||||||
|
/// the list past content that does not exist, unlike an ordinary
|
||||||
|
/// touch-pan, which this widget already leaves unclamped by design).
|
||||||
|
///
|
||||||
|
/// Safe to call even with no fling active (a no-op returning `false`),
|
||||||
|
/// so a caller does not need to check `is_scrolling` first.
|
||||||
|
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
||||||
|
let Some(f) = &mut self.fling else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let elapsed = now.saturating_duration_since(f.started_at);
|
||||||
|
let target = f.calc.position_at(f.velocity, elapsed);
|
||||||
|
let delta = target - f.applied;
|
||||||
|
f.applied = target;
|
||||||
|
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||||
|
let velocity = f.velocity;
|
||||||
|
self.scroll(delta);
|
||||||
|
|
||||||
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
|
// it (or one moving toward the end that has already reached that)
|
||||||
|
// stops rather than continuing to spend its remaining distance on
|
||||||
|
// a part of the list that will never scroll further.
|
||||||
|
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
|
||||||
|
|
||||||
|
if settled_on_schedule || hit_bound {
|
||||||
|
self.fling = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(redraw) = &self.redraw {
|
||||||
|
redraw.request_redraw();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Snap to the newest content (last item, or the `more_after`
|
/// Snap to the newest content (last item, or the `more_after`
|
||||||
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
||||||
pub fn jump_to_end(&mut self) {
|
pub fn jump_to_end(&mut self) {
|
||||||
@@ -725,26 +853,33 @@ impl Widget for List {
|
|||||||
};
|
};
|
||||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
||||||
|
|
||||||
let mut idx = anchor.slot;
|
let mut idx_top = anchor.slot;
|
||||||
while top > 0.0 {
|
while top > 0.0 {
|
||||||
let Some(prev) = self.prev_slot(idx) else {
|
let Some(prev) = self.prev_slot(idx_top) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||||
top = t;
|
top = t;
|
||||||
idx = prev;
|
idx_top = prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
idx = anchor.slot;
|
let mut idx_bottom = anchor.slot;
|
||||||
while bottom < self.viewport_len {
|
while bottom < self.viewport_len {
|
||||||
let Some(next) = self.next_slot(idx) else {
|
let Some(next) = self.next_slot(idx_bottom) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||||
bottom = b;
|
bottom = b;
|
||||||
idx = next;
|
idx_bottom = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
||||||
|
// field doc. `top`/`bottom` are the extreme edges actually placed
|
||||||
|
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
||||||
|
// "no more content" means everywhere else in this widget.
|
||||||
|
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||||
|
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
||||||
|
|
||||||
self.update_snap_end();
|
self.update_snap_end();
|
||||||
Size::REST
|
Size::REST
|
||||||
}
|
}
|
||||||
@@ -1178,4 +1313,147 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enough rows, tall enough, that a fling toward the start has real
|
||||||
|
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||||
|
/// tests below.
|
||||||
|
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||||
|
let mut list = List::new(Axis::Y);
|
||||||
|
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
|
||||||
|
let (list_weak, root) = add_list(rsc, list);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 600.0));
|
||||||
|
render.update(&root, rsc);
|
||||||
|
(list_weak, root, render)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fling_moves_the_list_and_then_settles() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
|
||||||
|
// A fling toward the start: negative velocity, matching `scroll`'s
|
||||||
|
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
|
||||||
|
// downward finger motion revealing older content).
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||||
|
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut last_still_scrolling = true;
|
||||||
|
for step in 0..600 {
|
||||||
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
|
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
if !last_still_scrolling {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!last_still_scrolling,
|
||||||
|
"fling never settled within 600 steps"
|
||||||
|
);
|
||||||
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fling_distance_is_positive_toward_the_end() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
// Start scrolled away from the newest end so there is room for an
|
||||||
|
// end-ward fling to actually move.
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
for step in 0..600 {
|
||||||
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
|
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
if !still {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
|
// Row 0 either scrolled out of the loaded extents (flung well past
|
||||||
|
// it) or moved upward (smaller top) -- either way, real motion
|
||||||
|
// happened toward the end rather than staying put.
|
||||||
|
if let Some(after) = list_ref.extents.get(&0) {
|
||||||
|
assert!(
|
||||||
|
after.top < before.top,
|
||||||
|
"fling toward the end did not move content up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.tick_fling(start + std::time::Duration::from_millis(16));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
|
||||||
|
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||||
|
|
||||||
|
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||||
|
// A tick after cancelling must be a no-op -- this is what a fresh
|
||||||
|
// touch-down relies on to stop a fling in its tracks.
|
||||||
|
let still = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.tick_fling(start + std::time::Duration::from_millis(200));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(!still);
|
||||||
|
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||||
|
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fling_toward_the_start_stops_at_the_first_row() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||||
|
// An enormous velocity that would travel far past all 200 rows if
|
||||||
|
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
|
||||||
|
// better for stress testing" fling asks for.
|
||||||
|
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
for step in 0..2000 {
|
||||||
|
let now = start + std::time::Duration::from_millis(step * 16);
|
||||||
|
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
if !still {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||||
|
assert!(
|
||||||
|
list_ref.at_start,
|
||||||
|
"fling should have clamped at the first row"
|
||||||
|
);
|
||||||
|
let first = list_ref.extents[&0];
|
||||||
|
assert!(
|
||||||
|
first.top >= -0.5,
|
||||||
|
"clamped fling overshot the first row's top: {}",
|
||||||
|
first.top
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,12 @@ pub struct Selection {
|
|||||||
/// pan wanting the same touch gesture). See `drag` below, and
|
/// pan wanting the same touch gesture). See `drag` below, and
|
||||||
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
||||||
arbiter: DragArbiter,
|
arbiter: DragArbiter,
|
||||||
|
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
|
||||||
|
/// signed units `list.scroll` takes), so a release that turns out to
|
||||||
|
/// have been panning can hand `List::fling` a realistic initial
|
||||||
|
/// velocity instead of one frame's noisy last delta --
|
||||||
|
/// IRIS_TODO.md's "swiping has no momentum."
|
||||||
|
velocity: VelocityTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Selection {
|
impl Default for Selection {
|
||||||
@@ -55,6 +61,7 @@ impl Selection {
|
|||||||
rows: BTreeMap::new(),
|
rows: BTreeMap::new(),
|
||||||
anchor: None,
|
anchor: None,
|
||||||
arbiter: DragArbiter::new(),
|
arbiter: DragArbiter::new(),
|
||||||
|
velocity: VelocityTracker::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +185,21 @@ impl Selection {
|
|||||||
CursorSense::PressStart(_) => {
|
CursorSense::PressStart(_) => {
|
||||||
let already_selected = self.has_selection(ui);
|
let already_selected = self.has_selection(ui);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
|
self.velocity.reset();
|
||||||
|
// A fresh touch-down cancels any fling still coasting from
|
||||||
|
// the previous gesture -- `List::fling`'s own doc, and
|
||||||
|
// Android's `Scroller::abortAnimation` for the same reason.
|
||||||
|
list(ui).cancel_fling();
|
||||||
self.arbiter.update(pos_window, now)
|
self.arbiter.update(pos_window, now)
|
||||||
}
|
}
|
||||||
CursorSense::PressEnd(_) => {
|
CursorSense::PressEnd(_) => {
|
||||||
|
// A fling only ever follows a pan -- never a selection
|
||||||
|
// that happened to end with the finger still moving, and
|
||||||
|
// never a tap/long-press that never left `Undecided`.
|
||||||
|
if self.arbiter.is_panning() {
|
||||||
|
let v = self.velocity.velocity();
|
||||||
|
list(ui).fling(v);
|
||||||
|
}
|
||||||
self.arbiter.release();
|
self.arbiter.release();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -198,13 +217,19 @@ impl Selection {
|
|||||||
_ if self.arbiter.is_idle() => {
|
_ if self.arbiter.is_idle() => {
|
||||||
let already_selected = self.has_selection(ui);
|
let already_selected = self.has_selection(ui);
|
||||||
self.arbiter.press_start(pos_window, now, already_selected);
|
self.arbiter.press_start(pos_window, now, already_selected);
|
||||||
|
self.velocity.reset();
|
||||||
|
list(ui).cancel_fling();
|
||||||
self.arbiter.update(pos_window, now)
|
self.arbiter.update(pos_window, now)
|
||||||
}
|
}
|
||||||
_ => self.arbiter.update(pos_window, now),
|
_ => self.arbiter.update(pos_window, now),
|
||||||
};
|
};
|
||||||
match outcome {
|
match outcome {
|
||||||
DragOutcome::Undecided => {}
|
DragOutcome::Undecided => {}
|
||||||
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
|
DragOutcome::Pan(dy) => {
|
||||||
|
let amt = -dy;
|
||||||
|
self.velocity.add_sample(amt, now);
|
||||||
|
list(ui).scroll(amt);
|
||||||
|
}
|
||||||
DragOutcome::SelectStart => {
|
DragOutcome::SelectStart => {
|
||||||
// Grep-able on "iris selection" the way the frame report is
|
// Grep-able on "iris selection" the way the frame report is
|
||||||
// on "iris frame report" -- selection has no accessibility
|
// on "iris frame report" -- selection has no accessibility
|
||||||
|
|||||||
Reference in new issue
Block a user