diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 864ae7a..58d7cdc 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -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, +} + +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, + 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 { + 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)] mod velocity_tracker_tests { use super::*; diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 959a221..8b7c5af 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -224,10 +224,10 @@ pub struct List { /// row is evicted (`pop_front`/`pop_back`) so this cannot grow past /// however many rows are currently loaded. heights: HashMap, - /// 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, + /// The list's fling, shared with every other scrolling widget in the + /// crate -- see `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's + /// "swiping has no momentum." + fling: Flinger, /// 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 @@ -262,27 +262,6 @@ pub struct List { 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, - applied: f32, -} - impl List { pub fn new(axis: Axis) -> Self { Self { @@ -294,7 +273,7 @@ impl List { snap_end: true, viewport_len: 0.0, last_viewport_len: 0.0, - fling: None, + fling: Flinger::new(), redraw: None, density: 1.0, at_start: false, @@ -482,46 +461,13 @@ impl List { /// (`bench_client.rs`'s fling phase, the headless tests) calls /// `tick_fling` directly instead and does not register. pub fn fling(&mut self, velocity_px_per_s: f32) { - // 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()); - // 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; + // No anchor means this list has never drawn, so there is nothing + // to move: `Flinger` cannot know that and this does. + if self.anchor.is_none() { + self.fling.stop(); return; } - self.fling = Some(Fling { - calc: FlingCalculator::new(self.density), - velocity: velocity_px_per_s, - started_at: None, - applied: 0.0, - }); + self.fling.start(velocity_px_per_s, self.density); } /// 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 /// "moving on its own" for any other purpose. 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 @@ -540,13 +486,13 @@ impl List { /// measured at the place it landed, rather than re-timing the /// gesture itself. pub fn fling_velocity(&self) -> Option { - self.fling.as_ref().map(|f| f.velocity) + self.fling.velocity() } /// 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; + self.fling.stop(); } /// 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`), /// 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 { + let Some(velocity) = self.fling.velocity() else { return false; }; - 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; - 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, - ); - } + let delta = self.fling.tick(now); 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; + // a part of the list that will never scroll further. `at_start`/ + // `at_end` are what the last draw found, which is the only thing + // here that knows where the content ends. + if (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end) { + self.fling.stop(); + } + if !self.fling.is_flinging() { return false; } if let Some(redraw) = &self.redraw { diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index d27553d..02f7782 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -1,5 +1,5 @@ use crate::prelude::*; -use crate::sense::{DragGesture, GestureOutcome, PointerRequests, PressState}; +use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState}; use std::time::Instant; pub struct Scroll { @@ -26,9 +26,40 @@ pub struct Scroll { /// `sense.rs` and only what a committed pan *means* is decided here. /// See [`Self::drag`]. 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 { + /// 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 { // The region offered to the child is sized using *last* frame's // content length, not a fresh measurement -- deliberately, so that @@ -58,6 +89,11 @@ impl Widget for Scroll { let axis = self.axis; let container_len = painter.px_size().axis(axis); 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 && let Some(content_len) = self.content_len @@ -111,6 +147,8 @@ impl Scroll { container_len: 0.0, content_len: None, 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 /// way -- a vertical drag scrolls, and only a long press selects. /// - /// No fling: unlike `List`, `Scroll` has no per-frame tick to animate - /// one with (`List::set_redraw_handle`/`tick_fling`), and the areas - /// this wraps today -- a six-line composer, a diagnostics pane -- are - /// at most a screenful, where Android does not fling either. The - /// released velocity is deliberately dropped rather than approximated. + /// Answers whether this frame *started a fling*, which is the + /// caller's cue to register the widget for frames + /// (`UiData::animate`) -- see [`Widget::tick`]. Split that way + /// because the two halves have different owners: the velocity is this + /// 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( &mut self, pointer: &PointerRequests, @@ -140,17 +179,27 @@ impl Scroll { sense: CursorSense, pos_window: Vec2, now: Instant, - ) { - // A default `PressState`: a scroll area has no selection of its - // own to extend, so a horizontal drag stays `Undecided` and a - // vertical one past the slop pans, which is the whole contract - // here; and it never flings (see this method's doc), so there is - // never a moving target to catch either. A caller that *does* own - // a selection, or a fling (the transcript's `Selection`), drives + ) -> bool { + // A scroll area has no selection of its own to extend, so a drag + // across the axis stays `Undecided` and one along it past the + // slop pans, which is the whole contract here. A caller that + // *does* own a selection (the transcript's `Selection`) drives // `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 .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 // `-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 // the content follows the finger. 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::Tapped | GestureOutcome::SelectStart | GestureOutcome::SelectExtend | 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 /// what `content_len`'s doc describes. pub fn update_amt(&mut self) { - let Some(content_len) = self.content_len else { + if self.content_len.is_none() { 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.snap_end = self.amt == len; } @@ -193,6 +258,13 @@ impl Scroll { 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) { self.amt -= amt; self.update_amt(); @@ -361,4 +433,155 @@ mod tests { ); 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 + ); + } } diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index 8ad5fd7..a1b26e6 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -124,11 +124,20 @@ widget_trait! { // has the arbitration and why there is no fling. The wheel // above and this are the two inputs of one scroll, so they // 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 (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); - ctx.widget(rsc) - .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); + let flung = + 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) } diff --git a/iris/transcript-fixture/tests/fence_fling.rs b/iris/transcript-fixture/tests/fence_fling.rs new file mode 100644 index 0000000..e70c8f7 --- /dev/null +++ b/iris/transcript-fixture/tests/fence_fling.rs @@ -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::()) + .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::()) + .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"); +}