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:
irisandClaude Opus 5 committed 2026-09-08 15:22:03 -04:00
1 parent fc82d9d7e8
commit 02b277e7ad
5 files changed
+559 -120

No files matched your search

+163
View File
@@ -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
View File
@@ -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 {
+241 -18
View File
@@ -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
);
}
} }
+10 -1
View File
@@ -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);
let flung =
ctx.widget(rsc) ctx.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); .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");
}