Merge remote-tracking branch 'origin/rustify' into worktree-agent-a1ff0294b6c29127e

# Conflicts:
#	docs/RUST.md
This commit is contained in:
iris committed 2026-09-06 00:54:06 -04:00
commit c589a75fa0
6 files changed
+814 -26

No files matched your search

+31
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
}
} }
+26 -1
View File
@@ -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