iris: every scroll area flings, on either axis, through one Flinger
Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll areas.
Flinging should be enabled by default in all scroll areas on android to
match composes behavior." Compose's `scrollable` attaches
`ScrollableDefaults.flingBehavior()` on every axis it is given and it is
not something a caller opts into, so neither is this.
`iris::sense::Flinger` is the fling `List` already had, taken out of it:
the `FlingCalculator` curve, the clock (started at the first tick, not
the release, so a caller on an explicit clock is not handed a fling that
has already expired), the incremental delta, Compose's two release
thresholds and the trace line. What it deliberately does *not* know is
which way a positive delta moves the content or whether there is content
left to move into -- a `List` scrolls its anchor one way and a `Scroll`
moves its `amt` the other, so the caller applies `tick`'s delta in its
own convention and calls `stop` at its own wall. `List` keeps
`fling`/`tick_fling`/`is_scrolling`/`cancel_fling` unchanged as a
surface, now three lines each over the shared type.
`Scroll` gains it, plus the two things a coasting widget needs and it
had no reason to have before: the display density (read from the painter
in `draw`, since the deceleration is physical -- a hardcoded 1.0 made a
one-second coast run for 45 on a list), and `PressState::scrolling`, so
a finger put down on a coasting fence stops it there from the first
sample rather than after `DRAG_SLOP`. `Scroll::drag` now answers whether
it started a fling, which is what `WidgetLike::scroll_area` needs to
call `UiData::animate` -- the same split `List::fling`'s doc describes,
and for the same reason: only the caller can reach the frame loop.
`Scroll::axis()` is public for a caller that found the widget rather
than built it.
Tests: `scroll.rs`'s three (a released pan coasts and decelerates on both
axes; both walls stop it; a press on coasting content catches it with no
slop), and `transcript-fixture/tests/fence_fling.rs`, which flicks a real
markdown fence in the real transcript screen and reads the fence's own
`Scroll` back out of what was drawn. Confirmed to fail with the release
arm removed ("the fence stopped dead at the release: 272 -> 272").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc82d9d7e8
commit
02b277e7ad
5 files changed
+561
-122
No files matched your search
@@ -1953,6 +1953,169 @@ impl FlingCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One fling in flight: the physics ([`FlingCalculator`]) plus how much of
|
||||||
|
/// its total travel has already been applied, so a tick only ever hands
|
||||||
|
/// back this frame's *incremental* delta.
|
||||||
|
///
|
||||||
|
/// It owns the curve and the clock and nothing else. Which way a positive
|
||||||
|
/// delta moves the content, and whether the content has anywhere left to
|
||||||
|
/// go, are the caller's -- a `List` scrolls its anchor one way and a
|
||||||
|
/// `Scroll` moves its `amt` the other, and a `Flinger` that tried to know
|
||||||
|
/// which would have to be told, which is the same thing as not knowing.
|
||||||
|
/// So a caller applies [`Self::tick`]'s delta in its own convention and
|
||||||
|
/// calls [`Self::stop`] when it runs out of content.
|
||||||
|
///
|
||||||
|
/// Every scrolling widget in this crate flings through this one type,
|
||||||
|
/// which is what Iris's 2026-09-08 "flinging doesn't work in horizontal
|
||||||
|
/// scroll areas -- flinging should be enabled by default in all scroll
|
||||||
|
/// areas on android to match compose's behavior" asks for: Compose's
|
||||||
|
/// `scrollable` attaches `ScrollableDefaults.flingBehavior()` on every
|
||||||
|
/// axis, and it is not something a caller opts into.
|
||||||
|
pub struct Flinger {
|
||||||
|
fling: Option<InFlight>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InFlight {
|
||||||
|
calc: FlingCalculator,
|
||||||
|
velocity: f32,
|
||||||
|
/// When the curve begins -- **the first [`Flinger::tick`], not the
|
||||||
|
/// release**. Set there so the only clock this reads is the one its
|
||||||
|
/// driver hands it: a caller running frames on an explicit clock
|
||||||
|
/// (`iris::harness`, `bench_client.rs`'s scripted phases) would
|
||||||
|
/// otherwise start every fling at the wall clock and advance it on a
|
||||||
|
/// different one, and a fling released at t=500ms would arrive
|
||||||
|
/// already over. In a running app the difference is at most one
|
||||||
|
/// frame, since that is how soon a fling is first ticked.
|
||||||
|
started_at: Option<Instant>,
|
||||||
|
applied: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Flinger {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flinger {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { fling: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a fling at `velocity_px_per_s`, in whatever pixel space the
|
||||||
|
/// caller applies [`Self::tick`]'s delta in. `density` is physical
|
||||||
|
/// pixels per dp, from the painter -- it does **not** cancel out of
|
||||||
|
/// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a
|
||||||
|
/// 2.55-density screen made a one-second coast run for 45.
|
||||||
|
///
|
||||||
|
/// Answers whether a fling actually started, which is a caller's cue
|
||||||
|
/// to register for frames (`UiData::animate`): registering a widget
|
||||||
|
/// that is not animating only asks the next frame to find that out.
|
||||||
|
/// Cancels any fling already in progress.
|
||||||
|
///
|
||||||
|
/// Compose's two thresholds at a release, and **only** those two. The
|
||||||
|
/// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
|
||||||
|
/// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
|
||||||
|
/// `VelocityTracker.calculateVelocity(maximumVelocity)`; it is applied
|
||||||
|
/// here rather than in the tracker because the tracker works in pixels
|
||||||
|
/// and has no density. The minimum is 1px/s, from
|
||||||
|
/// `DefaultFlingBehavior.performFling`'s `abs(initialVelocity) > 1f`
|
||||||
|
/// and its own stated reason ("we need it since spline curve gives us
|
||||||
|
/// NaNs") -- **not**
|
||||||
|
/// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s, whose
|
||||||
|
/// single use in either artifact is `NestedScrollInteropConnection`,
|
||||||
|
/// for View interop. A 50dp/s floor would swallow slow, deliberate
|
||||||
|
/// releases that Compose flings.
|
||||||
|
pub fn start(&mut self, velocity_px_per_s: f32, density: f32) -> bool {
|
||||||
|
// A NaN/inf velocity (a `VelocityTracker::velocity()`
|
||||||
|
// divide-by-near-zero span, or a caller passing a raw device value
|
||||||
|
// straight through) would propagate silently into
|
||||||
|
// `deceleration_for`'s `.ln()` -- the fling either never settles
|
||||||
|
// or jumps to NaN positions with nothing on screen saying why
|
||||||
|
// (docs/REVIEW-2026-09-06.md finding 3). A plain `assert!` rather
|
||||||
|
// than a `debug_assert!`: it is one comparison per *gesture*, and
|
||||||
|
// every build anybody runs -- the emulator's and Iris's phone's --
|
||||||
|
// is release, where a debug-only guard against silently wrong
|
||||||
|
// output is no guard at all (docs/REVIEW-2026-09-07.md's R1).
|
||||||
|
assert!(velocity_px_per_s.is_finite());
|
||||||
|
assert!(density.is_finite() && density > 0.0);
|
||||||
|
let max = MAX_FLING_VELOCITY_DP_S * density;
|
||||||
|
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
|
||||||
|
if velocity_px_per_s.abs() <= 1.0 {
|
||||||
|
self.fling = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.fling = Some(InFlight {
|
||||||
|
calc: FlingCalculator::new(density),
|
||||||
|
velocity: velocity_px_per_s,
|
||||||
|
started_at: None,
|
||||||
|
applied: 0.0,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a fling is in flight. What a caller polls to decide whether
|
||||||
|
/// a fresh press is a *catch* ([`PressState::scrolling`]) and when to
|
||||||
|
/// stop driving [`Self::tick`].
|
||||||
|
pub fn is_flinging(&self) -> bool {
|
||||||
|
self.fling.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The velocity a fling in progress is coasting at, `None` at rest --
|
||||||
|
/// what a test reads to see what a release actually measured, at the
|
||||||
|
/// place it landed.
|
||||||
|
pub fn velocity(&self) -> Option<f32> {
|
||||||
|
self.fling.as_ref().map(|f| f.velocity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End any fling with no further movement -- the next touch-down's
|
||||||
|
/// job (Android's `Scroller::abortAnimation`, which the view is
|
||||||
|
/// likewise expected to call: the curve has no idea a finger came back
|
||||||
|
/// down), and equally what a caller calls when the content has run out
|
||||||
|
/// underneath it.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
self.fling = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance to `now` and answer how far to move the content *this*
|
||||||
|
/// frame, in the caller's own sign convention. `0.0` with nothing
|
||||||
|
/// flinging, so a caller does not need to check first; the fling ends
|
||||||
|
/// itself on the spline's own schedule, after which
|
||||||
|
/// [`Self::is_flinging`] is false and the caller stops asking for
|
||||||
|
/// frames.
|
||||||
|
pub fn tick(&mut self, now: Instant) -> f32 {
|
||||||
|
let Some(f) = &mut self.fling else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
|
||||||
|
let target = f.calc.position_at(f.velocity, elapsed);
|
||||||
|
let delta = target - f.applied;
|
||||||
|
f.applied = target;
|
||||||
|
// The evidence that the spline is actually being followed, at the
|
||||||
|
// one granularity where a linear coast and a decelerating one look
|
||||||
|
// different: successive `dy` and `speed` shrinking. It was neither
|
||||||
|
// observable nor observed while `distance_fraction` returned `t`
|
||||||
|
// (`android_fling_spline`'s doc). Gated on
|
||||||
|
// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/
|
||||||
|
// RUST.md's review, D1): one line per fling *tick*, unconditional,
|
||||||
|
// was enough on its own to help fill the log ring.
|
||||||
|
if crate::diagnostics::trace_enabled() {
|
||||||
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
|
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
||||||
|
elapsed.as_secs_f32(),
|
||||||
|
delta,
|
||||||
|
f.calc.velocity_at(f.velocity, elapsed),
|
||||||
|
f.velocity,
|
||||||
|
f.calc.distance(f.velocity) - target,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if elapsed >= f.calc.duration(f.velocity) {
|
||||||
|
self.fling = None;
|
||||||
|
}
|
||||||
|
delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod velocity_tracker_tests {
|
mod velocity_tracker_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+22
-101
@@ -224,10 +224,10 @@ 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
|
/// The list's fling, shared with every other scrolling widget in the
|
||||||
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
|
/// crate -- see `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's
|
||||||
/// momentum."
|
/// "swiping has no momentum."
|
||||||
fling: Option<Fling>,
|
fling: Flinger,
|
||||||
/// What `tick_fling` re-arms every frame a fling is still running, so
|
/// What `tick_fling` re-arms every frame a fling is still running, so
|
||||||
/// the list keeps animating without needing a caller to poll it --
|
/// the list keeps animating without needing a caller to poll it --
|
||||||
/// set once via `set_redraw_handle` by whoever owns the surface this
|
/// set once via `set_redraw_handle` by whoever owns the surface this
|
||||||
@@ -262,27 +262,6 @@ pub struct List {
|
|||||||
at_end: bool,
|
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,
|
|
||||||
/// When the fling's own curve begins -- **the first `tick_fling`,
|
|
||||||
/// not the release**. It is set there rather than in `fling` so the
|
|
||||||
/// only clock this widget reads is the one its driver hands it: a
|
|
||||||
/// caller running frames on an explicit clock (`iris::harness`, and
|
|
||||||
/// `bench_client.rs`'s scripted phases) would otherwise start every
|
|
||||||
/// fling at the wall clock and advance it on a different one, and a
|
|
||||||
/// fling released at t=500ms would arrive already over. The
|
|
||||||
/// difference in a running app is at most one frame, since that is
|
|
||||||
/// how soon the fling is first ticked.
|
|
||||||
started_at: Option<Instant>,
|
|
||||||
applied: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl List {
|
impl List {
|
||||||
pub fn new(axis: Axis) -> Self {
|
pub fn new(axis: Axis) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -294,7 +273,7 @@ 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,
|
fling: Flinger::new(),
|
||||||
redraw: None,
|
redraw: None,
|
||||||
density: 1.0,
|
density: 1.0,
|
||||||
at_start: false,
|
at_start: false,
|
||||||
@@ -482,46 +461,13 @@ impl List {
|
|||||||
/// (`bench_client.rs`'s fling phase, the headless tests) calls
|
/// (`bench_client.rs`'s fling phase, the headless tests) calls
|
||||||
/// `tick_fling` directly instead and does not register.
|
/// `tick_fling` directly instead and does not register.
|
||||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||||
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
|
// No anchor means this list has never drawn, so there is nothing
|
||||||
// near-zero span, or a caller passing a raw device value straight
|
// to move: `Flinger` cannot know that and this does.
|
||||||
// through) would propagate silently into `deceleration_for`'s
|
if self.anchor.is_none() {
|
||||||
// `.ln()` -- the fling either never settles or jumps to NaN
|
self.fling.stop();
|
||||||
// positions with nothing on screen saying why (docs/
|
|
||||||
// REVIEW-2026-09-06.md finding 3). A plain `assert!` rather than a
|
|
||||||
// `debug_assert!`: it is one comparison per *gesture*, and every
|
|
||||||
// build anybody runs -- the emulator's and Iris's phone's -- is
|
|
||||||
// release, where a debug-only guard against silently wrong output
|
|
||||||
// is no guard at all (docs/REVIEW-2026-09-07.md's R1).
|
|
||||||
assert!(velocity_px_per_s.is_finite());
|
|
||||||
// Compose's two thresholds at a release, and **only** those two.
|
|
||||||
//
|
|
||||||
// The maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()`
|
|
||||||
// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into
|
|
||||||
// `VelocityTracker.calculateVelocity(maximumVelocity)`. It is
|
|
||||||
// applied here rather than in the tracker because the tracker
|
|
||||||
// works in pixels and has no density; this widget takes one from
|
|
||||||
// the painter in `draw`.
|
|
||||||
//
|
|
||||||
// The minimum is 1px/s, from `DefaultFlingBehavior.performFling`'s
|
|
||||||
// `abs(initialVelocity) > 1f` and its own stated reason ("we need
|
|
||||||
// it since spline curve gives us NaNs") -- not
|
|
||||||
// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s,
|
|
||||||
// which Compose's scrolling never consults: its single use in
|
|
||||||
// either artifact is `NestedScrollInteropConnection`, for View
|
|
||||||
// interop. A 50dp/s floor would swallow slow, deliberate releases
|
|
||||||
// that Compose flings, so it is deliberately not here.
|
|
||||||
let max = MAX_FLING_VELOCITY_DP_S * self.density;
|
|
||||||
let velocity_px_per_s = velocity_px_per_s.clamp(-max, max);
|
|
||||||
if velocity_px_per_s.abs() <= 1.0 || self.anchor.is_none() {
|
|
||||||
self.fling = None;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.fling = Some(Fling {
|
self.fling.start(velocity_px_per_s, self.density);
|
||||||
calc: FlingCalculator::new(self.density),
|
|
||||||
velocity: velocity_px_per_s,
|
|
||||||
started_at: None,
|
|
||||||
applied: 0.0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a fling is currently animating. What a caller's own
|
/// Whether a fling is currently animating. What a caller's own
|
||||||
@@ -529,7 +475,7 @@ impl List {
|
|||||||
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
||||||
/// "moving on its own" for any other purpose.
|
/// "moving on its own" for any other purpose.
|
||||||
pub fn is_scrolling(&self) -> bool {
|
pub fn is_scrolling(&self) -> bool {
|
||||||
self.fling.is_some()
|
self.fling.is_flinging()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The velocity a fling in progress is coasting at, in this list's
|
/// The velocity a fling in progress is coasting at, in this list's
|
||||||
@@ -540,13 +486,13 @@ impl List {
|
|||||||
/// measured at the place it landed, rather than re-timing the
|
/// measured at the place it landed, rather than re-timing the
|
||||||
/// gesture itself.
|
/// gesture itself.
|
||||||
pub fn fling_velocity(&self) -> Option<f32> {
|
pub fn fling_velocity(&self) -> Option<f32> {
|
||||||
self.fling.as_ref().map(|f| f.velocity)
|
self.fling.velocity()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel any fling in progress with no further movement -- the next
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
/// touch-down's job, per `fling`'s own doc.
|
/// touch-down's job, per `fling`'s own doc.
|
||||||
pub fn cancel_fling(&mut self) {
|
pub fn cancel_fling(&mut self) {
|
||||||
self.fling = None;
|
self.fling.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance an in-flight fling to `now`, applying this call's share of
|
/// Advance an in-flight fling to `now`, applying this call's share of
|
||||||
@@ -561,47 +507,22 @@ impl List {
|
|||||||
/// Safe to call even with no fling active (a no-op returning `false`),
|
/// Safe to call even with no fling active (a no-op returning `false`),
|
||||||
/// so a caller does not need to check `is_scrolling` first.
|
/// so a caller does not need to check `is_scrolling` first.
|
||||||
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
||||||
let Some(f) = &mut self.fling else {
|
let Some(velocity) = self.fling.velocity() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
|
let delta = self.fling.tick(now);
|
||||||
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;
|
|
||||||
// The evidence that the spline is actually being followed, at the
|
|
||||||
// one granularity where a linear coast and a decelerating one look
|
|
||||||
// different: successive `dy` and `speed` shrinking. It was neither
|
|
||||||
// observable nor observed while `distance_fraction` returned `t`
|
|
||||||
// (`android_fling_spline`'s doc), which is why this is here rather
|
|
||||||
// than the total-travel line the release log already carries.
|
|
||||||
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
|
|
||||||
// (docs/RUST.md's review, D1): one line per fling *tick*,
|
|
||||||
// unconditional, was enough on its own to help fill the log
|
|
||||||
// ring -- see `android::view::IrisViewPeer::render`'s own doc for
|
|
||||||
// the same finding on its two per-frame lines.
|
|
||||||
if crate::diagnostics::trace_enabled() {
|
|
||||||
log::debug!(
|
|
||||||
target: "iris::frame",
|
|
||||||
"iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px",
|
|
||||||
elapsed.as_secs_f32(),
|
|
||||||
delta,
|
|
||||||
f.calc.velocity_at(velocity, elapsed),
|
|
||||||
velocity,
|
|
||||||
f.calc.distance(velocity) - target,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.scroll(delta);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
// it (or one moving toward the end that has already reached that)
|
// it (or one moving toward the end that has already reached that)
|
||||||
// stops rather than continuing to spend its remaining distance on
|
// stops rather than continuing to spend its remaining distance on
|
||||||
// a part of the list that will never scroll further.
|
// a part of the list that will never scroll further. `at_start`/
|
||||||
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
|
// `at_end` are what the last draw found, which is the only thing
|
||||||
|
// here that knows where the content ends.
|
||||||
if settled_on_schedule || hit_bound {
|
if (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end) {
|
||||||
self.fling = None;
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
if !self.fling.is_flinging() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(redraw) = &self.redraw {
|
if let Some(redraw) = &self.redraw {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::sense::{DragGesture, GestureOutcome, PointerRequests, PressState};
|
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub struct Scroll {
|
pub struct Scroll {
|
||||||
@@ -26,9 +26,40 @@ pub struct Scroll {
|
|||||||
/// `sense.rs` and only what a committed pan *means* is decided here.
|
/// `sense.rs` and only what a committed pan *means* is decided here.
|
||||||
/// See [`Self::drag`].
|
/// See [`Self::drag`].
|
||||||
gesture: DragGesture,
|
gesture: DragGesture,
|
||||||
|
/// The momentum a release leaves behind, the same [`Flinger`] a
|
||||||
|
/// `List` coasts on. Every scroll area flings, on either axis and
|
||||||
|
/// with nothing to opt into -- Compose's `scrollable` attaches
|
||||||
|
/// `ScrollableDefaults.flingBehavior()` on every axis it is given,
|
||||||
|
/// and Iris asked for the same (2026-09-08: "flinging should be
|
||||||
|
/// enabled by default in all scroll areas on android to match
|
||||||
|
/// composes behavior").
|
||||||
|
fling: Flinger,
|
||||||
|
/// Physical pixels per dp, copied from the painter on every draw --
|
||||||
|
/// what a fling's deceleration is computed against. 1.0 until this
|
||||||
|
/// widget has drawn once, which is also the only state in which
|
||||||
|
/// nothing can be flung, since there is no content length yet.
|
||||||
|
density: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Scroll {
|
impl Widget for Scroll {
|
||||||
|
/// A `Scroll` animates exactly one thing, its fling. The registration
|
||||||
|
/// that makes this run is `UiData::animate`, which
|
||||||
|
/// `WidgetLike::scroll_area`'s own drag handler calls the frame a
|
||||||
|
/// release starts one.
|
||||||
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
let delta = self.fling.tick(now);
|
||||||
|
self.scroll(delta);
|
||||||
|
// A fling must not keep spending its distance on content that is
|
||||||
|
// not there. Unlike `List`, this widget knows exactly where its
|
||||||
|
// content ends -- `update_amt` has just clamped `amt` into it --
|
||||||
|
// so the wall is read after the move rather than from what the
|
||||||
|
// last draw found.
|
||||||
|
if self.amt <= 0.0 || self.amt >= self.scroll_range() {
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
self.fling.is_flinging()
|
||||||
|
}
|
||||||
|
|
||||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
// The region offered to the child is sized using *last* frame's
|
// The region offered to the child is sized using *last* frame's
|
||||||
// content length, not a fresh measurement -- deliberately, so that
|
// content length, not a fresh measurement -- deliberately, so that
|
||||||
@@ -58,6 +89,11 @@ impl Widget for Scroll {
|
|||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let container_len = painter.px_size().axis(axis);
|
let container_len = painter.px_size().axis(axis);
|
||||||
self.container_len = container_len;
|
self.container_len = container_len;
|
||||||
|
// Learned from the frame rather than passed in: a fling's
|
||||||
|
// deceleration is a physical quantity and needs the real display
|
||||||
|
// density, and `draw` is where this widget meets the only thing
|
||||||
|
// that knows it.
|
||||||
|
self.density = painter.density();
|
||||||
|
|
||||||
if self.snap_end
|
if self.snap_end
|
||||||
&& let Some(content_len) = self.content_len
|
&& let Some(content_len) = self.content_len
|
||||||
@@ -111,6 +147,8 @@ impl Scroll {
|
|||||||
container_len: 0.0,
|
container_len: 0.0,
|
||||||
content_len: None,
|
content_len: None,
|
||||||
gesture: DragGesture::on(axis),
|
gesture: DragGesture::on(axis),
|
||||||
|
fling: Flinger::new(),
|
||||||
|
density: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +166,12 @@ impl Scroll {
|
|||||||
/// takes the gesture over. Android's own `EditText` behaves the same
|
/// takes the gesture over. Android's own `EditText` behaves the same
|
||||||
/// way -- a vertical drag scrolls, and only a long press selects.
|
/// way -- a vertical drag scrolls, and only a long press selects.
|
||||||
///
|
///
|
||||||
/// No fling: unlike `List`, `Scroll` has no per-frame tick to animate
|
/// Answers whether this frame *started a fling*, which is the
|
||||||
/// one with (`List::set_redraw_handle`/`tick_fling`), and the areas
|
/// caller's cue to register the widget for frames
|
||||||
/// this wraps today -- a six-line composer, a diagnostics pane -- are
|
/// (`UiData::animate`) -- see [`Widget::tick`]. Split that way
|
||||||
/// at most a screenful, where Android does not fling either. The
|
/// because the two halves have different owners: the velocity is this
|
||||||
/// released velocity is deliberately dropped rather than approximated.
|
/// widget's business and whether anything animates at all is the
|
||||||
|
/// frame loop's, and `drag` has no `Rsc` to reach the loop through.
|
||||||
pub fn drag(
|
pub fn drag(
|
||||||
&mut self,
|
&mut self,
|
||||||
pointer: &PointerRequests,
|
pointer: &PointerRequests,
|
||||||
@@ -140,17 +179,27 @@ impl Scroll {
|
|||||||
sense: CursorSense,
|
sense: CursorSense,
|
||||||
pos_window: Vec2,
|
pos_window: Vec2,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
) {
|
) -> bool {
|
||||||
// A default `PressState`: a scroll area has no selection of its
|
// A scroll area has no selection of its own to extend, so a drag
|
||||||
// own to extend, so a horizontal drag stays `Undecided` and a
|
// across the axis stays `Undecided` and one along it past the
|
||||||
// vertical one past the slop pans, which is the whole contract
|
// slop pans, which is the whole contract here. A caller that
|
||||||
// here; and it never flings (see this method's doc), so there is
|
// *does* own a selection (the transcript's `Selection`) drives
|
||||||
// never a moving target to catch either. A caller that *does* own
|
|
||||||
// a selection, or a fling (the transcript's `Selection`), drives
|
|
||||||
// `DragGesture` itself instead.
|
// `DragGesture` itself instead.
|
||||||
|
//
|
||||||
|
// `scrolling` is the other half: a finger put down on content
|
||||||
|
// that is still coasting means "stop it here", and commits to a
|
||||||
|
// pan on that very sample with no slop to wait out
|
||||||
|
// (`DragArbiter::press_start`). The fling is cancelled in the
|
||||||
|
// same breath, since the curve has no idea a finger came back
|
||||||
|
// down.
|
||||||
|
let mut press = PressState::default();
|
||||||
|
if self.gesture.starts_press(sense) {
|
||||||
|
press.scrolling = self.fling.is_flinging();
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
match self
|
match self
|
||||||
.gesture
|
.gesture
|
||||||
.handle(pointer, id, sense, pos_window, now, PressState::default())
|
.handle(pointer, id, sense, pos_window, now, press)
|
||||||
{
|
{
|
||||||
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
// `scroll(dy)`, not `scroll(-dy)` -- `Selection::drag` passes
|
||||||
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
// `-dy` to `List::scroll` because a `List`'s anchor offset and
|
||||||
@@ -161,12 +210,28 @@ impl Scroll {
|
|||||||
// holds for both, and the one to check a sign against, is that
|
// holds for both, and the one to check a sign against, is that
|
||||||
// the content follows the finger.
|
// the content follows the finger.
|
||||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
|
// Same sign as `Pan`, since `tick` applies it through the
|
||||||
|
// same `scroll`.
|
||||||
|
GestureOutcome::Released(Some(v)) => {
|
||||||
|
return self.fling.start(v, self.density);
|
||||||
|
}
|
||||||
GestureOutcome::Undecided
|
GestureOutcome::Undecided
|
||||||
| GestureOutcome::Tapped
|
| GestureOutcome::Tapped
|
||||||
| GestureOutcome::SelectStart
|
| GestureOutcome::SelectStart
|
||||||
| GestureOutcome::SelectExtend
|
| GestureOutcome::SelectExtend
|
||||||
| GestureOutcome::Cancelled
|
| GestureOutcome::Cancelled
|
||||||
| GestureOutcome::Released(_) => {}
|
| GestureOutcome::Released(None) => {}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far this area can be panned: the content's length past the
|
||||||
|
/// container's, or zero when it all fits. The one arithmetic
|
||||||
|
/// `update_amt`'s clamp and `tick`'s wall both ask for, stated once.
|
||||||
|
fn scroll_range(&self) -> f32 {
|
||||||
|
match self.content_len {
|
||||||
|
Some(len) => (len - self.container_len).max(0.0),
|
||||||
|
None => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +243,10 @@ impl Scroll {
|
|||||||
/// question that cannot be answered yet -- answering it anyway is
|
/// question that cannot be answered yet -- answering it anyway is
|
||||||
/// what `content_len`'s doc describes.
|
/// what `content_len`'s doc describes.
|
||||||
pub fn update_amt(&mut self) {
|
pub fn update_amt(&mut self) {
|
||||||
let Some(content_len) = self.content_len else {
|
if self.content_len.is_none() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let len = (content_len - self.container_len).max(0.0);
|
let len = self.scroll_range();
|
||||||
self.amt = self.amt.clamp(0.0, len);
|
self.amt = self.amt.clamp(0.0, len);
|
||||||
self.snap_end = self.amt == len;
|
self.snap_end = self.amt == len;
|
||||||
}
|
}
|
||||||
@@ -193,6 +258,13 @@ impl Scroll {
|
|||||||
self.amt
|
self.amt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which way this area pans. For a caller that found the widget
|
||||||
|
/// rather than built it -- a test walking what is drawn, a scroll
|
||||||
|
/// indicator asking which edge to sit on.
|
||||||
|
pub fn axis(&self) -> Axis {
|
||||||
|
self.axis
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scroll(&mut self, amt: f32) {
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
self.amt -= amt;
|
self.amt -= amt;
|
||||||
self.update_amt();
|
self.update_amt();
|
||||||
@@ -361,4 +433,155 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
|
assert!((s.amt - 0.0).abs() < 0.01, "amt={}", s.amt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
|
||||||
|
/// areas. Flinging should be enabled by default in all scroll areas
|
||||||
|
/// on android to match composes behavior." A release with real
|
||||||
|
/// velocity coasts, decelerating, and settles on its own.
|
||||||
|
#[test]
|
||||||
|
fn a_released_pan_flings_and_settles() {
|
||||||
|
for axis in [Axis::X, Axis::Y] {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
s.axis = axis;
|
||||||
|
s.gesture = DragGesture::on(axis);
|
||||||
|
let render = PointerRequests::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
|
||||||
|
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
at(0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
// Four samples 8ms apart, accelerating away from the start --
|
||||||
|
// three is the fewest `VelocityTracker`'s quadratic fit can
|
||||||
|
// use, so this is a gesture that genuinely has a velocity.
|
||||||
|
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
at(d),
|
||||||
|
t + Duration::from_millis(8 * (i as u64 + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let at_release = s.amt;
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
at(-280.0),
|
||||||
|
t + Duration::from_millis(32),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.fling.is_flinging(),
|
||||||
|
"{axis:?}: a released pan with velocity must fling"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Frames at 8ms until it stops, with each step no longer than
|
||||||
|
// the one before it -- a coast that does not decelerate is
|
||||||
|
// the linear-spline bug this crate has had once already.
|
||||||
|
let mut last_step = f32::INFINITY;
|
||||||
|
let mut ticks = 0;
|
||||||
|
let mut now = t + Duration::from_millis(32);
|
||||||
|
while s.tick(now) {
|
||||||
|
let before = s.amt;
|
||||||
|
now += Duration::from_millis(8);
|
||||||
|
s.tick(now);
|
||||||
|
let step = (s.amt - before).abs();
|
||||||
|
assert!(
|
||||||
|
step <= last_step + 0.01,
|
||||||
|
"{axis:?}: the fling sped up: {last_step} then {step}"
|
||||||
|
);
|
||||||
|
last_step = step;
|
||||||
|
ticks += 1;
|
||||||
|
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
s.amt > at_release,
|
||||||
|
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wall: a fling must not spend its remaining distance on content
|
||||||
|
/// that is not there. Released hard toward the start, it settles
|
||||||
|
/// exactly on it.
|
||||||
|
#[test]
|
||||||
|
fn a_fling_stops_at_the_end_of_the_content() {
|
||||||
|
// Both walls. A positive delta is applied as `amt -= delta`, so a
|
||||||
|
// positive velocity runs toward the start of the content and a
|
||||||
|
// negative one toward its end; 1000px of content in a 100px box
|
||||||
|
// leaves `amt` in 0..=900.
|
||||||
|
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
|
||||||
|
let (_ui, mut s, _id) = area();
|
||||||
|
s.fling.start(velocity, 1.0);
|
||||||
|
let t = Instant::now();
|
||||||
|
let mut now = t;
|
||||||
|
for _ in 0..1_000 {
|
||||||
|
if !s.tick(now) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
now += Duration::from_millis(8);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!s.fling.is_flinging(),
|
||||||
|
"the fling toward {wall} ran past the content"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - wall).abs() < 0.01,
|
||||||
|
"it should have settled on {wall}, got amt={}",
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finger on coasting content stops it there, from the first
|
||||||
|
/// sample, with no `DRAG_SLOP` to wait out -- the catch
|
||||||
|
/// `DragArbiter::press_start` describes, which a scroll area needs
|
||||||
|
/// for the same reason a list does now that it can coast at all.
|
||||||
|
#[test]
|
||||||
|
fn a_press_on_a_coasting_area_catches_it() {
|
||||||
|
let (_ui, mut s, id) = area();
|
||||||
|
s.fling.start(-4_000.0, 1.0);
|
||||||
|
let t = Instant::now();
|
||||||
|
s.tick(t);
|
||||||
|
s.tick(t + Duration::from_millis(8));
|
||||||
|
let caught_at = s.amt;
|
||||||
|
assert!(s.fling.is_flinging(), "the fixture must still be moving");
|
||||||
|
|
||||||
|
let render = PointerRequests::default();
|
||||||
|
let down = t + Duration::from_millis(16);
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
down,
|
||||||
|
);
|
||||||
|
assert!(!s.fling.is_flinging(), "a touch-down must end the fling");
|
||||||
|
assert!(
|
||||||
|
(s.amt - caught_at).abs() < 0.01,
|
||||||
|
"the down itself must not move the content, only stop it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A move well under `DRAG_SLOP` still tracks the finger, because
|
||||||
|
// this press caught something that was moving.
|
||||||
|
s.drag(
|
||||||
|
&render,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 2.0),
|
||||||
|
down + Duration::from_millis(8),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s.amt - (caught_at - 2.0)).abs() < 0.01,
|
||||||
|
"a caught press must pan from its first sample: {} -> {}",
|
||||||
|
caught_at,
|
||||||
|
s.amt
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -124,11 +124,20 @@ widget_trait! {
|
|||||||
// has the arbitration and why there is no fling. The wheel
|
// has the arbitration and why there is no fling. The wheel
|
||||||
// above and this are the two inputs of one scroll, so they
|
// above and this are the two inputs of one scroll, so they
|
||||||
// are registered together rather than left to each caller.
|
// are registered together rather than left to each caller.
|
||||||
.on(CursorSense::drag_senses(), |ctx, rsc| {
|
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
|
||||||
let id = ctx.widget.id();
|
let id = ctx.widget.id();
|
||||||
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
ctx.widget(rsc)
|
let flung =
|
||||||
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
|
ctx.widget(rsc)
|
||||||
|
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
|
||||||
|
// The half that actually makes it move -- a fling is
|
||||||
|
// set by the widget and driven by the frame loop, and
|
||||||
|
// only this side can reach the loop. Only when one
|
||||||
|
// actually started: registering a widget that is not
|
||||||
|
// animating asks the next frame to find that out.
|
||||||
|
if flung {
|
||||||
|
rsc.ui_mut().animate(id);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.add(state)
|
.add(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
|
||||||
|
//! scroll areas": a real markdown fence in the real transcript screen,
|
||||||
|
//! flicked sideways, has to keep moving after the finger leaves.
|
||||||
|
//!
|
||||||
|
//! The fence is pushed here rather than hunted for in the bench fixture,
|
||||||
|
//! so the test knows which row it is pressing and where. The `Scroll` it
|
||||||
|
//! asserts on is found by walking what is actually drawn -- there is no
|
||||||
|
//! handle to it from the outside, and a coordinate would only prove that
|
||||||
|
//! *something* moved.
|
||||||
|
|
||||||
|
use iris::harness::{Harness, TouchAction};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
|
/// The horizontal scroll area drawn inside `top..bottom`, with the box
|
||||||
|
/// it was drawn at -- a fence is the only thing in a transcript that pans
|
||||||
|
/// sideways. Found by walking what is actually drawn, because there is no
|
||||||
|
/// handle to a fence's own `Scroll` from the outside and a bare
|
||||||
|
/// coordinate would only prove that *something* moved.
|
||||||
|
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||||
|
h.render
|
||||||
|
.active
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|&id| {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
|
||||||
|
.is_some_and(|s| s.axis() == Axis::X)
|
||||||
|
})
|
||||||
|
.find_map(|id| {
|
||||||
|
let r = h.render.window_region(&id, &h.rsc)?;
|
||||||
|
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<Scroll>())
|
||||||
|
.expect("the fence's scroll area is still drawn")
|
||||||
|
.amt()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
let screen = opened.screen;
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||||
|
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = transcript_ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||||
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||||
|
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||||
|
|
||||||
|
// Down the middle of the fence's own box, so the press is on the
|
||||||
|
// text inside the scroll area rather than on the row's sender label.
|
||||||
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||||
|
|
||||||
|
// A flick sideways: four samples 8ms apart, accelerating, then the
|
||||||
|
// finger leaves.
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||||
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||||
|
|
||||||
|
let at_release = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
at_release > 0.0,
|
||||||
|
"the flick itself must have panned the fence, got {at_release}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Frames for the next half second, with nothing touching the screen.
|
||||||
|
let mut t = 240;
|
||||||
|
while t <= 740 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let coasted = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
coasted > at_release + 1.0,
|
||||||
|
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and it settles rather than running forever.
|
||||||
|
let settled = coasted;
|
||||||
|
while t <= 4_000 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let after = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
after >= settled,
|
||||||
|
"a fling must not run backwards: {settled} -> {after}"
|
||||||
|
);
|
||||||
|
let last = after;
|
||||||
|
h.frame(t);
|
||||||
|
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user