Skip to main content

frust_widgets/
scroll.rs

1//! The `ScrollView` widget: a vertical scroll surface with drag,
2//! fling, and wheel support and clipped, offset content.
3//!
4//! [`scroll_view`] wraps a child that is laid out with unbounded height; the
5//! view itself takes the incoming constraints and paints the child offset by
6//! `-scroll_offset` inside a clip. Offsets settle within `[0, content −
7//! viewport]`; what a pointer *drag* past an edge does is the installed
8//! physics' call (wheel stays hard-clamped whatever it says). See
9//! [`ScrollView::on_scroll`] for scroll observation and
10//! [`ScrollView::on_refresh_release`] for the pull-to-refresh trigger.
11//!
12//! # Physics seam
13//!
14//! The feel is not wired in directly: the drag mapping, the
15//! boundary-rejection rule, and post-release ballistic motion are asked of a
16//! [`ScrollPhysics`] ([`crate::physics`]) the widget holds. The installed
17//! default is the platform-adaptive pairing
18//! ([`crate::physics::default_physics`]): Android gets clamping-plus-stretch,
19//! every other platform bouncing-plus-translate.
20//! [`RubberBand`](crate::RubberBand) — the pre-seam feel, a flat
21//! [`OVERSCROLL_RESISTANCE`] rubber band with the legacy fling — is an opt-in
22//! via [`ScrollView::physics`]. Two things stay widget-side on purpose: the
23//! **legacy fling/settle path** (a physics whose
24//! `create_ballistic_simulation` returns `None` — `RubberBand` always does —
25//! leaves post-release motion to [`ScrollWidget::tick`]/
26//! [`ScrollWidget::settle_tick`]), and the **wheel path**, which is a
27//! physics-independent hard clamp. A physics that *does* hand back a
28//! [`Simulation`] gets driven by the generic ballistic driver in
29//! [`ScrollWidget::pump_fling`] instead.
30//!
31//! ## Drag convention: a per-move delta against the live position
32//!
33//! Every drag `Move` hands [`ScrollPhysics::apply_physics_to_user_offset`]
34//! *that move's* raw finger delta, with metrics reporting the surface's real
35//! current position — past-edge displacement and all
36//! ([`ScrollWidget::apply_drag_offset`]). This is Flutter's own convention,
37//! and it is what makes a depth-aware friction curve work: a physics whose
38//! resistance tightens with overscroll depth (`Bouncing`) reads a real depth
39//! rather than a permanent zero. The consequence is that the mapping is
40//! **path-dependent** — the same total pull delivered in two moves and in four
41//! need not land on the same pixel for a non-linear physics — which is
42//! inherent to progressive tension, not a defect of this seam.
43//!
44//! # Overscroll visuals
45//!
46//! *What* a past-edge pull looks like is a separate axis from the physics that
47//! computes it: [`OverscrollEffect`] selects between moving the content with
48//! the pull ([`OverscrollEffect::Translate`], the default and this module's
49//! long-standing behavior), the Material-3-Expressive
50//! [`OverscrollEffect::Stretch`], and no visual at all. The offset itself
51//! evolves identically under all three — only the paint changes.
52//!
53//! Stretch is a **paint-only** vertical scale about the held edge
54//! ([`stretch_about_edge`], driven by [`ScrollWidget::edge_pull`] so a
55//! clamping physics stretches too): the content origin stays where an in-range
56//! offset would put it, and no layout pass reads the pull or the intensity
57//! derived from it. It is an affine approximation of Android 12's overscroll
58//! *shader* — the same approximation Flutter's non-Impeller
59//! `StretchingOverscrollIndicator` makes — so roughly 60–70% of the real
60//! effect: a whole-viewport scale cannot reproduce the shader's per-pixel
61//! falloff, and Android's own release spring (ω = 24.657, ζ = 0.98) is not
62//! ported either — the stretch decays on the same release settle the
63//! displacement rides. Both are accepted approximations, for
64//! `docs/LIMITATIONS.md`'s register rather than a fix here.
65//!
66//! # Gesture takeover
67//!
68//! ScrollView captures the pointer on `Down` and forwards events to the child
69//! so descendant widgets stay interactive. It *observes* `Move` deltas before
70//! forwarding: once the accumulated drag passes [`TOUCH_SLOP`] it enters
71//! scrolling mode — it sends the child a synthetic `Cancel` (disarming any
72//! armed descendant tap/press), stops forwarding, and consumes the drag itself.
73//! This is how a scroll can be *taken* from a child after the slop, matching
74//! masonry.
75//!
76//! ## Nested scrolling: innermost wins
77//!
78//! Dispatch is strictly parent-first, so an outer surface always reaches that
79//! takeover site before any nested one sees the `Move` — left alone, a
80//! scrollable inside a scrollable could never win a drag. Both surfaces
81//! therefore run an **ambient claim** ([`InnerScrollState`],
82//! [`with_scroll_claim`]), the shape the navigator's edge-swipe claim
83//! (`R-B3-inner`, `nav::ambient`) established: a scrollable pushes a fresh
84//! claim cell around the `Down` it forwards, any scrollable reached underneath
85//! reports what it could do with the gesture into it, and the outer reads that
86//! answer back ([`ScrollWidget::inner_at_down`]) before deciding at the slop.
87//! When the nested surface can consume the drag's *direction*, the outer
88//! **defers**: it takes nothing over, sends no `Cancel`, and keeps forwarding
89//! the real events for the rest of the gesture, so the inner's own slop
90//! machinery takes the drag (and cancels its own child). Otherwise the takeover
91//! below runs exactly as it always has — with no nested scrollable present the
92//! claim never registers and not one byte of this changes.
93//!
94//! The claim itself requires real capacity
95//! (`max_scroll_extent > min_scroll_extent`), on top of whatever the physics'
96//! own drag gate says: a bouncing-family physics accepts a user offset
97//! unconditionally, so without this a nested surface whose content exactly
98//! fills its viewport would still claim (and hold) every drag forever, with
99//! nothing to show for it. This parts from Flutter, whose bouncing physics
100//! bounces a fits-viewport scrollable too, toward UIKit's own default
101//! (`alwaysBounceVertical == false`): a scrollable with nothing to scroll
102//! does not intercept the gesture.
103//!
104//! ## Multi-contact veto: a live counterpart to the `Down`-time claim
105//!
106//! The ambient claim above is a snapshot taken once, synchronously, right
107//! after the `Down` is forwarded — too early for a multi-contact recognizer
108//! nested inside (`pinch_detector`, [`crate::pan_zoom`]'s child-owned-press
109//! branch) to report anything about a second contact that has not arrived
110//! yet. Those recognizers instead capture the ambient **multi-contact veto**
111//! cell ([`ambient_scroll_veto`]) on the claimant's `Down` and keep writing to
112//! it for the rest of the gesture: `true` while they are tracking more than
113//! the claimant's own contact, `false` once back down to one. The takeover
114//! site checks it live, on every `Move`, alongside `inner_at_down` — while it
115//! reads `true` this surface takes nothing over and keeps forwarding the
116//! claimant's events, exactly as it does for a deferred nested scrollable, so
117//! a two-finger pinch or pan-zoom beginning over a single-finger drag is never
118//! stolen out from under it. Clearing the veto does not retroactively replay
119//! the slop check that a live pinch suppressed — the very next claimant
120//! `Move` is measured against the gesture's original `down_start` as usual,
121//! so a drag that already travelled well past [`TOUCH_SLOP`] while the second
122//! finger was down takes over immediately once the veto lifts.
123//!
124//! # Fling driver (v1)
125//!
126//! On release with sufficient velocity a fling begins, integrated
127//! frame-by-frame with [`ScrollWidget::tick`] (pure, unit-tested). [`paint`]
128//! pumps the fling from the shared shell frame clock ([`PaintCtx::frame_time`]
129//! — no wall-clock reads in widget code) so it animates for free on the
130//! continuous-loop mobile shells, and calls [`PaintCtx::request_frame`] while the
131//! fling is still in flight so the desktop shell (event-driven
132//! `ControlFlow::Wait`) keeps scheduling frames via `window.request_redraw()`;
133//! the signal stops once the fling reaches rest. The first paint after the
134//! release seeds the fling clock from `frame_time` (a zero-delta frame), and each
135//! subsequent paint advances it by the inter-frame delta.
136//!
137//! # Programmatic scrolling
138//!
139//! A [`ScrollController`] attached with [`ScrollView::controller`] is the one
140//! external write seam: its recorded commands are drained at the start of the
141//! widget's next layout or paint ([`ScrollWidget::drain_controller`]), after
142//! the content has been measured, and every pass that moves the position
143//! publishes the new [`ScrollInfo`] back to the handle. A jump
144//! ([`ScrollWidget::apply_jump`]) stops any fling, settle or ballistic
145//! simulation in flight; its `on_scroll` notification has no `EventCtx` to
146//! fire through, so — like a fling frame's — it is owed to the next event,
147//! and a paint that applied one raises
148//! [`frust_core::mark_pending_result_flush`] so that event is the very next
149//! frame's `Housekeeping` flush. `ScrollController::animate_to` eases to its
150//! target the same way, through a [`TweenSimulation`] driven by the same
151//! [`ScrollWidget::ballistic`] field and [`ScrollWidget::drive_ballistic`]
152//! pump a release fling runs through
153//! ([`ScrollWidget::apply_animate_to`]) — so a jump, a fresh animate, or any
154//! user pointer `Down`/wheel on the surface all end it the same way
155//! ([`ScrollWidget::stop_ballistic`]), and the app's `motion.reduce_motion`
156//! theme flag collapses it to an immediate jump. See
157//! [`crate::scroll_controller`] for the handle's side of the contract.
158//!
159//! **A command ends a live drag** (Flutter's `animateTo`/`jumpTo` semantics):
160//! a jump or animate applied while a finger is down replaces the drag instead
161//! of running alongside it ([`ScrollWidget::end_live_drag`]). The rest of that
162//! gesture's `Move`s fall through to the child/hover path and its `Up` to the
163//! non-scrolling branch, so the finger can no longer write the offset the
164//! command now owns, and the release starts no fling or settle of its own.
165//! Pointer capture is not touched — the drain runs in layout/paint, which
166//! carry no `EventCtx`, and the shell releases capture on the physical
167//! `Up`/`Cancel` regardless. Every post-release start (`Up`'s simulation,
168//! settle or fling) tears the previous motion down through
169//! [`ScrollWidget::stop_ballistic`] first, so a fling, a settle and a
170//! simulation are never live together and `is_animating` cannot outlive a
171//! tween.
172
173use std::cell::{Cell, RefCell};
174use std::rc::Rc;
175
176use frust_core::accesskit::Role;
177use frust_core::{
178    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FLING_STOP,
179    FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
180    PointerPhase, ScrollDelta, SemanticsCtx, TOUCH_SLOP, VelocityTracker, View, WHEEL_LINE_PX,
181    Widget, any, fling_decay, fling_displacement,
182};
183use frust_theme::Theme;
184use kurbo::{Affine, Point, Rect, Size};
185
186use crate::authoring::{ErasedArgCallback, ErasedCallback, presses};
187use crate::physics::effect::OverscrollEffect;
188use crate::physics::simulation::TweenSimulation;
189use crate::physics::{
190    MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR, ScrollMetrics, ScrollPhysics, Simulation,
191    default_overscroll_effect, default_physics,
192};
193use crate::scroll_controller::{AnimateTo, ScrollBinding, ScrollCommand, ScrollController};
194
195/// iOS-style rubber-band resistance applied to the past-edge portion of a drag
196/// under [`RubberBand`](crate::RubberBand): the visible out-of-range
197/// displacement is `raw_excess * OVERSCROLL_RESISTANCE`. No longer any
198/// platform's default — the platform-parity physics carry their own curves —
199/// but still the constant the opt-in pre-seam feel is defined by.
200///
201/// **Community-approximate**: UIScrollView's rubber-banding is a
202/// diminishing-returns curve (roughly `d·(1 − 1/(1 + d/dim·c))`), not a
203/// published constant. A flat `0.5` factor is the common community linear
204/// approximation — half the raw finger travel shows past the edge, giving the
205/// pull a heavier feel the further it is dragged in *raw* terms while staying
206/// cheap and deterministic to reason about. Tunable in one place if a
207/// diminishing curve is wanted later.
208///
209/// `pub(crate)`, not `pub`: [`crate::list_view::ListViewWidget`] shares this
210/// exact value (and the three constants below) for overscroll feel parity
211/// (lazy-list 06) — never redeclare a second copy there.
212pub(crate) const OVERSCROLL_RESISTANCE: f64 = 0.5;
213
214/// Pull-past-top distance (logical px, measured on [`ScrollWidget::edge_pull`]
215/// — the physics-mapped pull, so a resisting physics needs proportionally more
216/// raw finger travel to reach it and a clamping one exactly this much) beyond
217/// which releasing fires [`ScrollView::on_refresh_release`] (and
218/// `ListView::on_refresh_release`, which shares this constant and
219/// [`crossed_refresh_trigger`]) — the pull-to-refresh trigger.
220///
221/// **Community-approximate**: iOS's `UIRefreshControl` trigger distance is not a
222/// published constant; ~64pt is the value community reimplementations converge
223/// on for a comfortable pull.
224pub(crate) const REFRESH_TRIGGER_PX: f64 = 64.0;
225
226/// Per-millisecond retain factor for the release-settle animation that returns
227/// an overscrolled surface to its clamped edge: after `dt` ms the remaining
228/// distance to the edge is scaled by `SETTLE_DECAY.powf(dt)`.
229///
230/// **Community-approximate**: `0.988` settles ~95% of the way in ≈250 ms, an
231/// iOS-like snap-back with no published spring spec to match.
232pub(crate) const SETTLE_DECAY: f64 = 0.988;
233
234/// Distance (logical px) below which the settle animation snaps exactly to the
235/// edge and stops, so it terminates instead of asymptotically approaching.
236pub(crate) const SETTLE_STOP_PX: f64 = 0.5;
237
238/// The [`ScrollMetrics::device_pixel_ratio`] both scroll surfaces report.
239///
240/// **No context exposes a real one**: density is resolved at the shell's FFI
241/// boundary and everything above it speaks logical pixels
242/// (`docs/CODE_STANDARDS.md`'s Interaction Semantics), so neither `EventCtx`
243/// nor `PaintCtx` carries a scale factor a widget could thread in. `1.0` is
244/// therefore reported rather than guessed. Nothing in the shipped feel reads
245/// it — every constant above is dpr-independent, and it feeds only
246/// [`crate::physics::Tolerance::for_device_pixel_ratio`] for a physics that
247/// builds a [`Simulation`]. Shared with [`crate::list_view::ListViewWidget`]
248/// so the two surfaces cannot report different densities.
249pub(crate) const METRICS_FALLBACK_DPR: f64 = 1.0;
250
251/// Whether a past-top overscroll displacement crossed [`REFRESH_TRIGGER_PX`] —
252/// the pull-to-refresh release condition, shared with
253/// [`crate::list_view::ListViewWidget`] so both surfaces trigger at exactly the
254/// same pull distance (lazy-list 06) rather than two independently-typed
255/// comparisons drifting apart.
256pub(crate) fn crossed_refresh_trigger(overscroll: f64) -> bool {
257    overscroll < -REFRESH_TRIGGER_PX
258}
259
260/// The scale [`OverscrollEffect::Stretch`] adds per unit of normalized pull —
261/// the linear term's slope and, equally, the exponential term's ceiling, so a
262/// full-viewport pull stretches by at most `2 · STRETCH_INTENSITY`.
263///
264/// **Published value**, not a guess: Flutter's `_StretchController`
265/// (`widgets/overscroll_indicator.dart`) uses exactly this in its port of
266/// Android 12's overscroll effect, and the port here is term-for-term the
267/// same curve. Shared with [`crate::list_view::ListViewWidget`] like every
268/// constant above.
269pub(crate) const STRETCH_INTENSITY: f64 = 0.016;
270
271/// How fast [`stretch_intensity`]'s exponential term saturates. Flutter's
272/// `_StretchController.exponentialScalar` verbatim (`e / 0.33`): the pull is
273/// ~95% of the way to the term's ceiling at a third of a viewport.
274pub(crate) const STRETCH_EXP_SCALAR: f64 = std::f64::consts::E / 0.33;
275
276/// The added scale [`OverscrollEffect::Stretch`] paints for a pull of
277/// `edge_pull` (signed, in the [`ScrollWidget::edge_pull`] sense) against a
278/// viewport `viewport_dimension` px along the scroll axis: a linear plus a
279/// saturating-exponential term over the normalized pull
280/// `x = |edge_pull| / viewport_dimension`, clamped to `[0, 1]`.
281///
282/// Magnitude-only — which edge is held decides the anchor
283/// ([`stretch_about_edge`]), never the amount. `0.0` for an unpulled surface
284/// or a degenerate (zero-height) viewport, and never above
285/// `2 · STRETCH_INTENSITY`.
286pub(crate) fn stretch_intensity(edge_pull: f64, viewport_dimension: f64) -> f64 {
287    if viewport_dimension <= 0.0 {
288        return 0.0;
289    }
290    let x = (edge_pull.abs() / viewport_dimension).clamp(0.0, 1.0);
291    STRETCH_INTENSITY * x + STRETCH_INTENSITY * (1.0 - (-x * STRETCH_EXP_SCALAR).exp())
292}
293
294/// The paint-side affine [`OverscrollEffect::Stretch`] wraps a viewport's
295/// content in: a scroll-axis-only scale of `1 + stretch_intensity(…)` about
296/// the **held** edge — the viewport's top for a pull past the top (negative
297/// `edge_pull`), its bottom edge otherwise — so the content grows away from
298/// the finger while the edge under it stays pinned.
299///
300/// `origin`/`size` are the viewport's absolute paint geometry
301/// ([`PaintCtx::origin`]/[`PaintCtx::size`]), which is the space
302/// [`PaintScene::push_transform`] composes in. `None` when there is nothing to
303/// paint (no pull, or a degenerate viewport), so a caller pushes no transform
304/// at all rather than an identity one.
305pub(crate) fn stretch_about_edge(origin: Point, size: Size, edge_pull: f64) -> Option<Affine> {
306    let intensity = stretch_intensity(edge_pull, size.height);
307    if intensity == 0.0 {
308        return None;
309    }
310    let anchor = if edge_pull < 0.0 {
311        origin.y
312    } else {
313        origin.y + size.height
314    };
315    // The standard scale-about-a-point sandwich (`motion::animated`'s
316    // `scale_about`, `nav::transition`'s `rect_to_rect`), non-uniform so only
317    // the scroll axis stretches.
318    Some(
319        Affine::translate((0.0, anchor))
320            * Affine::scale_non_uniform(1.0, 1.0 + intensity)
321            * Affine::translate((0.0, -anchor)),
322    )
323}
324
325// --- Nested-scroll arbitration: the ambient inner-scroll claim ---------------
326
327/// How far past an edge [`inner_claim_state`] probes
328/// [`ScrollPhysics::apply_boundary_conditions`] to ask "would a pull past this
329/// edge be rejected?" (logical px).
330///
331/// The question is categorical — *does this physics hold an out-of-range
332/// position at all* — not metric, and every physics in [`crate::physics`]
333/// answers it the same way at any depth, so one pixel is enough; it is far
334/// enough past the extent that no rounding step can land back inside it.
335const CLAIM_PROBE_PX: f64 = 1.0;
336
337/// A `Down`-time snapshot of what a nested scroll surface could do with the
338/// gesture, written by that surface into its nearest enclosing one's ambient
339/// claim cell and read back there at the takeover site (the module docs'
340/// *Nested scrolling*).
341///
342/// "Down"/"up" name the **finger's** direction, never the offset's: a
343/// finger-moving-down drag reveals content *above* it (the offset falls toward
344/// the leading edge), a finger-moving-up drag reveals content below.
345#[derive(Clone, Copy, Debug, Default)]
346pub(crate) struct InnerScrollState {
347    /// Whether a nested scroll surface reported at all. `false` — the default
348    /// a never-written cell reads back — is the no-nested-scrollable case
349    /// every gesture took before arbitration existed.
350    pub(crate) registered: bool,
351    /// Whether the inner can consume a finger-moving-DOWN drag: it still holds
352    /// content above (`pixels > min_scroll_extent`), or its physics allows
353    /// past-leading-edge displacement, which lets a bouncing-family surface
354    /// answer a pull with a rubber-band even pinned at the top.
355    pub(crate) can_consume_down_drag: bool,
356    /// Whether the inner can consume a finger-moving-UP drag: content below
357    /// (`pixels < max_scroll_extent`), or past-trailing displacement allowed.
358    pub(crate) can_consume_up_drag: bool,
359}
360
361impl InnerScrollState {
362    /// Whether an outer surface must stand down at its takeover site for a
363    /// cumulative drag of `dy` finger px (positive = the finger moved down) —
364    /// innermost-wins: a registered inner that can consume *this* direction
365    /// owns the gesture; one pinned against it does not, and the outer takes
366    /// over as it always has.
367    ///
368    /// Only ever asked past [`TOUCH_SLOP`], so `dy` is never zero; a zero would
369    /// read as an up-drag rather than warranting a third branch.
370    pub(crate) fn defers(self, dy: f64) -> bool {
371        if !self.registered {
372            return false;
373        }
374        if dy > 0.0 {
375            self.can_consume_down_drag
376        } else {
377            self.can_consume_up_drag
378        }
379    }
380}
381
382thread_local! {
383    /// The stack of per-surface **inner-scroll claim** cells for the scroll
384    /// surfaces currently forwarding a pointer `Down` — the seam a nested
385    /// scrollable reports itself to its nearest enclosing one through
386    /// (mirrors `nav::ambient`'s `SWIPE_CLAIM`/`with_swipe_claim`).
387    ///
388    /// A `Down` does not decide anything: the outer surface captures, forwards
389    /// it, and only at the later `Move` slop does it choose between taking the
390    /// drag over and deferring — by which point dispatch order has already put
391    /// it upstream of the inner. So the outer pushes a fresh cell before
392    /// forwarding the `Down` ([`with_scroll_claim`]), the nested surface writes
393    /// its [`InnerScrollState`] into whatever cell is ambient
394    /// ([`ambient_scroll_claim`]) as that `Down` reaches it, and the outer
395    /// reads it back once forwarding returns.
396    ///
397    /// A **stack**, not a single slot, because the pairing must be
398    /// *nearest*-inner: a surface writes its own state into the ambient cell
399    /// **before** pushing its own cell for its own children, so its write lands
400    /// in its enclosing surface's cell while everything deeper lands in its
401    /// own. Three levels deep, the outermost therefore learns only about the
402    /// middle surface and the middle only about the innermost — nothing
403    /// propagates a grandchild's claim up past its own parent, which is what
404    /// makes each layer arbitrate against the layer it actually contains.
405    ///
406    /// Reactive-free by construction (`frust-widgets` carries no
407    /// `reactive_graph` dependency): a plain `Rc<Cell<_>>`, never a signal, and
408    /// UI-thread-affine for the same reason `nav::ambient`'s cells are.
409    static SCROLL_CLAIM: RefCell<Vec<Rc<Cell<InnerScrollState>>>> =
410        const { RefCell::new(Vec::new()) };
411}
412
413/// Pops [`SCROLL_CLAIM`] on drop, so an unwinding child dispatch cannot leave a
414/// stale scope behind for the rest of the thread's life (mirrors
415/// `nav::ambient`'s `SwipeClaimGuard`).
416struct ScrollClaimGuard;
417
418impl Drop for ScrollClaimGuard {
419    fn drop(&mut self) {
420        SCROLL_CLAIM.with(|stack| {
421            stack.borrow_mut().pop();
422        });
423    }
424}
425
426/// Run `f` (a `Down` forward into this surface's own children) with `claim`
427/// installed as the ambient inner-scroll claim cell, so the nearest scroll
428/// surface reached underneath can report itself into it via
429/// [`ambient_scroll_claim`].
430///
431/// The [`SCROLL_CLAIM`] borrow is released *before* `f` runs, so `f` may itself
432/// nest another `with_scroll_claim` call (a third level of scroll nesting).
433pub(crate) fn with_scroll_claim<R>(claim: &Rc<Cell<InnerScrollState>>, f: impl FnOnce() -> R) -> R {
434    SCROLL_CLAIM.with(|stack| stack.borrow_mut().push(Rc::clone(claim)));
435    let _guard = ScrollClaimGuard;
436    f()
437}
438
439/// The claim cell of the scroll surface currently forwarding a `Down` — the
440/// *nearest enclosing* one — or `None` if none is (a top-level surface's own
441/// `Down`, or any non-`Down` event).
442pub(crate) fn ambient_scroll_claim() -> Option<Rc<Cell<InnerScrollState>>> {
443    SCROLL_CLAIM.with(|stack| stack.borrow().last().cloned())
444}
445
446thread_local! {
447    /// The stack of per-surface **multi-contact veto** cells for the scroll
448    /// surfaces currently forwarding a pointer `Down` — [`SCROLL_CLAIM`]'s
449    /// live counterpart (the module docs' *Multi-contact veto*). A nested
450    /// recognizer that opts into the gesture's other contacts
451    /// (`pinch_detector`, [`crate::pan_zoom`]'s child-owned-press branch)
452    /// captures the ambient cell on the claimant's `Down`, while it is still
453    /// reachable, and keeps writing to it for the rest of the gesture — long
454    /// after the `Down` forward that exposed it has returned, which is
455    /// exactly what a `Down`-time snapshot like [`InnerScrollState`] cannot
456    /// do (it is read back once, synchronously, before a second contact can
457    /// possibly have arrived).
458    ///
459    /// A separate stack from [`SCROLL_CLAIM`] rather than a field folded into
460    /// it: `InnerScrollState` stays `Copy` (and byte-identical) for
461    /// [`crate::list_view::ListViewWidget`]'s existing consumption of it, and
462    /// a plain `Rc<Cell<bool>>` is exactly as `Copy`-free a value as this
463    /// seam needs.
464    static MULTI_CONTACT_VETO: RefCell<Vec<Rc<Cell<bool>>>> = const { RefCell::new(Vec::new()) };
465}
466
467/// Pops [`MULTI_CONTACT_VETO`] on drop, so an unwinding child dispatch cannot
468/// leave a stale scope behind for the rest of the thread's life (mirrors
469/// [`ScrollClaimGuard`]).
470struct MultiContactVetoGuard;
471
472impl Drop for MultiContactVetoGuard {
473    fn drop(&mut self) {
474        MULTI_CONTACT_VETO.with(|stack| {
475            stack.borrow_mut().pop();
476        });
477    }
478}
479
480/// Run `f` (a `Down` forward into this surface's own children) with `veto`
481/// installed as the ambient multi-contact veto cell, so a recognizer reached
482/// underneath that opts into the gesture's other contacts can capture it and
483/// raise it later, once a second contact actually joins the gesture.
484///
485/// The [`MULTI_CONTACT_VETO`] borrow is released *before* `f` runs, mirroring
486/// [`with_scroll_claim`].
487pub(crate) fn with_scroll_veto<R>(veto: &Rc<Cell<bool>>, f: impl FnOnce() -> R) -> R {
488    MULTI_CONTACT_VETO.with(|stack| stack.borrow_mut().push(Rc::clone(veto)));
489    let _guard = MultiContactVetoGuard;
490    f()
491}
492
493/// The multi-contact veto cell of the scroll surface currently forwarding a
494/// `Down` — the *nearest enclosing* one — or `None` outside that forward (a
495/// top-level surface's own `Down`, or any non-`Down` event).
496pub(crate) fn ambient_scroll_veto() -> Option<Rc<Cell<bool>>> {
497    MULTI_CONTACT_VETO.with(|stack| stack.borrow().last().cloned())
498}
499
500/// Every veto cell on [`MULTI_CONTACT_VETO`] right now, nearest first — the
501/// whole chain of scroll surfaces currently forwarding this `Down`, not only
502/// the nearest enclosing one [`ambient_scroll_veto`] hands back.
503///
504/// A recognizer that owns its gesture outright once begun (`draggable()`'s
505/// drag session, not a live-tracked multi-contact opt-in) must raise every
506/// cell here, not only the top one: dispatch is parent-first, so each
507/// surface's own slop/takeover check runs against its own cell, and a
508/// grandparent surface whose cell was never raised still takes the gesture
509/// over at its own slop even while the nearest surface correctly defers.
510pub(crate) fn ambient_scroll_vetoes() -> Vec<Rc<Cell<bool>>> {
511    MULTI_CONTACT_VETO.with(|stack| stack.borrow().iter().rev().cloned().collect())
512}
513
514/// What a surface sitting at `metrics` under `physics` claims it could do with
515/// a drag starting now — the value a nested scrollable writes into its host's
516/// claim cell.
517///
518/// Both directions are answered the same way: *room* in that direction, or a
519/// physics willing to hold a position past that edge. The second half is a
520/// one-pixel [`CLAIM_PROBE_PX`] probe of
521/// [`ScrollPhysics::apply_boundary_conditions`] — nothing rejected means the
522/// surface can answer the drag with a rubber-band even pinned against the edge
523/// (the `Bouncing`/[`RubberBand`](crate::RubberBand) family), while a clamping
524/// physics rejects the probe and genuinely has nothing to give. `registered`
525/// is the physics' own drag gate, the same one the takeover site consults —
526/// **and, on top of it, real capacity** (`max_scroll_extent >
527/// min_scroll_extent`): the bouncing family's `should_accept_user_offset` is
528/// hardcoded `true` regardless of content, which without this conjunct would
529/// make a content-fits inner claim (and defer to) every drag forever, even
530/// though it has nothing to show for it. This deliberately parts from
531/// Flutter, whose `BouncingScrollPhysics` would still bounce a fits-viewport
532/// surface, in favor of UIKit's own default
533/// (`UIScrollView.alwaysBounceVertical == false`): a scrollable with nothing
534/// to scroll does not intercept the gesture. A `NeverScrollable` inner, or
535/// one with real capacity but no physics willing to accept the drag, never
536/// takes a drag from its host either way.
537pub(crate) fn inner_claim_state(
538    physics: &dyn ScrollPhysics,
539    metrics: &ScrollMetrics,
540) -> InnerScrollState {
541    let leading_free = physics
542        .apply_boundary_conditions(metrics, metrics.min_scroll_extent - CLAIM_PROBE_PX)
543        == 0.0;
544    let trailing_free = physics
545        .apply_boundary_conditions(metrics, metrics.max_scroll_extent + CLAIM_PROBE_PX)
546        == 0.0;
547    let has_capacity = metrics.max_scroll_extent > metrics.min_scroll_extent;
548    InnerScrollState {
549        registered: has_capacity && physics.should_accept_user_offset(metrics),
550        can_consume_down_drag: metrics.pixels > metrics.min_scroll_extent || leading_free,
551        can_consume_up_drag: metrics.pixels < metrics.max_scroll_extent || trailing_free,
552    }
553}
554
555/// A scroll observation snapshot handed to [`ScrollView::on_scroll`].
556///
557/// `offset` is the clamped scroll position in `[0, max_offset]`; `overscroll` is
558/// the signed past-edge displacement (negative = pulled past the top, positive =
559/// pulled past the bottom), zero while the surface rests in range. During a
560/// drag past an edge, `offset` pins at the edge and `overscroll` carries the
561/// (resisted) pull.
562#[derive(Debug, Clone, Copy, PartialEq)]
563pub struct ScrollInfo {
564    /// The clamped scroll offset in `[0, max_offset]` (px scrolled down).
565    pub offset: f64,
566    /// The maximum scroll offset (`content − viewport`, never negative).
567    pub max_offset: f64,
568    /// Signed past-edge displacement: negative past the top, positive past the
569    /// bottom, `0.0` while in range.
570    pub overscroll: f64,
571}
572
573/// A view-held scroll-observation callback (erased to [`ErasedArgCallback`] on
574/// build).
575type OnScroll<State> = Rc<dyn Fn(&mut State, ScrollInfo)>;
576
577/// A view-held pull-to-refresh release callback (erased to [`ErasedCallback`] on
578/// build).
579type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
580
581/// A declarative vertical scroll surface. See the [module docs](self).
582pub struct ScrollView<State: 'static> {
583    child: AnyView<State>,
584    /// Fired whenever the offset/overscroll changes (drag, wheel, or — one event
585    /// late — fling/settle). See [`ScrollView::on_scroll`].
586    on_scroll: Option<OnScroll<State>>,
587    /// Fired on pointer `Up` when the past-top overscroll exceeded
588    /// [`REFRESH_TRIGGER_PX`]. See [`ScrollView::on_refresh_release`].
589    on_refresh_release: Option<OnRefresh<State>>,
590    /// A custom [`ScrollPhysics`] installed via [`ScrollView::physics`], or
591    /// `None` to leave whatever is already installed on the widget alone.
592    /// `Rc`, not `Box`: [`ScrollWidget::physics`] is shared-immutable widget
593    /// state, so a cheap `Rc::clone` is what a `rebuild` (which only ever sees
594    /// `&self`) can hand across without a `Box<dyn ScrollPhysics>`-isn't-`Clone`
595    /// reconstruction problem. See [`ScrollView::physics`] for the full
596    /// build/rebuild contract.
597    physics: Option<Rc<dyn ScrollPhysics>>,
598    /// How past-edge pull is visualized, carried down to
599    /// [`ScrollWidget::effect`] on every build/rebuild. See
600    /// [`ScrollView::overscroll_effect`].
601    pub(crate) effect: OverscrollEffect,
602    /// The programmatic-scroll handle to attach, if any. See
603    /// [`ScrollView::controller`].
604    controller: Option<ScrollController>,
605}
606
607impl<State: 'static> ScrollView<State> {
608    /// Wrap `child` in a vertical scroll view.
609    pub fn new<V: View<State>>(child: V) -> Self {
610        Self {
611            child: any(child),
612            on_scroll: None,
613            on_refresh_release: None,
614            physics: None,
615            effect: default_overscroll_effect(),
616            controller: None,
617        }
618    }
619
620    /// Observe scroll position changes. The callback receives a [`ScrollInfo`]
621    /// snapshot each time the offset or overscroll changes due to input (drag,
622    /// wheel), and — one event late — for fling/settle motion driven at paint
623    /// time (the paint pass carries no [`EventCtx`], so the notification is
624    /// recorded and delivered on the next event, the same controlled-component
625    /// convention the interactive widgets follow). A `Cancel` clears any pending
626    /// notification without firing it.
627    pub fn on_scroll<F: Fn(&mut State, ScrollInfo) + 'static>(mut self, callback: F) -> Self {
628        self.on_scroll = Some(Rc::new(callback));
629        self
630    }
631
632    /// The pull-to-refresh trigger: fires on pointer `Up` when the surface was
633    /// pulled past the top by more than [`REFRESH_TRIGGER_PX`] (post-resistance),
634    /// so an app gets a single "release past threshold" signal without
635    /// reimplementing overscroll thresholding. Never fires on a `Cancel`.
636    pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
637        self.on_refresh_release = Some(Rc::new(callback));
638        self
639    }
640
641    /// Install a custom [`ScrollPhysics`] strategy — the pluggable
642    /// drag-mapping/boundary-rejection/post-release-motion contract described
643    /// in [`crate::physics`], with the platform-parity physics
644    /// ([`crate::physics::parity`]'s `Bouncing`/`Clamping`/
645    /// `AlwaysScrollable`/`NeverScrollable`) and the pre-seam
646    /// [`RubberBand`](crate::RubberBand) as the built-in implementations.
647    ///
648    /// ```
649    /// use frust_widgets::{NeverScrollable, ScrollView, scroll_view, text};
650    /// let view: ScrollView<()> = scroll_view(text("hi")).physics(NeverScrollable::new());
651    /// # let _ = view;
652    /// ```
653    ///
654    /// # Build/rebuild semantics
655    ///
656    /// A view built (or rebuilt) *with* `.physics(...)` installs it on the
657    /// widget every time — like [`ScrollView::on_scroll`]'s erased callback,
658    /// a trait object isn't comparable, so this reinstalls unconditionally
659    /// rather than diffing. A view built (or rebuilt) *without*
660    /// `.physics(...)` leaves whatever the widget already has installed
661    /// untouched: a fresh `build` still starts the widget at
662    /// [`crate::physics::default_physics`] (the widget's own constructor
663    /// default), but rebuilding *from* a `.physics(...)`-carrying view *to* a
664    /// plain one does not revert it — there is no spelling for "go back to the
665    /// default" versus "no opinion this rebuild", and this picks the latter,
666    /// the same shape [`ScrollWidget::effect`] already followed before this
667    /// method existed.
668    ///
669    /// Defaults to the platform-adaptive physics
670    /// ([`crate::physics::default_physics`] — Android clamping, elsewhere
671    /// bouncing) if never called; `.physics(RubberBand::new())` is how an app
672    /// asks for the pre-seam rubber-band feel instead.
673    pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
674        self.physics = Some(Rc::new(physics));
675        self
676    }
677
678    /// Select how past-edge pull is visualized. See [`OverscrollEffect`]
679    /// ([`crate::physics::effect`]) for the full contract: move the content
680    /// with the pull, paint a Material-3-Expressive edge stretch about the
681    /// held edge, or show no visual at all.
682    ///
683    /// ```
684    /// use frust_widgets::{OverscrollEffect, ScrollView, scroll_view, text};
685    /// let view: ScrollView<()> = scroll_view(text("hi")).overscroll_effect(OverscrollEffect::Stretch);
686    /// # let _ = view;
687    /// ```
688    ///
689    /// Plain view-owned data, unlike [`ScrollView::physics`]: every
690    /// build/rebuild carries the current value down to
691    /// [`ScrollWidget::effect`] unconditionally.
692    ///
693    /// Defaults to the effect paired with the platform's default physics
694    /// ([`crate::physics::default_overscroll_effect`]): the M3E
695    /// [`OverscrollEffect::Stretch`] on Android, translate overscroll
696    /// ([`OverscrollEffect::Translate`]) everywhere else.
697    pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
698        self.effect = effect;
699        self
700    }
701
702    /// Attach a [`ScrollController`] — the handle app code drives this surface
703    /// with ([`ScrollController::jump_to`]) and reads it through
704    /// ([`ScrollController::offset`]/[`ScrollController::max_offset`]/
705    /// [`ScrollController::on_change`]) from outside any dispatch.
706    ///
707    /// ```
708    /// use frust_widgets::{ScrollController, ScrollView, scroll_view, text};
709    /// let controller = ScrollController::new();
710    /// let view: ScrollView<()> = scroll_view(text("hi")).controller(controller.clone());
711    /// # let _ = view;
712    /// ```
713    ///
714    /// # Build/rebuild semantics
715    ///
716    /// A build binds the handle, taking it over from any other surface that
717    /// held it. A rebuild with the same handle keeps the binding — and, when
718    /// another surface has since taken the handle over, leaves it there rather
719    /// than taking it back, so two live views naming one handle never trade it
720    /// every frame (it re-binds only once the handle is unattached again). A
721    /// rebuild with a different handle binds that one; a rebuild with none
722    /// detaches. Dropping the widget detaches the handle it still holds.
723    pub fn controller(mut self, controller: ScrollController) -> Self {
724        self.controller = Some(controller);
725        self
726    }
727}
728
729/// Wrap `child` in a vertical [`ScrollView`] — the free-function spelling of
730/// [`ScrollView::new`].
731pub fn scroll_view<State: 'static, V: View<State>>(child: V) -> ScrollView<State> {
732    ScrollView::new(child)
733}
734
735/// A running [`Simulation`] and the frame clock it is measured from — the
736/// state behind the generic ballistic driver both scroll surfaces run when
737/// their physics hands one back (`pub(crate)`: shared with
738/// [`crate::list_view::ListViewWidget`]'s parallel pump rather than
739/// hand-copied, like the constants above).
740pub(crate) struct BallisticState {
741    /// The physics-built curve, in **seconds** from its own start.
742    pub(crate) sim: Box<dyn Simulation>,
743    /// The frame time the first paint-time pump seeded, or `None` before it —
744    /// the same zero-delta seeding convention `last_anim` uses, so the release
745    /// itself (which carries no clock) never has to guess a start.
746    pub(crate) start: Option<FrameTime>,
747}
748
749impl BallisticState {
750    /// Seconds elapsed at frame time `now`; `0.0` until the clock is seeded.
751    pub(crate) fn elapsed_secs(&self, now: FrameTime) -> f64 {
752        match self.start {
753            Some(start) => now.saturating_sub(start).as_secs_f64(),
754            None => 0.0,
755        }
756    }
757}
758
759/// The retained widget for a [`ScrollView`].
760pub struct ScrollWidget {
761    child: ChildPod,
762    /// Current *effective* scroll offset (px scrolled down). Normally in
763    /// `[0, max_offset]`, but a drag past an edge lets it go out of range (with
764    /// [`OVERSCROLL_RESISTANCE`] applied) until the release-settle brings it back;
765    /// the fling/wheel/layout paths still hard-clamp via [`ScrollWidget::set_offset`].
766    offset: f64,
767    /// Resolved viewport size (this widget's own size).
768    viewport: Size,
769    /// The child's (content) size after an unbounded-height layout.
770    content: Size,
771    /// Whether we have taken the gesture over as a scroll drag.
772    scrolling: bool,
773    /// The physics-mapped drag position accumulated during an active scroll
774    /// drag, **before** boundary rejection: seeded from `offset` at takeover
775    /// and advanced by each move's mapped delta
776    /// ([`ScrollWidget::apply_drag_offset`]). It equals `offset` under any
777    /// physics that rejects nothing, and runs off past the edge under a
778    /// clamping one — which is exactly what lets a pinned surface still report
779    /// how hard the finger is pulling.
780    drag_position: f64,
781    /// Whether a release-settle animation is returning an overscrolled surface to
782    /// its clamped edge (driven at paint via [`ScrollWidget::settle_tick`]).
783    settling: bool,
784    /// The installed scroll physics — [`crate::physics::default_physics`]'s
785    /// platform-adaptive choice unless [`ScrollView::physics`] replaces it.
786    /// `Rc`, not `Box`: every [`ScrollPhysics`] method takes `&self`, so a
787    /// shared, immutable handle is both cheap to (re)install (a `Rc::clone`,
788    /// not a fresh reconstruction — `Box<dyn ScrollPhysics>` isn't `Clone`)
789    /// and sufficient, since nothing here ever needs `&mut` access to it.
790    /// Survives a rebuild whose view carries no `.physics(...)` call
791    /// untouched; see [`ScrollView::physics`] for the full contract and the
792    /// [module docs](self)' *Physics seam*.
793    pub(crate) physics: Rc<dyn ScrollPhysics>,
794    /// How past-edge pull is visualized —
795    /// [`crate::physics::default_overscroll_effect`]'s platform pairing unless
796    /// [`ScrollView::overscroll_effect`] names one. Read at paint alone (see
797    /// [`ScrollWidget::painted_offset`] and the module docs' *Overscroll
798    /// visuals*), never by layout or by the physics.
799    pub(crate) effect: OverscrollEffect,
800    /// The signed pull past an edge, in the same sense as
801    /// [`ScrollInfo::overscroll`]: **negative past the top**, positive past the
802    /// bottom, `0.0` in range. Its two halves are the displacement the physics
803    /// *allowed* (what [`ScrollInfo::overscroll`] reports) plus whatever
804    /// [`ScrollPhysics::apply_boundary_conditions`] *rejected* while the
805    /// position was pinned at the edge — so a clamping physics, whose position
806    /// never leaves range, still reports how hard the finger is pulling
807    /// (`physics`' design ruling). Under a physics that rejects nothing
808    /// (`Bouncing`, [`RubberBand`](crate::RubberBand)) this is exactly the
809    /// overscroll, which is why basing the [`REFRESH_TRIGGER_PX`] check on it
810    /// changes no trigger distance for either of them.
811    ///
812    /// Re-derived from [`ScrollWidget::drag_position`] on every drag move
813    /// (never summed across moves, which would strand a rejected pull the
814    /// finger has since eased back), re-seeded from the live displacement on
815    /// `Down`, re-derived again on every ballistic pump, decayed by the
816    /// release-settle, and zeroed by the wheel/`Cancel` hard clamps. A
817    /// ballistic that ends with a pull outstanding hands it to that same settle
818    /// ([`ScrollWidget::settle_ballistic_residual`]) rather than leaving it
819    /// standing — the settle is the only path back to `0.0` that animates.
820    pub(crate) edge_pull: f64,
821    /// A generic ballistic simulation handed back by
822    /// [`ScrollPhysics::create_ballistic_simulation`] on release, or `None` —
823    /// always `None` under [`RubberBand`](crate::RubberBand), which keeps the
824    /// legacy [`ScrollWidget::fling`]/[`ScrollWidget::settling`] path instead.
825    ballistic: Option<BallisticState>,
826    /// Velocity (px/s of offset) of the motion a new `Down` interrupted, fed to
827    /// [`ScrollPhysics::carried_momentum`] at the next fling start. `Down` is
828    /// its only writer and always writes it (`0.0` when the press landed on a
829    /// resting surface), so it can never carry a stale value into a later
830    /// gesture.
831    carried_velocity: f64,
832    /// A scroll notification produced by the paint-time fling/settle pump (which
833    /// carries no [`EventCtx`]); delivered to `on_scroll` on the next event and
834    /// cleared by a `Cancel` without firing.
835    pending_scroll_notify: bool,
836    /// The scroll observation callback (`None` if the view set none).
837    on_scroll: Option<ErasedArgCallback<ScrollInfo>>,
838    /// The pull-to-refresh release callback (`None` if the view set none).
839    on_refresh_release: Option<ErasedCallback>,
840    /// Whether a `Down` has armed an active gesture (distinct from `scrolling`,
841    /// which only becomes true after the drag passes the slop). Set on `Down`
842    /// and cleared on `Up`/`Cancel`; the slop/takeover math runs only while it
843    /// is true, so a hover `Move` (dispatched on every cursor motion) is never
844    /// mistaken for a drag and can never take the gesture from a child.
845    down_active: bool,
846    /// What the nearest nested scroll surface claimed it could do with this
847    /// gesture, read back out of the claim cell this widget pushed around the
848    /// `Down`'s forward — the whole input to the innermost-wins decision at the
849    /// takeover site (the module docs' *Nested scrolling*). Reset on `Down`
850    /// (before that forward) and on `Up`/`Cancel`, so it can never carry a
851    /// previous gesture's answer.
852    ///
853    /// **Accepted staleness**: this is a `Down`-time snapshot. An inner
854    /// revealed (scrolled off the edge it was pinned against) or re-pinned
855    /// *during* the gesture never re-registers, and the decision taken from it
856    /// is never revisited — the same limitation the navigator's edge-swipe
857    /// claim accepts for exactly the same reason (one arbitration point per
858    /// gesture, decided once).
859    pub(crate) inner_at_down: InnerScrollState,
860    /// Whether this gesture was handed to the nested surface at the takeover
861    /// site. A **sticky** decision for the rest of the gesture: every remaining
862    /// `Move` is forwarded untouched and the takeover math never runs again, so
863    /// a mid-gesture direction reversal cannot steal the drag back (v1 — the
864    /// finger is already inside the inner's own drag by then, and taking over
865    /// would mean cancelling a scroll in flight). Cleared with
866    /// [`ScrollWidget::inner_at_down`].
867    pub(crate) deferring: bool,
868    /// A live veto a nested multi-contact recognizer (`pinch_detector`,
869    /// [`crate::pan_zoom`]'s child-owned-press branch) can raise for the rest
870    /// of this gesture once it is tracking more than the claimant's own
871    /// contact — the live counterpart to `inner_at_down` for a case the
872    /// `Down`-time snapshot cannot see (the module docs' *Multi-contact
873    /// veto*). Checked on every `Move` at the takeover site, not read once:
874    /// the recognizer flips it live as a second contact joins and leaves.
875    /// Replaced with a fresh, unset cell on every `Down` (and cleared again
876    /// on `Up`/`Cancel`) so a stale recognizer handle from a previous gesture
877    /// can never veto this one.
878    live_veto: Rc<Cell<bool>>,
879    down_start: Point,
880    last_drag: Point,
881    tracker: VelocityTracker,
882    /// Active fling velocity, in px/s of *offset* (opposite the finger), or
883    /// `None` when not flinging.
884    fling: Option<f64>,
885    /// The most recent frame time observed during [`Widget::paint`], reused as the
886    /// event-pass timestamp for velocity tracking (the event pass carries no clock
887    /// of its own — time is provided only at paint; the fling starts from the
888    /// last paint clock, which is today's behavior too).
889    last_frame_time: FrameTime,
890    /// Last animation frame time for the paint-time fling pump; `None` seeds the
891    /// clock (zero-delta) on the first paint after a release.
892    last_anim: Option<FrameTime>,
893    /// This surface's claim on an attached [`ScrollController`], or `None`.
894    /// Dropping it (with the widget, or on a rebuild that names no handle)
895    /// detaches the handle when this surface still holds it.
896    controller: Option<ScrollBinding>,
897    /// Whether a layout has run, so the extent a controller command clamps
898    /// against (and the snapshot it publishes) is a measured one rather than
899    /// the pre-layout zero.
900    laid_out: bool,
901    /// Whether a controller jump moved the position since the last paint —
902    /// the paint that sees it requests the `Housekeeping` flush that delivers
903    /// the owed `on_scroll` (the module docs' *Programmatic scrolling*).
904    jump_notify_owed: bool,
905    /// Whether [`ScrollWidget::ballistic`] is currently running a
906    /// controller-driven [`ScrollController::animate_to`] tween rather than a
907    /// release fling/spring — this surface's own mirror of the attached
908    /// [`ScrollController::is_animating`] flag, read back at every tear-down
909    /// site ([`ScrollWidget::stop_ballistic`]) so the controller is told the
910    /// instant it actually ends, and at [`ScrollWidget::drive_ballistic`]'s
911    /// own completion so a tween that finishes on its own is told too.
912    controller_animating: bool,
913}
914
915impl ScrollWidget {
916    fn new(child: ChildPod) -> Self {
917        Self {
918            child,
919            offset: 0.0,
920            viewport: Size::ZERO,
921            content: Size::ZERO,
922            scrolling: false,
923            drag_position: 0.0,
924            settling: false,
925            physics: default_physics(),
926            effect: default_overscroll_effect(),
927            edge_pull: 0.0,
928            ballistic: None,
929            carried_velocity: 0.0,
930            pending_scroll_notify: false,
931            on_scroll: None,
932            on_refresh_release: None,
933            down_active: false,
934            inner_at_down: InnerScrollState::default(),
935            deferring: false,
936            live_veto: Rc::new(Cell::new(false)),
937            down_start: Point::ZERO,
938            last_drag: Point::ZERO,
939            tracker: VelocityTracker::new(),
940            fling: None,
941            last_frame_time: FrameTime::ZERO,
942            last_anim: None,
943            controller: None,
944            laid_out: false,
945            jump_notify_owed: false,
946            controller_animating: false,
947        }
948    }
949
950    /// The current scroll offset.
951    pub fn offset(&self) -> f64 {
952        self.offset
953    }
954
955    /// The maximum scroll offset (`content − viewport`, never negative).
956    pub fn max_offset(&self) -> f64 {
957        (self.content.height - self.viewport.height).max(0.0)
958    }
959
960    /// Whether post-release motion is in flight — the legacy fling, or a
961    /// physics-supplied [`Simulation`] the generic driver is running (never
962    /// both, and never either one under [`RubberBand`](crate::RubberBand)'s
963    /// legacy-only path).
964    pub fn is_flinging(&self) -> bool {
965        self.fling.is_some() || self.ballistic.is_some()
966    }
967
968    /// This surface's extent/position snapshot for the physics, reading
969    /// `pixels` from a caller-supplied position rather than the live offset —
970    /// the drag path asks about the *raw* (un-resisted) drag position, clamped
971    /// into range, so resistance is derived from the accumulator instead of
972    /// compounding across moves.
973    fn metrics_at(&self, pixels: f64) -> ScrollMetrics {
974        ScrollMetrics {
975            pixels,
976            min_scroll_extent: 0.0,
977            max_scroll_extent: self.max_offset(),
978            viewport_dimension: self.viewport.height,
979            device_pixel_ratio: METRICS_FALLBACK_DPR,
980        }
981    }
982
983    /// This surface's extent/position snapshot at its current effective offset.
984    fn metrics(&self) -> ScrollMetrics {
985        self.metrics_at(self.offset)
986    }
987
988    /// The signed distance the effective offset currently sits past an edge
989    /// (negative past the top, positive past the bottom, `0.0` in range) — the
990    /// value [`ScrollInfo::overscroll`] reports and the allowed half of
991    /// [`ScrollWidget::edge_pull`].
992    fn displacement(&self) -> f64 {
993        self.offset - self.offset.clamp(0.0, self.max_offset())
994    }
995
996    /// The velocity (px/s of offset) of whatever post-release motion is live
997    /// right now — the legacy fling's own, or a running simulation's at the
998    /// last painted frame — and `0.0` when the surface is at rest.
999    fn live_velocity(&self) -> f64 {
1000        if let Some(v) = self.fling {
1001            return v;
1002        }
1003        match self.ballistic.as_ref() {
1004            Some(state) => state.sim.dx(state.elapsed_secs(self.last_frame_time)),
1005            None => 0.0,
1006        }
1007    }
1008
1009    /// A new fling's starting velocity: the `release` velocity plus whatever
1010    /// [`ScrollPhysics::carried_momentum`] carries over from the motion this
1011    /// gesture's `Down` interrupted. `Down` is the only writer of
1012    /// [`ScrollWidget::carried_velocity`] (and always writes it), so the
1013    /// remembered value is never stale; [`RubberBand`](crate::RubberBand)
1014    /// carries `0.0`, leaving `release` untouched.
1015    ///
1016    /// **Momentum is carried only onto a release that plainly continues the
1017    /// interrupted motion**: same sign, and faster than
1018    /// [`MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR`] of the physics' own
1019    /// **mapped** share of the interrupted velocity —
1020    /// `physics.carried_momentum(carried)`, the exact value the release is
1021    /// about to add, not the raw interrupted speed. Flutter's two
1022    /// `ScrollDragController.end` guards compare against `carriedMomentum`
1023    /// the same way; `Bouncing`'s fitted power curve sits below the raw speed
1024    /// under ~1563 px/s and above it beyond, so gating on the mapped value
1025    /// (rather than the raw one) changes where the threshold actually sits.
1026    /// Both guards are load-bearing, not polish — the carried term is
1027    /// comparable in magnitude to an ordinary release, so adding it to a
1028    /// flick back the other way cancels the finger's own velocity out or
1029    /// reverses it outright.
1030    ///
1031    /// **Accepted gap**: Flutter drops the carried velocity a third way, when
1032    /// the finger held still before letting go (`_maybeLoseMomentum`); that
1033    /// guard is not ported. A press that stalls live motion, pauses, then
1034    /// releases slowly in the same direction still carries momentum here.
1035    ///
1036    /// The single carry site for both release branches — the generic
1037    /// ballistic driver ([`ScrollWidget::release_simulation`]) and the legacy
1038    /// fling — so neither can grow a rule of its own.
1039    fn fling_start_velocity(&self, release: f64) -> f64 {
1040        let mapped = self.physics.carried_momentum(self.carried_velocity);
1041        let continues_it = release.signum() == mapped.signum()
1042            && release.abs() > MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped.abs();
1043        if continues_it {
1044            release + mapped
1045        } else {
1046            release
1047        }
1048    }
1049
1050    /// Ask the installed physics for post-release motion at the release
1051    /// velocity its own bounds allow: under
1052    /// [`ScrollPhysics::min_fling_velocity`] the release is not a fling at all
1053    /// (the physics may still want to spring an overscrolled surface back,
1054    /// just from rest), and over [`ScrollPhysics::max_fling_velocity`] it
1055    /// clamps. **Those bounds govern the generic driver only** — the legacy
1056    /// fling path keeps its own pinned [`FLING_STOP`] threshold and no upper
1057    /// clamp, so installing a physics that returns `None` here (as
1058    /// [`RubberBand`](crate::RubberBand) does) cannot change a single shipped
1059    /// fling.
1060    fn release_simulation(&self) -> Option<Box<dyn Simulation>> {
1061        // The offset moves opposite the finger, like every other release path.
1062        let released = self.fling_start_velocity(-self.tracker.velocity());
1063        let max = self.physics.max_fling_velocity();
1064        let velocity = if released.abs() < self.physics.min_fling_velocity() {
1065            0.0
1066        } else {
1067            released.clamp(-max, max)
1068        };
1069        self.physics
1070            .create_ballistic_simulation(&self.metrics(), velocity)
1071    }
1072
1073    /// The last painted frame time as milliseconds — the event-pass timestamp
1074    /// source for velocity tracking (see [`ScrollWidget::last_frame_time`]).
1075    fn event_time_ms(&self) -> f64 {
1076        self.last_frame_time.as_secs_f64() * 1000.0
1077    }
1078
1079    fn set_offset(&mut self, value: f64) {
1080        self.offset = value.clamp(0.0, self.max_offset());
1081    }
1082
1083    /// The offset the content is actually **painted** at, which is the live
1084    /// `offset` — past-edge displacement and all — only under
1085    /// [`OverscrollEffect::Translate`]. [`OverscrollEffect::Stretch`] paints
1086    /// the pull as a scale about the held edge instead, and
1087    /// [`OverscrollEffect::None`] paints it not at all, so both leave the
1088    /// content exactly where an in-range offset would put it.
1089    ///
1090    /// Subtracted back off rather than never computed: the offset itself still
1091    /// moves precisely as the physics dictates under every effect (the module
1092    /// docs' *Overscroll visuals*), so only this one read differs.
1093    fn painted_offset(&self) -> f64 {
1094        match self.effect {
1095            OverscrollEffect::Translate => self.offset,
1096            OverscrollEffect::Stretch | OverscrollEffect::None => self.offset - self.displacement(),
1097        }
1098    }
1099
1100    fn sync_child_origin(&mut self) {
1101        self.child
1102            .set_origin(Point::new(0.0, -self.painted_offset()));
1103    }
1104
1105    /// A snapshot of the current scroll position for [`ScrollView::on_scroll`]:
1106    /// the clamped `offset`, the `max_offset`, and the signed past-edge
1107    /// `overscroll` (negative past the top). See [`ScrollInfo`].
1108    fn scroll_info(&self) -> ScrollInfo {
1109        let max = self.max_offset();
1110        ScrollInfo {
1111            offset: self.offset.clamp(0.0, max),
1112            max_offset: max,
1113            overscroll: self.displacement(),
1114        }
1115    }
1116
1117    /// Fire `on_scroll` (if set) with the current [`ScrollInfo`]. Called from the
1118    /// event pass after an input-driven offset/overscroll change.
1119    fn notify_scroll(&mut self, ctx: &mut EventCtx) {
1120        let info = self.scroll_info();
1121        if let Some(cb) = self.on_scroll.as_mut() {
1122            cb(ctx, info);
1123        }
1124    }
1125
1126    /// Deliver a fling/settle notification recorded at paint time (which had no
1127    /// [`EventCtx`]) on the next event — one event of latency, the same
1128    /// controlled-component convention the fling clock already relies on.
1129    fn deliver_pending_scroll(&mut self, ctx: &mut EventCtx) {
1130        if self.pending_scroll_notify {
1131            self.pending_scroll_notify = false;
1132            self.notify_scroll(ctx);
1133        }
1134    }
1135
1136    /// Advance the drag by `delta` px of raw finger travel in offset space
1137    /// (positive = the content scrolls down), asking the physics what that
1138    /// delta is worth from where the surface actually sits — the module docs'
1139    /// *Drag convention*.
1140    ///
1141    /// Three steps, in order: the physics maps the raw delta at metrics
1142    /// reporting the **live** position (so a depth-aware curve reads a real
1143    /// overscroll depth); the mapped delta accumulates into
1144    /// [`ScrollWidget::drag_position`]; and the boundary rule decides how much
1145    /// of that accumulated position the surface may actually hold. What it
1146    /// rejects never reaches `offset` but is still reported through
1147    /// [`ScrollWidget::edge_pull`], which is what lets a clamping surface drive
1148    /// pull-to-refresh and a stretch effect.
1149    fn apply_drag_offset(&mut self, delta: f64) {
1150        let metrics = self.metrics_at(self.offset);
1151        let mapped = self.physics.apply_physics_to_user_offset(&metrics, delta);
1152        self.drag_position += mapped;
1153        let rejected = self
1154            .physics
1155            .apply_boundary_conditions(&metrics, self.drag_position);
1156        self.offset = self.drag_position - rejected;
1157        // Read the allowed half back off the offset rather than reusing the
1158        // term above, so this is the *same* number `scroll_info` reports and
1159        // the two can never disagree by a rounding step at the bottom edge.
1160        self.edge_pull = self.displacement() + rejected;
1161    }
1162
1163    /// Advance a release-settle by `dt_ms`, easing the effective `offset` back to
1164    /// its clamped edge and returning whether it is still animating. Pure and
1165    /// deterministic (mirrors [`ScrollWidget::tick`]); the paint pump and the
1166    /// tests both drive it.
1167    pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
1168        if !self.settling {
1169            return false;
1170        }
1171        let max = self.max_offset();
1172        let target = self.offset.clamp(0.0, max);
1173        let remaining = target - self.offset;
1174        // `edge_pull`'s boundary-rejected half (always `0.0` under
1175        // `RubberBand`, where the pull *is* the displacement) has no position
1176        // to ride back, so it decays on the same curve of its own — otherwise
1177        // a clamping physics' stretch would snap off at release.
1178        let rejected = self.edge_pull - self.displacement();
1179        if remaining.abs() <= SETTLE_STOP_PX && rejected.abs() <= SETTLE_STOP_PX {
1180            self.offset = target;
1181            self.settling = false;
1182            self.edge_pull = 0.0;
1183            self.sync_child_origin();
1184            return false;
1185        }
1186        let retained = SETTLE_DECAY.powf(dt_ms);
1187        self.offset = target - remaining * retained;
1188        self.edge_pull = self.displacement() + rejected * retained;
1189        self.sync_child_origin();
1190        true
1191    }
1192
1193    /// Advance an in-flight fling by `dt_ms`, returning whether it is still
1194    /// animating. Pure and deterministic — the paint-time pump and the tests
1195    /// both drive it.
1196    pub fn tick(&mut self, dt_ms: f64) -> bool {
1197        let Some(v) = self.fling else {
1198            return false;
1199        };
1200        self.set_offset(self.offset + fling_displacement(v, dt_ms));
1201        self.sync_child_origin();
1202        let next_v = fling_decay(v, dt_ms);
1203        let at_bound = self.offset <= 0.0 || self.offset >= self.max_offset();
1204        if next_v.abs() < FLING_STOP || at_bound {
1205            self.fling = None;
1206            false
1207        } else {
1208            self.fling = Some(next_v);
1209            true
1210        }
1211    }
1212
1213    /// Whether the running simulation can no longer move anything on screen:
1214    /// the position it proposed sits outside the range, the physics rejected
1215    /// **all** of that excess (so the painted offset is already pinned at the
1216    /// edge), and the curve is still travelling further out. Nothing it reports
1217    /// after that can reach the offset, so the driver ends it here rather than
1218    /// asking the shell for the rest of the spline's worth of frames — an
1219    /// Android-style fling into an edge otherwise pumps a second of them with
1220    /// the surface stock-still.
1221    ///
1222    /// **Deliberately conservative.** A *partial* rejection (a physics holding
1223    /// some of the excess) and an inward velocity each keep the simulation
1224    /// running, because either can still bring the position back in range: an
1225    /// edge spring released outward crosses back within a few frames, and only
1226    /// the fully-rejected-and-still-outward case is one-way for every curve in
1227    /// [`crate::physics::simulation`]. The comparison against the raw excess is
1228    /// exact rather than tolerant for the same reason — a physics whose
1229    /// rejection merely rounds to the excess keeps the old pump-to-done
1230    /// behavior instead of being guessed at.
1231    fn ballistic_is_pinned_outward(&self, proposed: f64, rejected: f64, velocity: f64) -> bool {
1232        let excess = proposed - proposed.clamp(0.0, self.max_offset());
1233        excess != 0.0 && rejected == excess && velocity * excess > 0.0
1234    }
1235
1236    /// Hand a residual [`ScrollWidget::edge_pull`] left behind by a finished
1237    /// ballistic to the release-settle, so it decays on [`SETTLE_DECAY`]
1238    /// exactly as a drag release's pull does instead of standing on screen
1239    /// until the next `Down`/wheel/`Cancel`. Only the *rejected* half can be
1240    /// left over — the offset is wherever the simulation put it — and
1241    /// [`ScrollWidget::settle_tick`] already decays that half on its own curve.
1242    ///
1243    /// A simulation that ends in range leaves nothing to settle and this is a
1244    /// no-op: the guard is [`SETTLE_STOP_PX`], the same distance `settle_tick`
1245    /// itself calls settled, so a bouncing spring's sub-pixel float residue
1246    /// never arms an animation that would stop on its first tick.
1247    fn settle_ballistic_residual(&mut self) {
1248        if self.edge_pull.abs() > SETTLE_STOP_PX {
1249            self.settling = true;
1250        }
1251    }
1252
1253    /// End any live [`ScrollWidget::ballistic`] simulation — a release
1254    /// fling/spring, or a controller [`ScrollController::animate_to`] tween —
1255    /// telling the attached controller the animation stopped if it was one.
1256    /// The single tear-down path for every place user input or a jump/animate
1257    /// interrupts motion (`Down`, wheel, `Cancel`, [`ScrollWidget::apply_jump`],
1258    /// [`ScrollWidget::apply_animate_to`], and [`ScrollWidget::drive_ballistic`]'s
1259    /// own completion all call this instead of assigning `self.ballistic =
1260    /// None` directly). **Every start goes through it too**: the `Up` release
1261    /// clears the fling and settle and calls this before it starts a
1262    /// simulation, settle or fling of its own, and a controller tween is only
1263    /// ever installed by [`ScrollWidget::apply_animate_to`] after the same
1264    /// tear-down — so a fling, a settle and a simulation are never live at
1265    /// once, and [`ScrollController::is_animating`] cannot outlive the tween
1266    /// it reports *while this widget stays current on the handle*. A tween
1267    /// whose surface is dropped, taken over, or rebound to a different
1268    /// handle mid-flight never calls this — that case is reconciled on the
1269    /// handle's own side instead ([`ScrollController::bind`] and
1270    /// `impl Drop for ScrollBinding` in `scroll_controller.rs`), since the
1271    /// tween keeps running on `self.ballistic` regardless of which binding,
1272    /// if any, is still current to report it.
1273    fn stop_ballistic(&mut self) {
1274        self.ballistic = None;
1275        if std::mem::take(&mut self.controller_animating)
1276            && let Some(binding) = self.controller.as_ref()
1277        {
1278            binding.set_animating(false);
1279        }
1280    }
1281
1282    /// End a live drag because a controller command just took the position
1283    /// over — Flutter's `animateTo`/`jumpTo` semantics, where a programmatic
1284    /// command replaces the user's drag rather than running alongside it (the
1285    /// module docs' *Programmatic scrolling*). Resets exactly the per-gesture
1286    /// state the `Down` arm resets (the drag/armed flags, the Down-time claim
1287    /// snapshot, the sticky defer decision and the live multi-contact veto
1288    /// cell), so the rest of the gesture's `Move`s take the unarmed
1289    /// child/hover path and its `Up` the non-scrolling child-forward branch —
1290    /// the finger can no longer write the offset and the release
1291    /// starts no fling or settle.
1292    ///
1293    /// Only an *established* drag ends here. A gesture that is armed but has
1294    /// not crossed [`TOUCH_SLOP`] yet is left exactly as it is: a pre-slop
1295    /// finger does not write the offset, so there is nothing to race, and
1296    /// the child under it already holds an uncancelled `Down` — disarming
1297    /// now would skip the slop takeover (and the child `Cancel` it sends), so
1298    /// the finger's later travel would never scroll and its lift would
1299    /// activate the child. Flutter's `jumpTo` during a hold behaves the same
1300    /// way: the drag is still startable at slop. A no-op when nothing is
1301    /// scrolling.
1302    ///
1303    /// Pointer capture is deliberately left alone: the drain that calls this
1304    /// runs in layout/paint, which carry no [`EventCtx`], and the shell clears
1305    /// capture on the physical `Up`/`Cancel` whatever this flag says. The
1306    /// caller re-seeds [`ScrollWidget::drag_position`] from the position it
1307    /// applies.
1308    fn end_live_drag(&mut self) {
1309        if !self.scrolling {
1310            return;
1311        }
1312        self.scrolling = false;
1313        self.down_active = false;
1314        self.inner_at_down = InnerScrollState::default();
1315        self.deferring = false;
1316        self.live_veto = Rc::new(Cell::new(false));
1317    }
1318
1319    /// Advance a physics-supplied [`Simulation`] to frame time `now`: the
1320    /// position it reports, minus whatever
1321    /// [`ScrollPhysics::apply_boundary_conditions`] rejects of it. Subtracting
1322    /// the rejection is what keeps the driver honest for **any** physics — a
1323    /// clamping one can never paint an out-of-range offset even if its
1324    /// simulation overshoots, while a bouncing one (rejecting nothing) is free
1325    /// to run past the edge and back.
1326    ///
1327    /// Clears the simulation once it reports itself done — or once it is
1328    /// [pinned outward](ScrollWidget::ballistic_is_pinned_outward) and can
1329    /// never move the offset again — which is what stops the pump asking for
1330    /// frames, and hands any pull the rejection left behind to the settle
1331    /// ([`ScrollWidget::settle_ballistic_residual`]).
1332    fn drive_ballistic(&mut self, now: FrameTime) {
1333        let Some((proposed, velocity, done)) = self.ballistic.as_ref().map(|state| {
1334            let t = state.elapsed_secs(now);
1335            (state.sim.x(t), state.sim.dx(t), state.sim.is_done(t))
1336        }) else {
1337            return;
1338        };
1339        let rejected = self
1340            .physics
1341            .apply_boundary_conditions(&self.metrics(), proposed);
1342        self.offset = proposed - rejected;
1343        self.edge_pull = self.displacement() + rejected;
1344        self.sync_child_origin();
1345        if done || self.ballistic_is_pinned_outward(proposed, rejected, velocity) {
1346            self.stop_ballistic();
1347            self.settle_ballistic_residual();
1348        }
1349    }
1350
1351    /// Advance the ballistic simulation, the legacy fling, *or* the
1352    /// release-settle by the delta since the last paint, and signal
1353    /// [`PaintCtx::request_frame`] while any of them is still running so the
1354    /// shell keeps scheduling frames (the desktop `ControlFlow::Wait` loop
1355    /// would otherwise idle). A fling stops once [`ScrollWidget::tick`] brings it
1356    /// to rest (`|velocity|` below [`FLING_STOP`], or a scroll bound reached); a
1357    /// settle stops once [`ScrollWidget::settle_tick`] reaches the edge; a
1358    /// simulation stops when it reports itself done or is pinned outward.
1359    /// Because this path carries no [`EventCtx`], an offset change here records
1360    /// a pending `on_scroll` notification delivered on the next event.
1361    ///
1362    /// The three are mutually exclusive at any one instant — a release picks
1363    /// one, after tearing every one of them down through
1364    /// [`ScrollWidget::stop_ballistic`], and a controller command ends the drag
1365    /// a release would come from — though a simulation ending against an edge
1366    /// hands the pull it left behind to the settle for the frames after it
1367    /// ([`ScrollWidget::settle_ballistic_residual`]). Under
1368    /// [`RubberBand`](crate::RubberBand) the simulation arm is never taken at
1369    /// all.
1370    fn pump_fling(&mut self, ctx: &mut PaintCtx) {
1371        if self.fling.is_none() && !self.settling && self.ballistic.is_none() {
1372            self.last_anim = None;
1373            return;
1374        }
1375        let now = ctx.frame_time();
1376        let dt = match self.last_anim {
1377            Some(t) => now.saturating_sub(t).as_secs_f64() * 1000.0,
1378            None => 0.0,
1379        };
1380        self.last_anim = Some(now);
1381        // A simulation measures time from its own start, so the first pump
1382        // after the release seeds it — the same zero-delta seeding frame
1383        // `last_anim` takes, so neither clock ever jumps on frame one.
1384        if let Some(state) = self.ballistic.as_mut() {
1385            state.start.get_or_insert(now);
1386        }
1387        if dt > 0.0 {
1388            if self.ballistic.is_some() {
1389                self.drive_ballistic(now);
1390            } else if self.fling.is_some() {
1391                self.tick(dt);
1392            } else {
1393                self.settle_tick(dt);
1394            }
1395            // The offset moved from a non-input source — record a notification the
1396            // next event delivers (the paint pass has no EventCtx to fire it now).
1397            self.pending_scroll_notify = true;
1398        }
1399        // While any animation is still in flight, ask the shell for another
1400        // frame to continue it.
1401        if self.fling.is_some() || self.settling || self.ballistic.is_some() {
1402            ctx.request_frame();
1403        }
1404    }
1405
1406    /// Reconcile the attached [`ScrollController`] with the one a view names
1407    /// (see [`ScrollView::controller`]'s build/rebuild semantics), returning
1408    /// whether the binding changed. Every fresh [`ScrollController::bind`]
1409    /// resets the handle's `is_animating` to `false`; a tween that is
1410    /// genuinely still running (`self.controller_animating`) belongs to this
1411    /// widget, not the handle, so it is republished onto the new binding
1412    /// right after bind() rather than stopped — the tween keeps running
1413    /// either way, only the handle reporting it changes.
1414    fn attach_controller(&mut self, requested: Option<&ScrollController>) -> bool {
1415        match (requested, self.controller.as_ref()) {
1416            (None, None) => false,
1417            (None, Some(_)) => {
1418                self.controller = None;
1419                true
1420            }
1421            (Some(handle), Some(binding)) if binding.controller().same(handle) => {
1422                if binding.is_current() || handle.is_attached() {
1423                    false
1424                } else {
1425                    let binding = handle.bind();
1426                    binding.set_animating(self.controller_animating);
1427                    self.controller = Some(binding);
1428                    true
1429                }
1430            }
1431            (Some(handle), _) => {
1432                let binding = handle.bind();
1433                binding.set_animating(self.controller_animating);
1434                self.controller = Some(binding);
1435                true
1436            }
1437        }
1438    }
1439
1440    /// Apply every command queued on the attached [`ScrollController`], in
1441    /// recording order — once this surface has laid out (a command queued
1442    /// earlier waits for the first measured extent) and only while it still
1443    /// holds the handle.
1444    fn drain_controller(&mut self, reduce_motion: bool) {
1445        if !self.laid_out {
1446            return;
1447        }
1448        let commands = match self.controller.as_ref() {
1449            Some(binding) => binding.take_commands(),
1450            None => return,
1451        };
1452        for command in commands {
1453            match command {
1454                ScrollCommand::JumpTo(target) => self.apply_jump(target),
1455                ScrollCommand::AnimateTo(target, options) => {
1456                    self.apply_animate_to(target, options, reduce_motion);
1457                }
1458            }
1459        }
1460    }
1461
1462    /// Move straight to `target`, clamped to `[0, max_offset]`, ending any
1463    /// fling, release-settle or ballistic simulation (and the pull it rode) in
1464    /// flight. A live drag ends here ([`ScrollWidget::end_live_drag`]) — the
1465    /// rest of its gesture never moves the content. `NaN` is ignored.
1466    fn apply_jump(&mut self, target: f64) {
1467        if target.is_nan() {
1468            return;
1469        }
1470        self.end_live_drag();
1471        let before = self.scroll_info();
1472        let was_moving = self.is_flinging() || self.settling;
1473        self.fling = None;
1474        self.stop_ballistic();
1475        self.settling = false;
1476        self.edge_pull = 0.0;
1477        self.last_anim = None;
1478        self.set_offset(target);
1479        self.drag_position = self.offset;
1480        self.sync_child_origin();
1481        if was_moving || self.scroll_info() != before {
1482            self.pending_scroll_notify = true;
1483            self.jump_notify_owed = true;
1484        }
1485    }
1486
1487    /// Start (or replace) a controller-driven tween to `target`, clamped to
1488    /// `[0, max_offset]`, ending any fling, release-settle or ballistic
1489    /// simulation in flight first — the same replace semantics
1490    /// [`ScrollWidget::apply_jump`] gives a jump. Drives the tween through a
1491    /// [`TweenSimulation`] in [`ScrollWidget::ballistic`], the exact same
1492    /// field and pump ([`ScrollWidget::drive_ballistic`]) a release fling
1493    /// runs through, so paint cadence, `request_frame` and the installed
1494    /// physics' boundary handling are shared rather than duplicated.
1495    /// `reduce_motion` (or a non-positive/non-finite `duration_ms`) collapses
1496    /// this to an immediate [`ScrollWidget::apply_jump`]. A live drag ends
1497    /// here ([`ScrollWidget::end_live_drag`]), so the tween is the only writer
1498    /// of the offset until it finishes or user input interrupts it. `NaN` is
1499    /// ignored.
1500    fn apply_animate_to(&mut self, target: f64, options: AnimateTo, reduce_motion: bool) {
1501        if target.is_nan() {
1502            return;
1503        }
1504        self.end_live_drag();
1505        self.fling = None;
1506        self.stop_ballistic();
1507        self.settling = false;
1508        self.edge_pull = 0.0;
1509        self.last_anim = None;
1510        if reduce_motion || !options.duration_ms.is_finite() || options.duration_ms <= 0.0 {
1511            self.apply_jump(target);
1512            return;
1513        }
1514        self.drag_position = self.offset;
1515        let clamped = target.clamp(0.0, self.max_offset());
1516        let sim = TweenSimulation::new(
1517            self.offset,
1518            clamped,
1519            options.duration_ms / 1000.0,
1520            options.curve,
1521        );
1522        self.ballistic = Some(BallisticState {
1523            sim: Box::new(sim),
1524            start: None,
1525        });
1526        self.controller_animating = true;
1527        if let Some(binding) = self.controller.as_ref() {
1528            binding.set_animating(true);
1529        }
1530    }
1531
1532    /// Publish the current position to the attached [`ScrollController`] (a
1533    /// no-op before the first layout, or once another surface took it over).
1534    fn publish_to_controller(&self) {
1535        if !self.laid_out {
1536            return;
1537        }
1538        if let Some(binding) = self.controller.as_ref() {
1539            binding.publish(self.scroll_info(), self.viewport.height);
1540        }
1541    }
1542
1543    fn send_child_cancel(&mut self, ctx: &mut EventCtx, pos: Point) {
1544        let cancel = InputEvent::Pointer(PointerEvent {
1545            phase: PointerPhase::Cancel,
1546            position: pos,
1547            button: PointerButton::Primary,
1548        });
1549        self.child.event_child(ctx, &cancel);
1550    }
1551
1552    /// The event entry, parameterised on an explicit timestamp so velocity math
1553    /// is deterministic in tests; [`Widget::event`] supplies the real clock.
1554    /// Routes the event, then publishes whatever position it left behind to an
1555    /// attached [`ScrollController`].
1556    fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
1557        let result = self.route_event_at(ctx, event, t_ms);
1558        self.publish_to_controller();
1559        result
1560    }
1561
1562    /// The event body behind [`ScrollWidget::event_at`].
1563    fn route_event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
1564        // A fling/settle notification recorded at paint time is delivered on the
1565        // next event — except a Cancel, which clears it without firing (below).
1566        if !matches!(
1567            event,
1568            InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
1569        ) {
1570            self.deliver_pending_scroll(ctx);
1571        }
1572        match event {
1573            // A broadcast is not user input: it bypasses the whole gesture
1574            // machinery, reaches the child whether or not it is focused or
1575            // captured, and is never consumed (`crate::authoring::route_event`'s
1576            // contract, applied to this widget's hand-rolled routing). A floated
1577            // surface's own input travels the same way — it has to reach an
1578            // overlay owner anywhere below this viewport, and a scroll gesture
1579            // must never swallow it.
1580            InputEvent::Housekeeping | InputEvent::Overlay(_) => {
1581                self.child.event_child(ctx, event);
1582                EventResult::Ignored
1583            }
1584            // Focus-routed events (Key/Ime, and the clipboard verbs an
1585            // `EditCommand` carries) bypass the scroll gesture machinery and go
1586            // straight to the child if it holds the recorded focus path.
1587            InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
1588                if self.child.is_focused() {
1589                    self.child.event_child(ctx, event)
1590                } else {
1591                    EventResult::Ignored
1592                }
1593            }
1594            InputEvent::Scroll { delta, .. } => {
1595                let dy = match delta {
1596                    ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
1597                    ScrollDelta::Pixels(_, y) => *y,
1598                };
1599                // Wheel scrolling stays hard-clamped — no overscroll rubber-band on
1600                // desktop wheel input, and no physics consulted: the clamp is a
1601                // property of the input device, not of the installed feel, so
1602                // this arm is identical under every physics.
1603                self.fling = None;
1604                self.settling = false;
1605                self.stop_ballistic();
1606                self.edge_pull = 0.0;
1607                self.set_offset(self.offset + dy);
1608                self.sync_child_origin();
1609                self.notify_scroll(ctx);
1610                ctx.request_redraw();
1611                EventResult::Handled
1612            }
1613            InputEvent::Pointer(p) => match p.phase {
1614                PointerPhase::Down => {
1615                    // Only a primary press arms a drag. A secondary press is a
1616                    // context gesture: it still reaches the child (a
1617                    // context-menu consumer inside the viewport must see it),
1618                    // but opens no capture and can never start a scroll. The
1619                    // wheel arm above is unaffected — it carries no button.
1620                    if !presses(p) {
1621                        return self.child.event_child(ctx, event);
1622                    }
1623                    self.scrolling = false;
1624                    self.down_active = true;
1625                    self.inner_at_down = InnerScrollState::default();
1626                    self.deferring = false;
1627                    // A fresh, unset cell for this gesture — never the
1628                    // previous one, which a since-torn-down recognizer may
1629                    // still hold a clone of.
1630                    self.live_veto = Rc::new(Cell::new(false));
1631                    // Remember what this press interrupted before killing it —
1632                    // the next fling asks the physics how much of it to carry
1633                    // forward (`0.0` under `RubberBand`, i.e. start cold).
1634                    self.carried_velocity = self.live_velocity();
1635                    self.fling = None;
1636                    self.settling = false;
1637                    self.stop_ballistic();
1638                    // A `Down` deliberately leaves a mid-bounce displacement on
1639                    // screen (the regrab continues from it), so the pull is
1640                    // re-seeded from that displacement rather than zeroed —
1641                    // what "reset" means here is "carries nothing stale from
1642                    // the previous gesture".
1643                    self.edge_pull = self.displacement();
1644                    self.last_anim = None;
1645                    self.down_start = p.position;
1646                    self.last_drag = p.position;
1647                    self.tracker.clear();
1648                    self.tracker.record(t_ms, p.position.y);
1649                    ctx.capture_pointer();
1650                    // Innermost-wins arbitration, both halves in dispatch
1651                    // order. First report THIS surface into whatever cell is
1652                    // ambient — the nearest *enclosing* scrollable's, if any —
1653                    // while that is still the cell on top; only then push this
1654                    // surface's own cell for the forward below, so a nested
1655                    // scrollable's write lands here and never in the
1656                    // grandparent's.
1657                    if let Some(host) = ambient_scroll_claim() {
1658                        host.set(inner_claim_state(self.physics.as_ref(), &self.metrics()));
1659                    }
1660                    let claim = Rc::new(Cell::new(InnerScrollState::default()));
1661                    let veto = Rc::clone(&self.live_veto);
1662                    let child = &mut self.child;
1663                    with_scroll_claim(&claim, || {
1664                        with_scroll_veto(&veto, || child.event_child(ctx, event))
1665                    });
1666                    self.inner_at_down = claim.get();
1667                    EventResult::Handled
1668                }
1669                PointerPhase::Move => {
1670                    // Without an armed `Down`, this is a hover move (the desktop
1671                    // shell dispatches `Move` on every cursor motion): never run
1672                    // the slop/takeover math against a stale `down_start`, just
1673                    // forward it to the child.
1674                    if !self.down_active {
1675                        return self.child.event_child(ctx, event);
1676                    }
1677                    self.tracker.record(t_ms, p.position.y);
1678                    if self.scrolling {
1679                        let dy = p.position.y - self.last_drag.y;
1680                        self.last_drag = p.position;
1681                        // Hand the physics this move's raw delta (the offset
1682                        // moves opposite the finger) and let it decide what
1683                        // reaches the position past an edge.
1684                        self.apply_drag_offset(-dy);
1685                        self.sync_child_origin();
1686                        self.notify_scroll(ctx);
1687                        ctx.request_redraw();
1688                    } else if !self.deferring
1689                        && !self.live_veto.get()
1690                        && (p.position.y - self.down_start.y).abs() > TOUCH_SLOP
1691                        && self.physics.should_accept_user_offset(&self.metrics())
1692                    {
1693                        if self.inner_at_down.defers(p.position.y - self.down_start.y) {
1694                            // Innermost wins: a nested scrollable registered on
1695                            // this gesture's `Down` and can consume this
1696                            // direction, so take nothing over — no `Cancel`, no
1697                            // capture handover — and keep forwarding. The
1698                            // decision is sticky (`deferring` gates this whole
1699                            // branch), and the inner's own slop machinery
1700                            // cancels its own child from here.
1701                            self.deferring = true;
1702                            self.child.event_child(ctx, event);
1703                        } else {
1704                            // Take the gesture over: cancel the child, stop
1705                            // forwarding — unless the physics refuses drags
1706                            // outright, in which case the move keeps flowing to
1707                            // the child. `Bouncing`/`RubberBand` accept
1708                            // unconditionally (even content that fits
1709                            // rubber-bands), so that gate is inert on the
1710                            // bouncing-family default.
1711                            self.scrolling = true;
1712                            self.settling = false;
1713                            self.last_drag = p.position;
1714                            // Seed the drag accumulator from the live position
1715                            // — including a mid-bounce displacement, so a
1716                            // regrab continues from what is on screen.
1717                            self.drag_position = self.offset;
1718                            self.send_child_cancel(ctx, p.position);
1719                            // A takeover, not the gesture's end: releasing the
1720                            // child through the context also ends a contact
1721                            // opt-in held below it, so the root stops routing
1722                            // other fingers to a captor that was just cancelled.
1723                            ctx.release_captured_child(&mut self.child);
1724                            ctx.request_redraw();
1725                        }
1726                    } else {
1727                        self.child.event_child(ctx, event);
1728                    }
1729                    EventResult::Handled
1730                }
1731                PointerPhase::Up => {
1732                    if self.scrolling {
1733                        // Pull-to-refresh: released past the top trigger fires the
1734                        // app hook (an Up, so mutating state is allowed). Measured
1735                        // on `edge_pull`, so a clamping physics — which never lets
1736                        // the position leave range — can still trigger it; under
1737                        // `RubberBand` this is bit-for-bit the overscroll the
1738                        // check has always read.
1739                        if crossed_refresh_trigger(self.edge_pull)
1740                            && let Some(cb) = self.on_refresh_release.as_mut()
1741                        {
1742                            cb(ctx);
1743                        }
1744                        // Whatever the release starts, it starts alone: tear
1745                        // every motion down first, through the single
1746                        // tear-down path, so no fling, settle or simulation
1747                        // (a controller tween included) survives alongside the
1748                        // one picked below and `is_animating` is never left
1749                        // set by a tween this release replaced.
1750                        self.fling = None;
1751                        self.settling = false;
1752                        self.stop_ballistic();
1753                        self.last_anim = None;
1754                        // Ask the physics for post-release motion first: one that
1755                        // hands back a simulation owns the release outright, and
1756                        // one that does not (`RubberBand`) falls through to the
1757                        // legacy settle/fling below untouched.
1758                        if let Some(sim) = self.release_simulation() {
1759                            self.ballistic = Some(BallisticState { sim, start: None });
1760                        } else if self.edge_pull != 0.0 {
1761                            // Released while overscrolled: settle back to the edge,
1762                            // never fling out of range.
1763                            self.settling = true;
1764                        } else {
1765                            // The legacy path keeps its own FLING_STOP threshold
1766                            // (the trait's min/max fling bounds govern the generic
1767                            // driver only) and takes carried momentum, which is
1768                            // `0.0` under `RubberBand`.
1769                            let finger_v = self.tracker.velocity();
1770                            if finger_v.abs() > FLING_STOP {
1771                                // Offset moves opposite the finger.
1772                                self.fling = Some(self.fling_start_velocity(-finger_v));
1773                            }
1774                        }
1775                        self.notify_scroll(ctx);
1776                    } else {
1777                        self.child.event_child(ctx, event);
1778                    }
1779                    self.child.set_active(false);
1780                    self.scrolling = false;
1781                    self.down_active = false;
1782                    self.inner_at_down = InnerScrollState::default();
1783                    self.deferring = false;
1784                    self.live_veto = Rc::new(Cell::new(false));
1785                    ctx.request_redraw();
1786                    EventResult::Handled
1787                }
1788                PointerPhase::Cancel => {
1789                    self.child.event_child(ctx, event);
1790                    self.child.set_active(false);
1791                    self.scrolling = false;
1792                    self.down_active = false;
1793                    self.inner_at_down = InnerScrollState::default();
1794                    self.deferring = false;
1795                    self.live_veto = Rc::new(Cell::new(false));
1796                    // Cancel never mutates state and never fires a callback: drop
1797                    // any pending notification and snap an overscrolled surface back
1798                    // into range (no settle animation, no on_scroll/on_refresh) —
1799                    // including any live simulation and the pull it was riding.
1800                    self.settling = false;
1801                    self.stop_ballistic();
1802                    self.edge_pull = 0.0;
1803                    self.pending_scroll_notify = false;
1804                    self.set_offset(self.offset);
1805                    self.sync_child_origin();
1806                    ctx.request_redraw();
1807                    EventResult::Handled
1808                }
1809            },
1810            // Any other variant — a hit-tested `Scale` today — belongs to the
1811            // child, and so does its result: a child that handled it consumed
1812            // it, so an enclosing recognizer (a `pinch_detector` offered the
1813            // same gesture next) must not act on it a second time. Only a
1814            // broadcast is never consumed, whatever the child returned; the arm
1815            // above catches both today, and the check keeps that true for a
1816            // broadcast variant added later.
1817            _ => {
1818                let result = self.child.event_child(ctx, event);
1819                if event.is_broadcast() {
1820                    EventResult::Ignored
1821                } else {
1822                    result
1823                }
1824            }
1825        }
1826    }
1827}
1828
1829impl<State: 'static> View<State> for ScrollView<State> {
1830    type Element = ScrollWidget;
1831
1832    fn build(&self, ctx: &mut BuildCtx<'_>) -> ScrollWidget {
1833        let mut widget = ScrollWidget::new(crate::authoring::build_child(&self.child, ctx));
1834        widget.on_scroll = self
1835            .on_scroll
1836            .as_ref()
1837            .map(crate::authoring::erase_callback_arg);
1838        widget.on_refresh_release = self
1839            .on_refresh_release
1840            .as_ref()
1841            .map(crate::authoring::erase_callback);
1842        if let Some(physics) = self.physics.clone() {
1843            widget.physics = physics;
1844        }
1845        widget.effect = self.effect;
1846        widget.attach_controller(self.controller.as_ref());
1847        widget
1848    }
1849
1850    fn rebuild(
1851        &self,
1852        prev: &Self,
1853        element: &mut ScrollWidget,
1854        ctx: &mut BuildCtx<'_>,
1855    ) -> ChangeFlags {
1856        // Closures are not comparable — always reinstall the erased adapters.
1857        element.on_scroll = self
1858            .on_scroll
1859            .as_ref()
1860            .map(crate::authoring::erase_callback_arg);
1861        element.on_refresh_release = self
1862            .on_refresh_release
1863            .as_ref()
1864            .map(crate::authoring::erase_callback);
1865        // A `.physics(...)`-carrying view reinstalls it every rebuild, like the
1866        // erased callbacks above; a view with no opinion (`None`) leaves the
1867        // widget's currently-installed physics alone — see
1868        // `ScrollView::physics`'s doc for the full contract.
1869        if let Some(physics) = self.physics.clone() {
1870            element.physics = physics;
1871        }
1872        // The visual effect is plain data the view owns and is always carried
1873        // down unconditionally.
1874        element.effect = self.effect;
1875        let mut flags =
1876            crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
1877        // A new binding publishes, and a queued command applies, at the next
1878        // layout or paint — make sure a frame-gated shell runs one.
1879        let rebound = element.attach_controller(self.controller.as_ref());
1880        if rebound
1881            || element
1882                .controller
1883                .as_ref()
1884                .is_some_and(ScrollBinding::has_pending)
1885        {
1886            flags |= ChangeFlags::PAINT;
1887        }
1888        flags
1889    }
1890
1891    fn teardown(&self, element: &mut ScrollWidget, ctx: &mut BuildCtx<'_>) {
1892        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
1893    }
1894}
1895
1896impl Widget for ScrollWidget {
1897    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1898        let vw = if bc.max().width.is_finite() {
1899            bc.max().width
1900        } else {
1901            0.0
1902        };
1903        // The child is laid out at the viewport width with unbounded height.
1904        let child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
1905        self.content = self.child.layout_child(ctx, &child_bc);
1906        let vh = if bc.max().height.is_finite() {
1907            bc.max().height
1908        } else {
1909            self.content.height
1910        };
1911        self.viewport = Size::new(vw, vh);
1912        // Invariant: an in-flight gesture/settle/simulation owns an
1913        // out-of-range offset; layout must not snap it. While `scrolling` (an
1914        // active past-slop drag), `settling` (the post-release decay back to
1915        // the edge), or a physics-driven ballistic simulation (which for a
1916        // bouncing physics legitimately runs past an edge and back) is live,
1917        // `self.offset` legitimately carries the resisted past-edge overscroll —
1918        // re-clamping it here would snap the child to rest mid-gesture, and the
1919        // next pointer `Move` (or settle tick) would re-apply the displacement,
1920        // producing a visible alternation between rest and dragged positions at
1921        // display rate on a page where something else requests layout every
1922        // frame (device-gate G6, the "phantom clone" bug). Only the clamp is
1923        // conditional: origin sync and the viewport/content bookkeeping above
1924        // still run unconditionally either way. A resize mid-drag (content or
1925        // viewport shrinking under an out-of-range offset) still resolves
1926        // correctly without an immediate clamp here: `Up`'s handler always
1927        // recomputes `scroll_info()`/settles/flings off the freshly-updated
1928        // `max_offset`, and any non-drag layout after the gesture ends clamps
1929        // normally on its own next pass.
1930        if !self.scrolling && !self.settling && self.ballistic.is_none() {
1931            self.set_offset(self.offset); // re-clamp against new content/viewport
1932        }
1933        // A controller command clamps against the extent just measured, so a
1934        // jump recorded alongside a content change lands in the new range.
1935        self.laid_out = true;
1936        let reduce_motion = Theme::from_layout_ctx(ctx)
1937            .map(|t| t.motion.reduce_motion)
1938            .unwrap_or(false);
1939        self.drain_controller(reduce_motion);
1940        self.sync_child_origin();
1941        self.publish_to_controller();
1942        bc.constrain(self.viewport)
1943    }
1944
1945    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1946        // Record the shared frame clock so the between-frames event pass (which
1947        // carries no clock) has a timestamp for velocity tracking.
1948        self.last_frame_time = ctx.frame_time();
1949        // A command recorded since layout (or with layout skipped this frame)
1950        // applies before the pump, so a jump ends a fling before it advances.
1951        let reduce_motion = Theme::from_paint_ctx(ctx)
1952            .map(|t| t.motion.reduce_motion)
1953            .unwrap_or(false);
1954        self.drain_controller(reduce_motion);
1955        self.pump_fling(ctx);
1956        self.publish_to_controller();
1957        if std::mem::take(&mut self.jump_notify_owed) && self.on_scroll.is_some() {
1958            // Paint has no `&mut State`: owe the jump's `on_scroll` to the next
1959            // frame's Housekeeping flush (the `pan_zoom` precedent).
1960            frust_core::mark_pending_result_flush();
1961            ctx.request_frame();
1962        }
1963        if self
1964            .controller
1965            .as_ref()
1966            .is_some_and(ScrollBinding::has_pending)
1967        {
1968            // Recorded during this paint (a listener reacting to the publish
1969            // above): the next frame applies it.
1970            ctx.request_frame();
1971        }
1972        scene.push_clip(ctx.origin(), ctx.size());
1973        self.sync_child_origin();
1974        // Publish this viewport as the paint-time visible rect (absolute coords),
1975        // so a `Flex` in the scrolled content can cull children fully below/above
1976        // the fold — suppressing offscreen animators' frame requests. Intersects
1977        // (never widens) any rect an outer scroll surface already threaded down.
1978        ctx.constrain_visible_rect(Rect::from_origin_size(ctx.origin(), ctx.size()));
1979        // The stretch is PAINT-ONLY, and load-bearingly so: no layout pass
1980        // reads `edge_pull` or the intensity derived from it, and none may
1981        // start to. A layout-affecting animation must request a relayout on
1982        // every frame of its motion or the mobile shell's intra-frame layout
1983        // skip leaves it frozen (`docs/WIDGETS_CODE_STANDARDS.md`'s
1984        // animation-pacing rule) — keeping the stretch out of every layout
1985        // read is what makes that irrelevant here, and is why there is no
1986        // `request_layout` in this path either: the settle/ballistic pump
1987        // above already asks for every frame the decaying stretch needs.
1988        // Pushed INSIDE the viewport clip so stretched content can never
1989        // paint past the viewport's edges.
1990        let stretch = match self.effect {
1991            OverscrollEffect::Stretch => {
1992                stretch_about_edge(ctx.origin(), ctx.size(), self.edge_pull)
1993            }
1994            OverscrollEffect::Translate | OverscrollEffect::None => None,
1995        };
1996        if let Some(transform) = stretch {
1997            scene.push_transform(transform);
1998        }
1999        self.child.paint_child(ctx, scene);
2000        if stretch.is_some() {
2001            scene.pop_transform();
2002        }
2003        scene.pop_clip();
2004    }
2005
2006    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2007        let t = self.event_time_ms();
2008        self.event_at(ctx, event, t)
2009    }
2010
2011    fn semantics(&self, ctx: &mut SemanticsCtx) {
2012        // A ScrollView node exposing the vertical scroll offset and its range,
2013        // wrapping its scrolled content: `semantics_child` translates by the
2014        // child's origin (which carries `-offset`), so descendant bounds reflect
2015        // the scrolled position.
2016        let max_offset = (self.content.height - self.viewport.height).max(0.0);
2017        ctx.push_container(
2018            Role::ScrollView,
2019            |node| {
2020                node.set_scroll_y(self.offset);
2021                node.set_scroll_y_min(0.0);
2022                node.set_scroll_y_max(max_offset);
2023            },
2024            |ctx| self.child.semantics_child(ctx),
2025        );
2026    }
2027
2028    crate::authoring::visit_children!(child);
2029}
2030
2031#[cfg(test)]
2032mod tests {
2033    use super::*;
2034    use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
2035    use crate::physics::rubber_band::RubberBand;
2036    use crate::test_support::leaf;
2037    use std::any::Any;
2038
2039    /// Build and lay out a scroll widget over `()` state with a `content_h`-tall
2040    /// leaf child inside a `vw`×`vh` viewport.
2041    fn laid_out(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
2042        let view: ScrollView<()> = scroll_view(leaf(vw, content_h));
2043        let mut counter = 0u64;
2044        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
2045        let mut lctx = LayoutCtx::new();
2046        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(vw, vh)));
2047        w
2048    }
2049
2050    /// [`laid_out`] with the pre-seam [`RubberBand`] feel pinned explicitly —
2051    /// the fixture every *rubber-band* pin below builds from now that the
2052    /// widget's own default is the platform-adaptive physics (bouncing here,
2053    /// clamping on Android), each with curves of its own. A test whose
2054    /// assertions are a `0.5`-resisted displacement, a `SETTLE_DECAY` trace or
2055    /// a legacy-fling trace is pinning *this* physics, not the default.
2056    fn laid_out_rubber_band(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
2057        let mut w = laid_out(vw, vh, content_h);
2058        w.physics = Rc::new(RubberBand::new());
2059        w
2060    }
2061
2062    fn scroll(y: f64, lines: bool, amount: f64) -> InputEvent {
2063        let delta = if lines {
2064            ScrollDelta::Lines(0.0, amount)
2065        } else {
2066            ScrollDelta::Pixels(0.0, amount)
2067        };
2068        InputEvent::Scroll {
2069            position: Point::new(10.0, y),
2070            delta,
2071        }
2072    }
2073
2074    fn ev(phase: PointerPhase, y: f64) -> InputEvent {
2075        InputEvent::Pointer(PointerEvent {
2076            phase,
2077            position: Point::new(10.0, y),
2078            button: PointerButton::Primary,
2079        })
2080    }
2081
2082    fn dispatch(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) {
2083        let mut unit = ();
2084        let state_any: &mut dyn Any = &mut unit;
2085        let mut ctx = EventCtx::new(state_any, Point::ZERO, w.viewport);
2086        w.event_at(&mut ctx, event, t_ms);
2087    }
2088
2089    #[test]
2090    fn max_offset_is_content_minus_viewport() {
2091        let w = laid_out(200.0, 100.0, 1000.0);
2092        assert_eq!(w.max_offset(), 900.0);
2093        assert_eq!(w.offset(), 0.0);
2094    }
2095
2096    #[test]
2097    fn wheel_scrolls_and_clamps_with_no_overscroll() {
2098        let mut w = laid_out(200.0, 100.0, 1000.0);
2099        // 3 lines * 40 px = 120.
2100        dispatch(&mut w, &scroll(50.0, true, 3.0), 0.0);
2101        assert_eq!(w.offset(), 120.0);
2102        // A huge line delta clamps to max_offset (no overscroll past the end).
2103        dispatch(&mut w, &scroll(50.0, true, 100.0), 0.0);
2104        assert_eq!(w.offset(), 900.0);
2105        // Scrolling back past the top clamps to 0.
2106        dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
2107        assert_eq!(w.offset(), 0.0);
2108    }
2109
2110    #[test]
2111    fn drag_past_slop_scrolls_the_offset() {
2112        let mut w = laid_out(200.0, 100.0, 1000.0);
2113        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2114        // First move crosses the slop → takeover (no scroll on this move).
2115        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
2116        assert_eq!(w.offset(), 0.0);
2117        assert!(w.scrolling);
2118        // Next move drags the finger up 30 px → content scrolls down 30 px.
2119        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
2120        assert_eq!(w.offset(), 30.0);
2121    }
2122
2123    #[test]
2124    fn takeover_sends_the_child_a_cancel() {
2125        // A child that records the pointer phases it receives.
2126        #[derive(Default)]
2127        struct Rec {
2128            downs: u32,
2129            cancels: u32,
2130        }
2131        struct Probe;
2132        struct ProbeW;
2133        impl View<Rec> for Probe {
2134            type Element = ProbeW;
2135            fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
2136                ProbeW
2137            }
2138            fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2139                ChangeFlags::NONE
2140            }
2141        }
2142        impl Widget for ProbeW {
2143            fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2144                bc.constrain(Size::new(200.0, 1000.0))
2145            }
2146            fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2147            fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
2148                if let InputEvent::Pointer(p) = e {
2149                    let rec = ctx.state_mut::<Rec>();
2150                    match p.phase {
2151                        PointerPhase::Down => rec.downs += 1,
2152                        PointerPhase::Cancel => rec.cancels += 1,
2153                        _ => {}
2154                    }
2155                }
2156                EventResult::Handled
2157            }
2158        }
2159
2160        let view: ScrollView<Rec> = scroll_view(Probe);
2161        let mut counter = 0u64;
2162        let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
2163        w.viewport = Size::new(200.0, 100.0);
2164
2165        let mut state = Rec::default();
2166        let run = |w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64| {
2167            let sa: &mut dyn Any = state;
2168            let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
2169            w.event_at(&mut ctx, e, t);
2170        };
2171        run(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2172        // Drag 30 px past the slop → takeover fires a Cancel at the child.
2173        run(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
2174        assert_eq!(state.downs, 1);
2175        assert_eq!(state.cancels, 1);
2176        // Subsequent scrolling moves are not forwarded to the child.
2177        run(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0);
2178        assert_eq!(state.cancels, 1);
2179    }
2180
2181    /// A recording child probe, shared by the takeover/hover tests.
2182    #[derive(Default)]
2183    struct Rec {
2184        downs: u32,
2185        cancels: u32,
2186        moves: u32,
2187    }
2188    struct Probe;
2189    struct ProbeW;
2190    impl View<Rec> for Probe {
2191        type Element = ProbeW;
2192        fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
2193            ProbeW
2194        }
2195        fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2196            ChangeFlags::NONE
2197        }
2198    }
2199    impl Widget for ProbeW {
2200        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2201            bc.constrain(Size::new(200.0, 1000.0))
2202        }
2203        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2204        fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
2205            if let InputEvent::Pointer(p) = e {
2206                let rec = ctx.state_mut::<Rec>();
2207                match p.phase {
2208                    PointerPhase::Down => rec.downs += 1,
2209                    PointerPhase::Cancel => rec.cancels += 1,
2210                    PointerPhase::Move => rec.moves += 1,
2211                    _ => {}
2212                }
2213            }
2214            EventResult::Ignored
2215        }
2216    }
2217
2218    fn probe_scroll() -> ScrollWidget {
2219        let view: ScrollView<Rec> = scroll_view(Probe);
2220        let mut counter = 0u64;
2221        let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
2222        w.viewport = Size::new(200.0, 100.0);
2223        w
2224    }
2225
2226    fn run_rec(w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64) -> bool {
2227        let sa: &mut dyn Any = state;
2228        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
2229        w.event_at(&mut ctx, e, t);
2230        ctx.needs_redraw()
2231    }
2232
2233    #[test]
2234    fn an_overlay_broadcast_reaches_the_child_and_is_never_consumed() {
2235        use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
2236
2237        /// What a floated surface's owner below this viewport would see.
2238        #[derive(Default)]
2239        struct Seen {
2240            overlays: u32,
2241            pointers: u32,
2242        }
2243        struct Owner;
2244        struct OwnerW;
2245        impl View<Seen> for Owner {
2246            type Element = OwnerW;
2247            fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
2248                OwnerW
2249            }
2250            fn rebuild(&self, _p: &Self, _e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2251                ChangeFlags::NONE
2252            }
2253        }
2254        impl Widget for OwnerW {
2255            fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2256                bc.constrain(Size::new(200.0, 1000.0))
2257            }
2258            fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2259            fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
2260                match e {
2261                    InputEvent::Overlay(_) => {
2262                        ctx.state_mut::<Seen>().overlays += 1;
2263                        // Even a `Handled` must not be reported upward: a
2264                        // broadcast is never consumed.
2265                        EventResult::Handled
2266                    }
2267                    InputEvent::Pointer(_) => {
2268                        ctx.state_mut::<Seen>().pointers += 1;
2269                        EventResult::Handled
2270                    }
2271                    _ => EventResult::Ignored,
2272                }
2273            }
2274        }
2275
2276        let view: ScrollView<Seen> = scroll_view(Owner);
2277        let mut counter = 0u64;
2278        let mut w = View::<Seen>::build(&view, &mut BuildCtx::new(&mut counter));
2279        w.viewport = Size::new(200.0, 100.0);
2280        let mut state = Seen::default();
2281        let broadcast = InputEvent::Overlay(OverlayEvent {
2282            key: OverlayKey::next(),
2283            kind: OverlayEventKind::OutsideDown,
2284        });
2285        let result = {
2286            let sa: &mut dyn Any = &mut state;
2287            let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
2288            w.event_at(&mut ctx, &broadcast, 0.0)
2289        };
2290        assert_eq!(
2291            state.overlays, 1,
2292            "a floated surface's own input reaches its owner through the viewport"
2293        );
2294        assert_eq!(
2295            result,
2296            EventResult::Ignored,
2297            "and is never consumed, whatever the child returned"
2298        );
2299        assert_eq!(state.pointers, 0, "it is not a pointer event");
2300        // The gesture machinery is untouched by it: no capture was opened and
2301        // no drag armed.
2302        assert!(!w.down_active && !w.scrolling);
2303    }
2304
2305    #[test]
2306    fn hover_move_without_down_never_scrolls_or_cancels_child() {
2307        let mut w = probe_scroll();
2308        let mut state = Rec::default();
2309        // A cursor drifting over the list with no prior Down: no takeover, no
2310        // Cancel to the child, no offset change, no self redraw request.
2311        let redraw = run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 16.0);
2312        assert!(!w.scrolling, "hover must not enter scrolling");
2313        assert!(!w.down_active);
2314        assert_eq!(w.offset(), 0.0, "hover must not move the offset");
2315        assert_eq!(state.cancels, 0, "hover must not cancel the child");
2316        assert!(!redraw, "hover must not request a redraw");
2317        // The hover move is forwarded to the child (which ignores it).
2318        assert_eq!(state.moves, 1);
2319    }
2320
2321    #[test]
2322    fn cancel_clears_down_active() {
2323        let mut w = probe_scroll();
2324        let mut state = Rec::default();
2325        run_rec(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2326        assert!(w.down_active);
2327        run_rec(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0), 16.0);
2328        assert!(!w.down_active, "Cancel disarms the gesture");
2329        assert!(!w.scrolling);
2330        // A subsequent hover Move must not run the takeover math.
2331        run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 20.0), 32.0);
2332        assert!(!w.scrolling, "hover after Cancel must not take over");
2333        assert_eq!(w.offset(), 0.0);
2334    }
2335
2336    #[test]
2337    fn fling_after_release_decays_and_clamps() {
2338        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2339        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2340        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2341        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // scroll, builds velocity
2342        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
2343        assert!(w.is_flinging(), "release with velocity starts a fling");
2344        // Integrate to completion.
2345        let mut steps = 0;
2346        while w.tick(16.0) {
2347            steps += 1;
2348            assert!(steps < 100_000, "fling failed to terminate");
2349        }
2350        assert!(!w.is_flinging());
2351        assert!(w.offset() >= 0.0 && w.offset() <= w.max_offset());
2352    }
2353
2354    #[test]
2355    fn pump_fling_signals_needs_frame_until_at_rest() {
2356        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2357        // Drive a release-with-velocity to start a fling (deterministic seam).
2358        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2359        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2360        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // build velocity
2361        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
2362        assert!(w.is_flinging(), "release with velocity starts a fling");
2363
2364        // A paint-time pump while flinging asks for another frame.
2365        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
2366        w.pump_fling(&mut ctx);
2367        assert!(
2368            ctx.needs_frame(),
2369            "an in-flight fling requests continuation"
2370        );
2371        assert!(w.is_flinging());
2372
2373        // Integrate the fling to rest via the deterministic tick seam.
2374        while w.tick(16.0) {}
2375        assert!(!w.is_flinging());
2376
2377        // At rest, the pump no longer signals — the shell can idle again.
2378        let mut ctx_rest = PaintCtx::new(Point::ZERO, w.viewport);
2379        w.pump_fling(&mut ctx_rest);
2380        assert!(
2381            !ctx_rest.needs_frame(),
2382            "a fling at rest stops requesting frames"
2383        );
2384    }
2385
2386    #[test]
2387    fn tick_without_a_fling_is_a_noop() {
2388        let mut w = laid_out(200.0, 100.0, 1000.0);
2389        assert!(!w.tick(16.0));
2390        assert_eq!(w.offset(), 0.0);
2391    }
2392
2393    /// A no-op paint sink for the RenderRoot clock test.
2394    struct NullScene;
2395    impl PaintScene for NullScene {
2396        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2397        fn draw_text(&mut self, _o: Point, _t: &str) {}
2398    }
2399
2400    fn scroll_widget(root: &frust_core::RenderRoot<(), ScrollView<()>>) -> &ScrollWidget {
2401        let id = root.root_id().expect("root built");
2402        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
2403            .downcast_ref::<ScrollWidget>()
2404            .expect("root is a ScrollWidget")
2405    }
2406
2407    #[test]
2408    fn fling_advances_from_injected_paint_frame_time() {
2409        // End-to-end through the real paint path: the
2410        // event pass reads the last painted frame time for velocity tracking, and
2411        // the fling pump advances off the injected `RenderRoot::paint` frame time
2412        // — no wall clock anywhere. Paints are interleaved with the drag so the
2413        // velocity tracker sees distinct (paint-clock) timestamps.
2414        use frust_core::{FrameTime, RenderRoot};
2415
2416        fn logic(_: &mut ()) -> ScrollView<()> {
2417            scroll_view(leaf(200.0, 1000.0))
2418        }
2419        let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
2420        let mut state = ();
2421        root.rebuild(&mut logic, &mut state);
2422        root.layout(Size::new(200.0, 100.0));
2423
2424        let ft = |ms: f64| FrameTime::from_nanos((ms * 1_000_000.0) as u64);
2425        let mut sink = NullScene;
2426        root.paint(&mut sink, ft(0.0));
2427        root.event(&mut state, &ev(PointerPhase::Down, 100.0));
2428        root.paint(&mut sink, ft(16.0));
2429        root.event(&mut state, &ev(PointerPhase::Move, 75.0)); // crosses slop → takeover
2430        root.paint(&mut sink, ft(32.0));
2431        root.event(&mut state, &ev(PointerPhase::Move, 50.0)); // scroll, builds velocity
2432        root.event(&mut state, &ev(PointerPhase::Up, 50.0)); // release → fling
2433
2434        assert!(
2435            scroll_widget(&root).is_flinging(),
2436            "release with paint-clock velocity starts a fling"
2437        );
2438        let before = scroll_widget(&root).offset();
2439
2440        // Advancing frame times drive the fling: the first paint seeds the fling
2441        // clock (zero delta), the next advances the offset.
2442        root.paint(&mut sink, ft(48.0));
2443        root.paint(&mut sink, ft(64.0));
2444        assert!(
2445            scroll_widget(&root).offset() > before,
2446            "the fling advanced from the injected paint frame time"
2447        );
2448    }
2449
2450    /// A perpetual animator: requests a continuation frame on every paint.
2451    /// Stands in for an offscreen shimmer/spinner whose frame requests
2452    /// paint-time culling must suppress.
2453    struct Ticker;
2454    struct TickerWidget;
2455    impl View<()> for Ticker {
2456        type Element = TickerWidget;
2457        fn build(&self, _c: &mut BuildCtx<'_>) -> TickerWidget {
2458            TickerWidget
2459        }
2460        fn rebuild(&self, _p: &Self, _e: &mut TickerWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2461            ChangeFlags::NONE
2462        }
2463    }
2464    impl Widget for TickerWidget {
2465        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2466            bc.constrain(Size::new(100.0, 100.0))
2467        }
2468        fn paint(&mut self, ctx: &mut PaintCtx, _s: &mut dyn PaintScene) {
2469            ctx.request_frame();
2470        }
2471    }
2472
2473    #[test]
2474    fn offscreen_flex_animator_culled_until_scrolled_into_view() {
2475        // End-to-end frame suppression: a perpetual animator below
2476        // the fold in a Column inside a ScrollView is culled from paint, so its
2477        // request_frame never bubbles and the root PaintOutcome asks for no
2478        // continuation frame. Scroll it into view and the requests resume.
2479        use frust_core::RenderRoot;
2480
2481        fn logic(_: &mut ()) -> ScrollView<()> {
2482            // A 1000px spacer, then a 100px perpetual animator (content 1100 tall).
2483            scroll_view(crate::column().child(leaf(100.0, 1000.0)).child(Ticker))
2484        }
2485        let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
2486        let mut state = ();
2487        root.rebuild(&mut logic, &mut state);
2488        root.layout(Size::new(100.0, 100.0));
2489
2490        let mut sink = NullScene;
2491        // At rest (offset 0) the animator sits at y=1000, far below the warm band
2492        // (viewport 100 + one-viewport margin → y ∈ [-100, 200]); it is culled, so
2493        // no continuation frame is requested.
2494        let outcome = root.paint(&mut sink, FrameTime::ZERO);
2495        assert!(
2496            !outcome.needs_frame,
2497            "an offscreen animator's frame request is culled"
2498        );
2499
2500        // Scroll to the bottom (wheel clamps to max_offset = 1000): the animator
2501        // comes into view and its request_frame bubbles out of paint again.
2502        root.event(&mut state, &scroll(50.0, false, 5000.0));
2503        let outcome = root.paint(&mut sink, FrameTime::ZERO);
2504        assert!(
2505            outcome.needs_frame,
2506            "scrolling the animator into view resumes its frame requests"
2507        );
2508    }
2509
2510    #[test]
2511    fn offset_reclamps_when_content_shrinks() {
2512        let mut w = laid_out(200.0, 100.0, 1000.0);
2513        dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
2514        assert_eq!(w.offset(), 800.0);
2515        // Content shrinks to just above the viewport → max_offset drops to 50,
2516        // and a re-clamp (what layout does) pulls the stale offset back in range.
2517        w.content = Size::new(200.0, 150.0);
2518        w.set_offset(w.offset);
2519        assert_eq!(w.max_offset(), 50.0);
2520        assert_eq!(w.offset(), 50.0);
2521    }
2522
2523    // --- Overscroll (pull-to-refresh seam) ---
2524
2525    #[test]
2526    fn drag_past_top_overscrolls_with_resistance_then_settles_back() {
2527        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2528        // Down, then a drag downward crossing the slop takes the gesture over.
2529        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2530        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2531        assert!(w.scrolling);
2532        assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
2533        // Drag 20px further down past the already-at-top edge → resisted overscroll.
2534        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2535        assert!(w.offset() < 0.0, "a drag past the top overscrolls negative");
2536        assert_eq!(
2537            w.offset(),
2538            -10.0,
2539            "overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5)"
2540        );
2541        // Release → a settle animation, not a fling; it returns to the edge.
2542        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
2543        assert!(
2544            !w.is_flinging(),
2545            "an overscrolled release settles, never flings"
2546        );
2547        let mut steps = 0;
2548        while w.settle_tick(16.0) {
2549            steps += 1;
2550            assert!(steps < 10_000, "settle failed to terminate");
2551        }
2552        assert_eq!(
2553            w.offset(),
2554            0.0,
2555            "the surface settles back to the clamped edge"
2556        );
2557    }
2558
2559    #[test]
2560    fn wheel_never_overscrolls_past_top() {
2561        let mut w = laid_out(200.0, 100.0, 1000.0);
2562        // A large negative wheel delta at the top stays hard-clamped at 0 — no
2563        // rubber-band on wheel input.
2564        dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
2565        assert_eq!(w.offset(), 0.0);
2566        assert!(!w.settling, "wheel input starts no settle animation");
2567    }
2568
2569    /// A state that records every `ScrollInfo` its `on_scroll` observes.
2570    #[derive(Default)]
2571    struct ScrollLog {
2572        infos: Vec<ScrollInfo>,
2573        refreshes: u32,
2574    }
2575
2576    /// A fixed-size content view generic over the state type (the shared `leaf`
2577    /// fixture is `View<()>` only), so a scroll view can wrap it over `ScrollLog`.
2578    struct Content(Size);
2579    struct ContentW(Size);
2580    impl<S: 'static> View<S> for Content {
2581        type Element = ContentW;
2582        fn build(&self, _c: &mut BuildCtx<'_>) -> ContentW {
2583            ContentW(self.0)
2584        }
2585        fn rebuild(&self, _p: &Self, _e: &mut ContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2586            ChangeFlags::NONE
2587        }
2588    }
2589    impl Widget for ContentW {
2590        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2591            bc.constrain(self.0)
2592        }
2593        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2594    }
2595
2596    /// Build+lay out a scroll widget over `ScrollLog` state with the given
2597    /// callbacks installed.
2598    fn observed(with_refresh: bool) -> ScrollWidget {
2599        let mut view: ScrollView<ScrollLog> = scroll_view(Content(Size::new(200.0, 1000.0)))
2600            .on_scroll(|s: &mut ScrollLog, info| s.infos.push(info));
2601        if with_refresh {
2602            view = view.on_refresh_release(|s: &mut ScrollLog| s.refreshes += 1);
2603        }
2604        let mut counter = 0u64;
2605        let mut w = View::<ScrollLog>::build(&view, &mut BuildCtx::new(&mut counter));
2606        let mut lctx = LayoutCtx::new();
2607        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2608        w
2609    }
2610
2611    /// [`observed`] with the pre-seam [`RubberBand`] feel pinned explicitly,
2612    /// for the same reason [`laid_out_rubber_band`] exists.
2613    fn observed_rubber_band(with_refresh: bool) -> ScrollWidget {
2614        let mut w = observed(with_refresh);
2615        w.physics = Rc::new(RubberBand::new());
2616        w
2617    }
2618
2619    fn run_log(w: &mut ScrollWidget, state: &mut ScrollLog, e: &InputEvent, t: f64) {
2620        let sa: &mut dyn Any = state;
2621        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
2622        w.event_at(&mut ctx, e, t);
2623    }
2624
2625    #[test]
2626    fn on_scroll_observes_drag_deltas() {
2627        let mut w = observed(false);
2628        let mut state = ScrollLog::default();
2629        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2630        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0); // takeover
2631        // Two scrolling drags upward move the content down.
2632        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
2633        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 10.0), 48.0);
2634        assert!(!state.infos.is_empty(), "on_scroll fired on the drag");
2635        let last = state.infos.last().unwrap();
2636        assert!(
2637            last.offset > 0.0,
2638            "the observed offset grew as content scrolled"
2639        );
2640        assert_eq!(last.overscroll, 0.0, "an in-range drag has no overscroll");
2641        assert_eq!(last.max_offset, 900.0);
2642    }
2643
2644    #[test]
2645    fn on_refresh_release_fires_only_past_trigger_and_only_on_release() {
2646        // A small pull (under the trigger) does not fire on release.
2647        let mut w = observed_rubber_band(true);
2648        let mut state = ScrollLog::default();
2649        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2650        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2651        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 180.0), 32.0); // raw -90 → -45
2652        assert_eq!(w.offset(), -45.0, "under the trigger (|-45| < 64)");
2653        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 180.0), 48.0);
2654        assert_eq!(
2655            state.refreshes, 0,
2656            "release under the trigger does not refresh"
2657        );
2658
2659        // A large pull past the trigger fires exactly once, on release.
2660        let mut w = observed_rubber_band(true);
2661        let mut state = ScrollLog::default();
2662        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2663        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2664        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); // raw -200 → -100
2665        assert_eq!(w.offset(), -100.0, "past the trigger (|-100| > 64)");
2666        assert_eq!(state.refreshes, 0, "no fire before release");
2667        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 290.0), 48.0);
2668        assert_eq!(state.refreshes, 1, "release past the trigger fires once");
2669    }
2670
2671    #[test]
2672    fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
2673        let mut w = observed_rubber_band(true);
2674        let mut state = ScrollLog::default();
2675        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2676        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2677        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); // past trigger
2678        assert_eq!(w.offset(), -100.0);
2679        // A Cancel (gesture steal) must not fire on_refresh_release and snaps the
2680        // overscroll away with no settle animation.
2681        run_log(&mut w, &mut state, &ev(PointerPhase::Cancel, 290.0), 48.0);
2682        assert_eq!(
2683            state.refreshes, 0,
2684            "Cancel never fires the refresh callback"
2685        );
2686        assert_eq!(w.offset(), 0.0, "Cancel snaps the surface back into range");
2687        assert!(!w.settling);
2688    }
2689
2690    // --- Device-gate G6 (the "phantom clone" bug): a layout pass mid-gesture
2691    // must not snap an out-of-range offset back into range. ---
2692
2693    #[test]
2694    fn layout_mid_drag_preserves_top_overscroll() {
2695        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2696        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2697        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2698        assert!(w.scrolling);
2699        // Drag 20px further down past the already-at-top edge → resisted overscroll.
2700        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2701        assert_eq!(
2702            w.offset(),
2703            -10.0,
2704            "resisted overscroll before the layout pass"
2705        );
2706
2707        // A layout pass fires mid-drag (e.g. a sibling requesting relayout every
2708        // frame, like a wavy progress indicator). Without the fix this snaps the
2709        // child back to rest (offset 0.0) — the phantom-clone bug: the next
2710        // pointer Move re-applies the displacement, so the presented frame
2711        // alternates between rest and dragged at display rate.
2712        let mut lctx = LayoutCtx::new();
2713        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2714        assert_eq!(
2715            w.offset(),
2716            -10.0,
2717            "a layout pass mid-drag must not snap the overscroll back into range"
2718        );
2719        assert!(w.scrolling, "still an active drag after the layout pass");
2720    }
2721
2722    #[test]
2723    fn layout_mid_settle_preserves_decaying_overscroll() {
2724        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2725        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2726        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2727        dispatch(&mut w, &ev(PointerPhase::Move, 290.0), 32.0); // raw -200 → -100
2728        assert_eq!(w.offset(), -100.0, "past the refresh trigger (|-100| > 64)");
2729        dispatch(&mut w, &ev(PointerPhase::Up, 290.0), 48.0);
2730        assert!(
2731            w.settling,
2732            "an overscrolled release enters the settle animation"
2733        );
2734        assert!(!w.is_flinging());
2735
2736        // Advance one settle tick: the offset has eased toward the edge but has
2737        // not arrived yet.
2738        let still_settling = w.settle_tick(16.0);
2739        assert!(still_settling);
2740        let after_tick = w.offset();
2741        assert!(
2742            after_tick < 0.0,
2743            "one settle tick eases toward the edge but is still out of range"
2744        );
2745
2746        // A layout pass fires mid-settle. Without the fix this snaps the
2747        // decaying offset straight to 0.0, visibly skipping the rest of the
2748        // settle animation (the same bug class as the mid-drag case above).
2749        let mut lctx = LayoutCtx::new();
2750        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2751        assert_eq!(
2752            w.offset(),
2753            after_tick,
2754            "a layout pass mid-settle must not snap the decaying overscroll back into range"
2755        );
2756        assert!(w.settling, "still settling after the layout pass");
2757    }
2758
2759    // --- The physics seam: the default `RubberBand` install, the generic
2760    //     ballistic driver, and the carried-momentum hook. ---
2761
2762    #[test]
2763    fn edge_pull_equals_overscroll_under_rubber_band() {
2764        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2765        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2766        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2767        // 20px past the already-at-top edge: resistance halves it, and nothing
2768        // is boundary-rejected, so the pull *is* the displacement.
2769        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2770        assert_eq!(w.offset(), -10.0);
2771        assert_eq!(w.edge_pull, -10.0, "negative past the top, like overscroll");
2772        assert_eq!(
2773            w.edge_pull,
2774            w.scroll_info().overscroll,
2775            "nothing rejected → the two are the same number"
2776        );
2777
2778        // The settle decays both together, and both land exactly on zero.
2779        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
2780        assert!(w.settling);
2781        assert!(w.settle_tick(16.0));
2782        assert!(
2783            w.edge_pull < 0.0 && w.edge_pull > -10.0,
2784            "one settle tick eases the pull toward the edge: {}",
2785            w.edge_pull
2786        );
2787        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2788        while w.settle_tick(16.0) {}
2789        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
2790        assert_eq!(w.scroll_info().overscroll, 0.0);
2791
2792        // The bottom edge is the same story with the opposite sign.
2793        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2794        dispatch(&mut w, &scroll(50.0, false, 5000.0), 0.0); // clamp to max_offset
2795        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2796        dispatch(&mut w, &ev(PointerPhase::Move, 60.0), 16.0); // takeover
2797        dispatch(&mut w, &ev(PointerPhase::Move, 0.0), 32.0); // 60px past the bottom
2798        assert_eq!(w.edge_pull, 30.0, "positive past the bottom");
2799        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2800    }
2801
2802    /// A scripted ballistic curve: a straight 100 px/s ramp from where the
2803    /// release left the position, done after 100ms.
2804    struct Ramp {
2805        from: f64,
2806    }
2807    impl Simulation for Ramp {
2808        fn x(&self, time: f64) -> f64 {
2809            self.from + 100.0 * time
2810        }
2811        fn dx(&self, _time: f64) -> f64 {
2812            100.0
2813        }
2814        fn is_done(&self, time: f64) -> bool {
2815            time >= 0.1
2816        }
2817    }
2818
2819    /// A toy physics that *does* hand back a simulation — the counterpart of
2820    /// `RubberBand`'s `None`, exercising the generic driver.
2821    #[derive(Debug)]
2822    struct RampPhysics;
2823    impl ScrollPhysics for RampPhysics {
2824        fn create_ballistic_simulation(
2825            &self,
2826            metrics: &ScrollMetrics,
2827            _velocity: f64,
2828        ) -> Option<Box<dyn Simulation>> {
2829            Some(Box::new(Ramp {
2830                from: metrics.pixels,
2831            }))
2832        }
2833    }
2834
2835    fn frame_time(ms: f64) -> FrameTime {
2836        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
2837    }
2838
2839    #[test]
2840    fn ballistic_driver_runs_generic_simulation() {
2841        let mut w = observed(false);
2842        w.physics = Rc::new(RampPhysics);
2843        let mut state = ScrollLog::default();
2844        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2845        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2846        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0); // in-range drag
2847        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 50.0), 32.0);
2848        assert!(
2849            w.ballistic.is_some(),
2850            "a physics handing back a simulation owns the release"
2851        );
2852        assert!(w.fling.is_none(), "…and the legacy fling never starts");
2853        assert!(!w.settling);
2854        let start = w.offset();
2855        let observed_before = state.infos.len();
2856
2857        // The first pump seeds the simulation clock: zero delta, nothing moves,
2858        // nothing recorded — the same seeding frame the legacy pump takes.
2859        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(100.0));
2860        w.pump_fling(&mut ctx);
2861        assert!(ctx.needs_frame(), "a live simulation asks for continuation");
2862        assert_eq!(w.offset(), start, "the seeding frame moves nothing");
2863        assert!(!w.pending_scroll_notify);
2864
2865        // 16ms on, the offset is exactly the curve's own position.
2866        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(116.0));
2867        w.pump_fling(&mut ctx);
2868        assert!(
2869            (w.offset() - (start + 1.6)).abs() < 1e-9,
2870            "the offset follows sim.x(t): {}",
2871            w.offset()
2872        );
2873        assert!(w.pending_scroll_notify, "recorded at paint, not fired");
2874        assert_eq!(
2875            state.infos.len(),
2876            observed_before,
2877            "the notification stays one event late"
2878        );
2879
2880        // …and the next event delivers it (a hover move, the gesture is over).
2881        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 132.0);
2882        assert!(!w.pending_scroll_notify);
2883        assert_eq!(state.infos.len(), observed_before + 1);
2884
2885        // Past the curve's own end the driver drops it and the pump goes quiet.
2886        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(300.0));
2887        w.pump_fling(&mut ctx);
2888        assert!((w.offset() - (start + 20.0)).abs() < 1e-9);
2889        assert!(w.ballistic.is_none(), "a done simulation is dropped");
2890        assert!(!w.is_flinging());
2891        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(316.0));
2892        w.pump_fling(&mut ctx);
2893        assert!(!ctx.needs_frame(), "at rest the shell can idle again");
2894    }
2895
2896    /// A toy physics carrying a fixed +100 px/s out of *interrupted* motion
2897    /// (nothing to carry from a press onto a resting surface), leaving
2898    /// post-release motion to the legacy path like `RubberBand` does.
2899    #[derive(Debug)]
2900    struct CarryPhysics;
2901    impl ScrollPhysics for CarryPhysics {
2902        fn carried_momentum(&self, existing_velocity: f64) -> f64 {
2903            if existing_velocity == 0.0 { 0.0 } else { 100.0 }
2904        }
2905    }
2906
2907    /// Drag-release twice, the second press landing on the live fling of the
2908    /// first, and report the two fling velocities.
2909    fn fling_then_refling(w: &mut ScrollWidget) -> (f64, f64) {
2910        dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
2911        dispatch(w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2912        dispatch(w, &ev(PointerPhase::Move, 50.0), 32.0); // builds velocity
2913        dispatch(w, &ev(PointerPhase::Up, 50.0), 32.0);
2914        let first = w.fling.expect("release with velocity flings");
2915        // The second press interrupts that fling, and releases identically.
2916        dispatch(w, &ev(PointerPhase::Down, 100.0), 48.0);
2917        dispatch(w, &ev(PointerPhase::Move, 75.0), 64.0);
2918        dispatch(w, &ev(PointerPhase::Move, 50.0), 80.0);
2919        dispatch(w, &ev(PointerPhase::Up, 50.0), 80.0);
2920        (first, w.fling.expect("the second release flings too"))
2921    }
2922
2923    #[test]
2924    fn carried_momentum_hook_feeds_new_fling() {
2925        let mut w = laid_out(200.0, 100.0, 5000.0);
2926        w.physics = Rc::new(CarryPhysics);
2927        let (first, second) = fling_then_refling(&mut w);
2928        assert_eq!(
2929            second,
2930            first + 100.0,
2931            "a fling started during live motion carries the physics' momentum"
2932        );
2933
2934        // `RubberBand` carries nothing, so the identical sequence produces
2935        // the identical velocity twice (and keeps the legacy fling the helper
2936        // above reads — the bouncing default hands back a simulation instead,
2937        // pinned by `default_carried_momentum_compounds_a_refling`).
2938        let mut w = laid_out_rubber_band(200.0, 100.0, 5000.0);
2939        let (first, second) = fling_then_refling(&mut w);
2940        assert_eq!(second, first, "RubberBand starts every fling cold");
2941    }
2942
2943    // --- The platform default (`physics::default_physics`): bouncing on this
2944    //     host, clamping on Android. What a surface that names no physics of
2945    //     its own actually does — the depth-aware drag curve, the spring-back
2946    //     release, carried momentum, and the fling gate. ---
2947
2948    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
2949        assert!(
2950            (actual - expected).abs() < epsilon,
2951            "{what}: {actual} is not within {epsilon} of {expected}"
2952        );
2953    }
2954
2955    /// The host default, named once so every test below reads as "the default"
2956    /// rather than "Bouncing" — and so the pairing itself is asserted.
2957    #[test]
2958    fn a_fresh_surface_installs_the_platform_default() {
2959        let w = laid_out(200.0, 100.0, 1000.0);
2960        assert_eq!(
2961            format!("{:?}", w.physics),
2962            format!("{:?}", crate::physics::default_physics())
2963        );
2964        assert_eq!(w.effect, crate::physics::default_overscroll_effect());
2965    }
2966
2967    #[test]
2968    fn default_drag_tension_tightens_with_depth() {
2969        let mut w = laid_out(200.0, 100.0, 1000.0);
2970        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2971        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2972
2973        // 20px past the already-at-top edge, from zero depth: the friction
2974        // factor is 0.52·(1 − 0)² = 0.52, so 20 · 0.52 = 10.4 shows.
2975        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2976        let first = w.offset();
2977        assert_close(
2978            first,
2979            -20.0 * DecelerationRate::NORMAL_FRICTION,
2980            1e-12,
2981            "the first past-edge move, at zero depth",
2982        );
2983
2984        // 20px more. The surface now sits 10.4px out of a 100px viewport, so
2985        // the factor has tightened to 0.52·(1 − 0.104)² = 0.41746432 and this
2986        // move only adds 20 · 0.41746432 = 8.3492864 — a total of 18.7492864.
2987        dispatch(&mut w, &ev(PointerPhase::Move, 130.0), 48.0);
2988        let second = w.offset() - first;
2989        assert_close(second, -8.349_286_4, 1e-9, "the second, deeper move");
2990        assert_close(w.offset(), -18.749_286_4, 1e-9, "the accumulated pull");
2991        assert!(
2992            second.abs() < first.abs(),
2993            "the deeper pull must displace LESS per raw px: {second} vs {first}"
2994        );
2995        // The whole pull is displacement, none of it boundary-rejected.
2996        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2997    }
2998
2999    #[test]
3000    fn default_release_past_the_edge_springs_back_to_the_boundary() {
3001        let mut w = laid_out(200.0, 100.0, 1000.0);
3002        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
3003        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
3004        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
3005        assert!(w.offset() < 0.0, "the drag left the surface past its top");
3006
3007        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
3008        assert!(
3009            w.ballistic.is_some(),
3010            "the default physics owns the release with a spring"
3011        );
3012        assert!(w.fling.is_none() && !w.settling, "…so no legacy path runs");
3013        assert!(w.is_flinging(), "post-release motion is live");
3014
3015        // Pump the shared frame clock until the spring reports itself done.
3016        let mut ms = 100.0;
3017        let mut steps = 0;
3018        let mut moved = false;
3019        while w.is_flinging() {
3020            let before = w.offset();
3021            let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
3022            w.pump_fling(&mut ctx);
3023            moved |= w.offset() != before;
3024            ms += 16.0;
3025            steps += 1;
3026            assert!(steps < 2_000, "the bounce-back never settled");
3027        }
3028        assert!(moved, "the spring must actually animate, not snap");
3029        assert!(
3030            w.offset().abs() < 1e-6,
3031            "the spring converges onto the boundary: {}",
3032            w.offset()
3033        );
3034        assert!(
3035            w.edge_pull.abs() < 1e-6,
3036            "…and leaves no pull behind: {}",
3037            w.edge_pull
3038        );
3039    }
3040
3041    #[test]
3042    fn default_carried_momentum_compounds_a_refling() {
3043        let mut w = laid_out(200.0, 100.0, 5000.0);
3044        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3045        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
3046        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // builds velocity
3047        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
3048        let first = w
3049            .ballistic
3050            .as_ref()
3051            .expect("a release above the fling minimum is ballistic")
3052            .sim
3053            .dx(0.0);
3054        // 50px of finger travel over 32ms, and the offset moves opposite it.
3055        assert_close(first, 1562.5, 1e-9, "the first release velocity");
3056
3057        // The second press lands on that live curve, so its release carries
3058        // the physics' own fitted share of it forward.
3059        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3060        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 64.0);
3061        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 80.0);
3062        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 80.0);
3063        let second = w
3064            .ballistic
3065            .as_ref()
3066            .expect("the second release is ballistic too")
3067            .sim
3068            .dx(0.0);
3069        assert_close(
3070            second,
3071            first + Bouncing::new().carried_momentum(first),
3072            1e-9,
3073            "the re-fling carries Bouncing::carried_momentum(first)",
3074        );
3075        assert!(second > first, "…which is a genuine speed-up");
3076    }
3077
3078    /// The signed start velocity of whatever post-release motion the last `Up`
3079    /// produced — the physics-supplied curve's own, the legacy fling's, or
3080    /// `0.0` for a release that started no motion at all. Reads all three
3081    /// outcomes through one number so a direction assertion does not depend on
3082    /// which release path caught the gesture.
3083    fn release_velocity(w: &ScrollWidget) -> f64 {
3084        match w.ballistic.as_ref() {
3085            Some(state) => state.sim.dx(0.0),
3086            None => w.fling.unwrap_or(0.0),
3087        }
3088    }
3089
3090    #[test]
3091    fn reverse_refling_keeps_the_fingers_velocity() {
3092        // Parked mid-content, so both releases are judged on velocity alone
3093        // with no edge spring in play.
3094        let mut w = laid_out(200.0, 100.0, 5000.0);
3095        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
3096
3097        // A downward fling: 50px of finger travel up over 32ms.
3098        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3099        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
3100        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
3101        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
3102        assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
3103
3104        // The finger lands on that live curve and flicks back the other way,
3105        // just as fast. The interrupted motion's momentum must not be added to
3106        // a release pointing the other way — it would cancel the flick out (or
3107        // reverse it), and the surface would ignore the finger entirely.
3108        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3109        dispatch(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); // takeover
3110        dispatch(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
3111        dispatch(&mut w, &ev(PointerPhase::Up, 150.0), 80.0);
3112        assert_close(
3113            release_velocity(&w),
3114            -1562.5,
3115            1e-9,
3116            "the reverse re-fling runs at the finger's own velocity",
3117        );
3118        assert!(
3119            w.ballistic.is_some(),
3120            "…as a real ballistic curve, not a stalled remnant"
3121        );
3122    }
3123
3124    #[test]
3125    fn small_reverse_flick_is_not_inverted() {
3126        let mut w = laid_out(200.0, 100.0, 5000.0);
3127        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
3128
3129        // Live downward motion at 1000 px/s: 32px of finger travel over 32ms.
3130        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3131        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
3132        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
3133        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
3134        assert_close(release_velocity(&w), 1000.0, 1e-9, "the live motion");
3135
3136        // A *modest* drag back the other way — 24px down over 60ms, 400 px/s,
3137        // well under the interrupted motion's own speed. Slower than what it
3138        // interrupted, but still unambiguously the other way: the surface must
3139        // never answer it by accelerating onward in the old direction.
3140        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3141        dispatch(&mut w, &ev(PointerPhase::Move, 108.0), 68.0);
3142        dispatch(&mut w, &ev(PointerPhase::Move, 116.0), 88.0);
3143        dispatch(&mut w, &ev(PointerPhase::Move, 124.0), 108.0); // takeover
3144        dispatch(&mut w, &ev(PointerPhase::Up, 124.0), 108.0);
3145        let released = release_velocity(&w);
3146        assert!(
3147            released <= 0.0,
3148            "a reverse flick must never relaunch the surface the way it was \
3149             already going: {released}"
3150        );
3151        assert_close(
3152            released,
3153            -400.0,
3154            1e-9,
3155            "…it runs at the finger's own velocity instead",
3156        );
3157    }
3158
3159    /// The retain gate compares a release against the physics' **mapped**
3160    /// share of the interrupted velocity, not the raw interrupted speed —
3161    /// pinned here because the two diverge (`Bouncing`'s power curve sits
3162    /// below the raw value under ~1563 px/s). Interrupted at 1000 px/s,
3163    /// `Bouncing::new().carried_momentum(1000.0)` maps to ~649.7, putting the
3164    /// retain threshold at ~324.8 — well under the raw-carried threshold
3165    /// (500) the pre-fix gate used.
3166    #[test]
3167    fn momentum_retain_threshold_refuses_a_weak_refling() {
3168        // Parked mid-content, so the release is judged on velocity alone.
3169        let mut w = laid_out(200.0, 100.0, 5000.0);
3170        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
3171
3172        // Interrupted motion at 1000 px/s (the same drag
3173        // `small_reverse_flick_is_not_inverted` uses to establish it).
3174        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3175        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
3176        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
3177        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
3178        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
3179
3180        let mapped = Bouncing::new().carried_momentum(1000.0);
3181        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
3182        assert!(
3183            (300.0..350.0).contains(&threshold),
3184            "the fixture's release values must straddle the threshold: {threshold}"
3185        );
3186
3187        // Same-direction re-flick at 300 px/s — under the mapped threshold.
3188        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3189        dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
3190        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); // 30px / 100ms → 300 px/s
3191        dispatch(&mut w, &ev(PointerPhase::Up, 70.0), 148.0);
3192        assert_close(
3193            release_velocity(&w),
3194            300.0,
3195            1e-9,
3196            "a release under the mapped threshold carries nothing forward",
3197        );
3198    }
3199
3200    /// The strong-side twin of `momentum_retain_threshold_refuses_a_weak_refling`:
3201    /// a release over the same mapped threshold carries `mapped` forward
3202    /// exactly, pre-clamp.
3203    #[test]
3204    fn momentum_retain_threshold_carries_a_strong_refling() {
3205        let mut w = laid_out(200.0, 100.0, 5000.0);
3206        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
3207
3208        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3209        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
3210        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
3211        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
3212        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
3213
3214        let mapped = Bouncing::new().carried_momentum(1000.0);
3215        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
3216        assert!(
3217            (300.0..350.0).contains(&threshold),
3218            "the fixture's release values must straddle the threshold: {threshold}"
3219        );
3220
3221        // Same-direction re-flick at 350 px/s — over the mapped threshold.
3222        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3223        dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
3224        dispatch(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); // 35px / 100ms → 350 px/s
3225        dispatch(&mut w, &ev(PointerPhase::Up, 65.0), 148.0);
3226        assert_close(
3227            release_velocity(&w),
3228            350.0 + mapped,
3229            1e-9,
3230            "a release over the mapped threshold carries `mapped` forward exactly",
3231        );
3232    }
3233
3234    #[test]
3235    fn default_min_fling_gate_is_one_hundred() {
3236        // Both halves start parked mid-content, so the release is judged on
3237        // velocity alone — an out-of-range release always gets its spring back
3238        // whatever the speed (`default_release_past_the_edge_springs_back…`).
3239        let mut slow = laid_out(200.0, 100.0, 5000.0);
3240        dispatch(&mut slow, &scroll(50.0, false, 1000.0), 0.0);
3241        dispatch(&mut slow, &ev(PointerPhase::Down, 100.0), 0.0);
3242        dispatch(&mut slow, &ev(PointerPhase::Move, 80.0), 16.0); // takeover
3243        // 6px of finger travel over the tracker's whole 100ms window: 60 px/s.
3244        dispatch(&mut slow, &ev(PointerPhase::Move, 94.0), 100.0);
3245        dispatch(&mut slow, &ev(PointerPhase::Up, 94.0), 100.0);
3246        assert!(
3247            slow.ballistic.is_none(),
3248            "60 px/s is under the bouncing minimum (100), so no ballistic"
3249        );
3250        // The physics declining leaves the release on the widget's own legacy
3251        // path, whose threshold is the pinned FLING_STOP (30 px/s) instead —
3252        // the one place the two ladders disagree, pinned so it cannot drift
3253        // unnoticed.
3254        assert_close(
3255            slow.fling.expect("the legacy fling catches it instead"),
3256            60.0,
3257            1e-9,
3258            "the legacy fallback runs at the raw release velocity",
3259        );
3260
3261        let mut fast = laid_out(200.0, 100.0, 5000.0);
3262        dispatch(&mut fast, &scroll(50.0, false, 1000.0), 0.0);
3263        dispatch(&mut fast, &ev(PointerPhase::Down, 100.0), 0.0);
3264        dispatch(&mut fast, &ev(PointerPhase::Move, 80.0), 16.0); // takeover
3265        // 15px over the same window: 150 px/s, over the minimum.
3266        dispatch(&mut fast, &ev(PointerPhase::Move, 85.0), 100.0);
3267        dispatch(&mut fast, &ev(PointerPhase::Up, 85.0), 100.0);
3268        let sim = fast
3269            .ballistic
3270            .as_ref()
3271            .expect("150 px/s clears the bouncing minimum");
3272        assert_close(sim.sim.dx(0.0), 150.0, 1e-9, "…at the release velocity");
3273        assert!(fast.fling.is_none(), "and the legacy fling stays out of it");
3274        assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
3275    }
3276
3277    // --- Pull-to-refresh under both shipped defaults: the trigger is measured
3278    //     on `edge_pull`, which a bouncing surface fills with displacement and
3279    //     a clamping one with boundary-rejected pull. ---
3280
3281    #[test]
3282    fn refresh_trigger_under_the_bouncing_default() {
3283        // 110px of raw pull at zero depth maps to 110 · 0.52 = 57.2, under the
3284        // 64px trigger.
3285        let mut w = observed(true);
3286        let mut state = ScrollLog::default();
3287        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
3288        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
3289        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 200.0), 32.0);
3290        assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
3291        assert_eq!(
3292            w.edge_pull,
3293            w.scroll_info().overscroll,
3294            "a bouncing surface rejects nothing, so the pull IS the displacement"
3295        );
3296        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 200.0), 48.0);
3297        assert_eq!(state.refreshes, 0, "release under the trigger never fires");
3298
3299        // 150px of raw pull maps to 78.0 — past it, so the release fires once.
3300        let mut w = observed(true);
3301        let mut state = ScrollLog::default();
3302        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
3303        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
3304        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 240.0), 32.0);
3305        assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
3306        assert!(crossed_refresh_trigger(w.edge_pull));
3307        assert_eq!(state.refreshes, 0, "no fire before release");
3308        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 240.0), 48.0);
3309        assert_eq!(state.refreshes, 1, "release past the trigger fires once");
3310    }
3311
3312    #[test]
3313    fn refresh_trigger_under_a_clamping_physics() {
3314        // Android's default, simulated on the host: the position never leaves
3315        // range, so the trigger is reached at the RAW pull distance.
3316        let mut w = observed(true);
3317        w.physics = Rc::new(Clamping::new());
3318        let mut state = ScrollLog::default();
3319        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
3320        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
3321        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 140.0), 32.0);
3322        assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
3323        assert_eq!(w.scroll_info().overscroll, 0.0);
3324        assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
3325        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 140.0), 48.0);
3326        assert_eq!(state.refreshes, 0, "50px of raw pull is under the trigger");
3327
3328        let mut w = observed(true);
3329        w.physics = Rc::new(Clamping::new());
3330        let mut state = ScrollLog::default();
3331        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
3332        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
3333        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 190.0), 32.0);
3334        assert_eq!(w.offset(), 0.0);
3335        assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
3336        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 190.0), 48.0);
3337        assert_eq!(state.refreshes, 1, "past 64px of raw pull, it fires once");
3338        // Nothing to spring back (the position never moved), so the rejected
3339        // pull decays on the settle instead — which is what keeps a stretch
3340        // effect from snapping off at release.
3341        assert!(w.settling, "the rejected pull settles rather than springs");
3342        while w.settle_tick(16.0) {}
3343        assert_eq!(w.edge_pull, 0.0);
3344    }
3345
3346    // --- The M3E stretch effect: a paint-side affine about the pulled edge,
3347    //     with the content origin left where an in-range offset puts it. See
3348    //     the module docs' *Overscroll visuals*. ---
3349
3350    /// `Down`, a 40px past-slop takeover drag, then 20px further past the
3351    /// already-at-top edge — the same gesture the overscroll tests above use,
3352    /// so every effect below sees byte-identical input.
3353    fn drag_20px_past_top(w: &mut ScrollWidget) {
3354        dispatch(w, &ev(PointerPhase::Down, 50.0), 0.0);
3355        dispatch(w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
3356        dispatch(w, &ev(PointerPhase::Move, 110.0), 32.0);
3357    }
3358
3359    /// Scroll to the bottom, then drag 60px past that edge.
3360    fn drag_60px_past_bottom(w: &mut ScrollWidget) {
3361        dispatch(w, &scroll(50.0, false, 5000.0), 0.0); // clamp to max_offset
3362        dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
3363        dispatch(w, &ev(PointerPhase::Move, 60.0), 16.0); // takeover
3364        dispatch(w, &ev(PointerPhase::Move, 0.0), 32.0);
3365    }
3366
3367    /// Paint `w` into a recording scene and read back the one transform the
3368    /// stretch pushed, as `(anchor_y, scale_y)` in absolute paint space —
3369    /// painted-scene inspection, no test-only accessor. The affine is
3370    /// `translate(anchor)·scale(1, s)·translate(−anchor)`, whose coefficients
3371    /// are `[1, 0, 0, s, 0, anchor·(1 − s)]`, so both terms read straight back
3372    /// off it. `None` when the paint pushed no transform at all.
3373    fn painted_stretch(w: &mut ScrollWidget) -> Option<(f64, f64)> {
3374        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
3375        let mut scene = crate::test_support::RecordingScene::default();
3376        w.paint(&mut ctx, &mut scene);
3377        assert_eq!(
3378            scene.transforms.len(),
3379            scene.transform_pops as usize,
3380            "every pushed transform must be popped in the same paint"
3381        );
3382        assert!(
3383            scene.transforms.len() <= 1,
3384            "the stretch pushes at most one transform"
3385        );
3386        assert_eq!(scene.rects.len(), 1, "the child paints either way");
3387        let [a, b, c, d, e, f] = scene.transforms.first()?.as_coeffs();
3388        assert_eq!(
3389            [a, b, c, e],
3390            [1.0, 0.0, 0.0, 0.0],
3391            "a scroll-axis-only scale: no x scale, no skew, no x translation"
3392        );
3393        Some((f / (1.0 - d), d))
3394    }
3395
3396    #[test]
3397    fn stretch_intensity_curve_pins() {
3398        // An unpulled surface stretches not at all.
3399        assert_eq!(stretch_intensity(0.0, 100.0), 0.0);
3400
3401        // x = 0.1, hand-computed from the two constants:
3402        //   0.016·0.1 + 0.016·(1 − e^(−0.1·e/0.33))
3403        // = 0.0016   + 0.016·(1 − e^−0.8237217661997105)
3404        // = 0.01057927172144907
3405        assert!(
3406            (stretch_intensity(-10.0, 100.0) - 0.010_579_271_721_449_07).abs() < 1e-9,
3407            "the curve drifted from its pinned constants: {}",
3408            stretch_intensity(-10.0, 100.0)
3409        );
3410        assert_eq!(
3411            stretch_intensity(10.0, 100.0),
3412            stretch_intensity(-10.0, 100.0),
3413            "magnitude-only: the sign picks the anchor, never the amount"
3414        );
3415
3416        // Strictly increasing across the whole normalized range.
3417        let mut previous = 0.0;
3418        for step in 1..=100 {
3419            let intensity = stretch_intensity(step as f64, 100.0);
3420            assert!(
3421                intensity > previous,
3422                "the curve must increase monotonically (step {step}): {intensity} <= {previous}"
3423            );
3424            previous = intensity;
3425        }
3426
3427        // Bounded by the sum of the two terms' ceilings, and clamped past a
3428        // full-viewport pull rather than growing without limit.
3429        assert!(stretch_intensity(100.0, 100.0) <= 2.0 * STRETCH_INTENSITY);
3430        assert_eq!(
3431            stretch_intensity(500.0, 100.0),
3432            stretch_intensity(100.0, 100.0),
3433            "the normalized pull clamps at 1.0"
3434        );
3435        assert_eq!(
3436            stretch_intensity(-10.0, 0.0),
3437            0.0,
3438            "a degenerate viewport stretches nothing"
3439        );
3440    }
3441
3442    #[test]
3443    fn stretch_keeps_child_origin_fixed() {
3444        // The identical drag under each effect. The offset is the physics'
3445        // answer and must not vary; only what paint does with it does.
3446        let mut translate = laid_out_rubber_band(200.0, 100.0, 1000.0);
3447        drag_20px_past_top(&mut translate);
3448        let mut stretch = laid_out_rubber_band(200.0, 100.0, 1000.0);
3449        stretch.effect = OverscrollEffect::Stretch;
3450        drag_20px_past_top(&mut stretch);
3451        let mut none = laid_out_rubber_band(200.0, 100.0, 1000.0);
3452        none.effect = OverscrollEffect::None;
3453        drag_20px_past_top(&mut none);
3454
3455        assert_eq!(stretch.offset(), -10.0, "the resisted overscroll, as ever");
3456        assert_eq!(translate.offset(), stretch.offset());
3457        assert_eq!(none.offset(), stretch.offset());
3458        assert_eq!(translate.edge_pull, stretch.edge_pull);
3459
3460        // Translate paints the displacement into the child origin; Stretch and
3461        // None leave it exactly where an in-range offset would put it.
3462        assert_eq!(translate.child.origin().y, 10.0);
3463        assert_eq!(stretch.child.origin().y, 0.0);
3464        assert_eq!(none.child.origin().y, 0.0);
3465        assert_eq!(
3466            translate.child.origin().y - stretch.child.origin().y,
3467            -stretch.displacement(),
3468            "the two fixtures differ by exactly the overscroll displacement"
3469        );
3470
3471        // …and only Stretch paints a transform for it.
3472        assert_eq!(painted_stretch(&mut translate), None);
3473        assert_eq!(painted_stretch(&mut none), None);
3474        assert!(painted_stretch(&mut stretch).is_some());
3475    }
3476
3477    #[test]
3478    fn stretch_anchor_follows_pulled_edge() {
3479        let mut top = laid_out_rubber_band(200.0, 100.0, 1000.0);
3480        top.effect = OverscrollEffect::Stretch;
3481        drag_20px_past_top(&mut top);
3482        assert_eq!(top.edge_pull, -10.0, "pulled past the top");
3483        let (anchor, scale) = painted_stretch(&mut top).expect("a held pull stretches");
3484        assert!(
3485            anchor.abs() < 1e-9,
3486            "a top pull scales about the viewport's top edge: {anchor}"
3487        );
3488        assert!(
3489            (scale - (1.0 + stretch_intensity(-10.0, 100.0))).abs() < 1e-12,
3490            "scale is 1 + the curve's intensity: {scale}"
3491        );
3492        assert!(scale > 1.0, "the content grows, never shrinks");
3493
3494        let mut bottom = laid_out_rubber_band(200.0, 100.0, 1000.0);
3495        bottom.effect = OverscrollEffect::Stretch;
3496        drag_60px_past_bottom(&mut bottom);
3497        assert_eq!(bottom.edge_pull, 30.0, "pulled past the bottom");
3498        let (anchor, scale) = painted_stretch(&mut bottom).expect("a held pull stretches");
3499        assert!(
3500            (anchor - 100.0).abs() < 1e-9,
3501            "a bottom pull scales about the viewport's bottom edge: {anchor}"
3502        );
3503        assert!((scale - (1.0 + stretch_intensity(30.0, 100.0))).abs() < 1e-12);
3504    }
3505
3506    #[test]
3507    fn stretch_settles_back_to_identity() {
3508        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
3509        w.effect = OverscrollEffect::Stretch;
3510        drag_20px_past_top(&mut w);
3511        let (_, held) = painted_stretch(&mut w).expect("the held pull stretches");
3512
3513        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
3514        assert!(w.settling, "an overscrolled release settles, effect or not");
3515        let (_, releasing) = painted_stretch(&mut w).expect("the settle still stretches");
3516        assert!(
3517            releasing <= held,
3518            "the stretch decays with the pull, never grows: {releasing} > {held}"
3519        );
3520
3521        let mut steps = 0;
3522        while w.settle_tick(16.0) {
3523            steps += 1;
3524            assert!(steps < 10_000, "settle failed to terminate");
3525        }
3526        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
3527        assert_eq!(
3528            painted_stretch(&mut w),
3529            None,
3530            "…so paint pushes no transform at all — back to identity"
3531        );
3532
3533        // And with nothing left to animate, the pump stops asking for frames.
3534        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
3535        let mut scene = crate::test_support::RecordingScene::default();
3536        w.paint(&mut ctx, &mut scene);
3537        assert!(
3538            !ctx.needs_frame(),
3539            "a settled stretch stops requesting frames"
3540        );
3541    }
3542
3543    /// A toy clamping physics: it rejects 100% of any past-edge proposal, so
3544    /// the position never leaves range and the entire pull is reported as
3545    /// boundary rejection instead — the Android-style clamping-plus-stretch
3546    /// pairing, exercised here without depending on any composed default.
3547    #[derive(Debug)]
3548    struct RejectPastEdge;
3549    impl ScrollPhysics for RejectPastEdge {
3550        fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
3551            value - value.clamp(metrics.min_scroll_extent, metrics.max_scroll_extent)
3552        }
3553    }
3554
3555    #[test]
3556    fn stretch_under_boundary_rejection_uses_edge_pull() {
3557        let mut w = laid_out(200.0, 100.0, 1000.0);
3558        w.effect = OverscrollEffect::Stretch;
3559        w.physics = Rc::new(RejectPastEdge);
3560        drag_20px_past_top(&mut w);
3561
3562        assert_eq!(
3563            w.offset(),
3564            0.0,
3565            "a clamping physics never lets the position leave range"
3566        );
3567        assert_eq!(
3568            w.scroll_info().overscroll,
3569            0.0,
3570            "…so there is no displacement for Translate to have shown"
3571        );
3572        // The whole raw 20px is rejected excess (this physics maps the drag
3573        // itself with the trait's identity default — no rubber-band halving).
3574        assert_eq!(w.edge_pull, -20.0, "the pull is still reported in full");
3575
3576        let (anchor, scale) = painted_stretch(&mut w).expect("a rejected pull still stretches");
3577        assert!(
3578            anchor.abs() < 1e-9,
3579            "anchored at the pulled (top) edge: {anchor}"
3580        );
3581        assert!((scale - (1.0 + stretch_intensity(-20.0, 100.0))).abs() < 1e-12);
3582        assert!(scale > 1.0, "clamping + stretch is a visible effect");
3583        assert_eq!(
3584            w.child.origin().y,
3585            0.0,
3586            "and the content itself never moves"
3587        );
3588    }
3589
3590    #[test]
3591    fn clamping_fling_into_the_edge_settles_the_stretch() {
3592        use crate::physics::Tolerance;
3593        use crate::physics::simulation::ClampingScrollSimulation;
3594
3595        // The shipped Android pairing, run on the host: parked 100px short of
3596        // the bottom, released at 1000 px/s straight into it. The clamping
3597        // curve is unbounded (`physics::parity`'s *Why Clamping needs no
3598        // clamped simulation adapter*), so the driver pins the offset and
3599        // routes the whole overshoot into `edge_pull` — the release must not
3600        // leave that pull, and the stretch it paints, standing.
3601        let mut w = laid_out(200.0, 100.0, 1000.0);
3602        w.physics = Rc::new(Clamping::new());
3603        w.effect = OverscrollEffect::Stretch;
3604        dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
3605        assert_eq!(w.offset(), 800.0, "parked 100px short of the 900px bottom");
3606
3607        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3608        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); // 32px > slop → takeover
3609        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
3610        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
3611        let released = w.ballistic.as_ref().expect("a clamping release flings");
3612        assert_close(released.sim.dx(0.0), 1000.0, 1e-9, "the release velocity");
3613
3614        // The same curve, spelled out: it runs far past the extent, which is
3615        // what makes the pull it feeds `edge_pull` a real one.
3616        let curve = ClampingScrollSimulation::new(
3617            800.0,
3618            1000.0,
3619            ClampingScrollSimulation::DEFAULT_FRICTION,
3620            Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
3621        );
3622        assert!(
3623            curve.final_x() > 1000.0,
3624            "the spline must overshoot the 900px extent by >100px: {}",
3625            curve.final_x()
3626        );
3627        let spline_frames = (curve.duration() * 1000.0 / 16.0).ceil();
3628
3629        // Pump the shared frame clock until nothing asks for another frame.
3630        let mut ms = 100.0;
3631        let mut frames = 0.0;
3632        let mut ballistic_frames = 0.0;
3633        let mut peak = 0.0f64;
3634        loop {
3635            let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
3636            w.pump_fling(&mut ctx);
3637            peak = peak.max(w.edge_pull.abs());
3638            if w.ballistic.is_some() {
3639                ballistic_frames += 1.0;
3640            }
3641            ms += 16.0;
3642            frames += 1.0;
3643            assert!(
3644                frames < 2_000.0,
3645                "the release never came to rest: edge_pull {}",
3646                w.edge_pull
3647            );
3648            if !ctx.needs_frame() {
3649                break;
3650            }
3651        }
3652
3653        assert!(
3654            peak > 1.0,
3655            "the fling must actually reach the edge for this to mean anything: {peak}"
3656        );
3657        assert_close(
3658            w.offset(),
3659            900.0,
3660            1e-9,
3661            "the offset ends pinned at the edge",
3662        );
3663        assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
3664        assert_eq!(
3665            painted_stretch(&mut w),
3666            None,
3667            "…so paint pushes no transform at all — back to identity"
3668        );
3669
3670        // And the simulation itself stops the moment it is pinned outward,
3671        // rather than pumping dead frames for the rest of the spline.
3672        assert!(
3673            ballistic_frames < spline_frames / 2.0,
3674            "the pinned curve ran {ballistic_frames} frames of a {spline_frames}-frame spline"
3675        );
3676    }
3677
3678    // --- Nested scrolling: innermost-wins arbitration. See the module docs'
3679    //     *Nested scrolling*. ---
3680
3681    /// Which layer of a nested fixture an observation came from — an index into
3682    /// [`Nest`]'s per-layer logs, so one builder serves every depth.
3683    const OUTER: usize = 0;
3684    /// The middle layer of the three-deep fixture.
3685    const MIDDLE: usize = 1;
3686    /// The innermost scroll surface of a nested fixture.
3687    const INNER: usize = 2;
3688
3689    /// What each layer of a nested fixture observed, by layer index.
3690    #[derive(Default)]
3691    struct Nest {
3692        /// Every `ScrollInfo` a layer reported through `on_scroll`.
3693        scrolls: [Vec<ScrollInfo>; 3],
3694        /// How many times a layer's pull-to-refresh fired.
3695        refreshes: [u32; 3],
3696    }
3697
3698    /// The pointer phases the deepest, non-scrollable content saw — how a
3699    /// `Cancel` is attributed to whichever surface sent it.
3700    #[derive(Clone, Copy, Default)]
3701    struct ContentSeen {
3702        downs: u32,
3703        cancels: u32,
3704    }
3705
3706    /// 1000px of ordinary, non-scrollable content tallying what reaches it.
3707    ///
3708    /// Counted through an `Rc<Cell<_>>` rather than `EventCtx::state_mut`
3709    /// because a `Cancel` arm never touches state
3710    /// (`docs/CODE_STANDARDS.md`'s Interaction Semantics) — and `Cancel`s are
3711    /// exactly what this probe exists to count.
3712    struct NestContent(Rc<Cell<ContentSeen>>);
3713    /// Retained widget for [`NestContent`].
3714    struct NestContentW(Rc<Cell<ContentSeen>>);
3715
3716    /// A [`NestContent`] tallying into `seen`.
3717    fn nest_content(seen: &Rc<Cell<ContentSeen>>) -> NestContent {
3718        NestContent(Rc::clone(seen))
3719    }
3720
3721    /// A [`NestContent`] whose tally nobody reads — the placeholder child a
3722    /// layer is built with before [`nest`] wires the real nested surface into
3723    /// its place. Its 1000px height is what gives that layer its content
3724    /// extent, so the placeholder is load-bearing even after the swap.
3725    fn spacer_content() -> NestContent {
3726        NestContent(Rc::new(Cell::new(ContentSeen::default())))
3727    }
3728
3729    impl View<Nest> for NestContent {
3730        type Element = NestContentW;
3731        fn build(&self, _c: &mut BuildCtx<'_>) -> NestContentW {
3732            NestContentW(Rc::clone(&self.0))
3733        }
3734        fn rebuild(&self, _p: &Self, _e: &mut NestContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
3735            ChangeFlags::NONE
3736        }
3737    }
3738
3739    impl Widget for NestContentW {
3740        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3741            bc.constrain(Size::new(200.0, 1000.0))
3742        }
3743        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
3744        fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
3745            if let InputEvent::Pointer(p) = e {
3746                let mut seen = self.0.get();
3747                match p.phase {
3748                    PointerPhase::Down => seen.downs += 1,
3749                    PointerPhase::Cancel => seen.cancels += 1,
3750                    _ => {}
3751                }
3752                self.0.set(seen);
3753            }
3754            EventResult::Ignored
3755        }
3756    }
3757
3758    /// Build and lay out one layer of a nested fixture: a `viewport_h`-tall
3759    /// viewport over `child`, reporting scrolls and refreshes under `layer`.
3760    ///
3761    /// Laid out **tight** rather than by an enclosing surface, because a
3762    /// `ScrollView` hands its child *unbounded* height — a nested scroll
3763    /// surface laid out that way sizes its viewport to its own content and has
3764    /// nothing left to scroll. A real tree bounds it (a `SizedBox`, a list
3765    /// row's own extent); the fixture states the resulting geometry directly
3766    /// rather than threading a third widget through every assertion.
3767    fn nest_surface(child: impl View<Nest>, viewport_h: f64, layer: usize) -> ScrollWidget {
3768        let view: ScrollView<Nest> = scroll_view(child)
3769            .on_scroll(move |s: &mut Nest, info| s.scrolls[layer].push(info))
3770            .on_refresh_release(move |s: &mut Nest| s.refreshes[layer] += 1);
3771        let mut counter = 0u64;
3772        let mut w = View::<Nest>::build(&view, &mut BuildCtx::new(&mut counter));
3773        let mut lctx = LayoutCtx::new();
3774        w.layout(
3775            &mut lctx,
3776            &BoxConstraints::tight(Size::new(200.0, viewport_h)),
3777        );
3778        w
3779    }
3780
3781    /// [`nest_surface`] with the pre-seam [`RubberBand`] feel pinned
3782    /// explicitly — the variant the nested tests that assert a `0.5`-resisted
3783    /// number build their feel-carrying layer from (see
3784    /// [`laid_out_rubber_band`]); the arbitration itself is physics-agnostic,
3785    /// so every other layer stays on the platform default.
3786    fn nest_surface_rubber_band(
3787        child: impl View<Nest>,
3788        viewport_h: f64,
3789        layer: usize,
3790    ) -> ScrollWidget {
3791        let mut w = nest_surface(child, viewport_h, layer);
3792        w.physics = Rc::new(RubberBand::new());
3793        w
3794    }
3795
3796    /// Wire `inner` in as `outer`'s single child — the nesting a real tree
3797    /// builds through a bounded-height wrapper (see [`nest_surface`]).
3798    fn nest(outer: &mut ScrollWidget, inner: ScrollWidget) {
3799        outer.child = ChildPod::new(Box::new(inner));
3800    }
3801
3802    /// The nested surface [`nest`] wired under `outer` (single-boxed, unlike an
3803    /// `AnyView`-erased pod).
3804    fn nested_of(outer: &ScrollWidget) -> &ScrollWidget {
3805        (outer.child.widget() as &dyn Any)
3806            .downcast_ref::<ScrollWidget>()
3807            .expect("the fixture wired a ScrollWidget child")
3808    }
3809
3810    /// The nested `ListView` wired under `outer`.
3811    fn nested_list_of(outer: &ScrollWidget) -> &crate::list_view::ListViewWidget {
3812        (outer.child.widget() as &dyn Any)
3813            .downcast_ref::<crate::list_view::ListViewWidget>()
3814            .expect("the fixture wired a ListViewWidget child")
3815    }
3816
3817    /// Build and lay out a nested `ListView` — 10 rows of 100px in a
3818    /// `viewport_h`-tall viewport, tight for the same reason
3819    /// [`nest_surface`] is.
3820    fn nested_list(viewport_h: f64) -> crate::list_view::ListViewWidget {
3821        let view: crate::list_view::ListView<Nest> =
3822            crate::list_view::list_view(10, 100.0, |_| spacer_content());
3823        let mut counter = 0u64;
3824        let mut w = View::<Nest>::build(&view, &mut BuildCtx::new(&mut counter));
3825        let mut lctx = LayoutCtx::new();
3826        w.layout(
3827            &mut lctx,
3828            &BoxConstraints::tight(Size::new(200.0, viewport_h)),
3829        );
3830        w
3831    }
3832
3833    fn run_nest(w: &mut ScrollWidget, state: &mut Nest, e: &InputEvent, t: f64) {
3834        let sa: &mut dyn Any = state;
3835        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
3836        w.event_at(&mut ctx, e, t);
3837    }
3838
3839    /// Park a surface at `offset` px with a wheel scroll (hard-clamped, no
3840    /// overscroll and no gesture state) — how a real surface reaches a
3841    /// mid-content position.
3842    fn park(w: &mut dyn Widget, viewport_h: f64, offset: f64) {
3843        let mut throwaway = Nest::default();
3844        let sa: &mut dyn Any = &mut throwaway;
3845        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
3846        w.event(&mut ctx, &scroll(50.0, false, offset));
3847    }
3848
3849    #[test]
3850    fn outer_defers_when_inner_can_consume() {
3851        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3852        let seen = Rc::new(Cell::new(ContentSeen::default()));
3853        let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
3854        // Mid-content, at neither edge: the inner's claim comes from actual
3855        // room, not from a displacement-allowing physics.
3856        park(&mut inner, 120.0, 400.0);
3857        assert_eq!(inner.offset(), 400.0);
3858        nest(&mut outer, inner);
3859
3860        let mut state = Nest::default();
3861        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
3862        assert!(
3863            outer.inner_at_down.registered,
3864            "the nested surface reported itself on the forwarded Down"
3865        );
3866        assert!(outer.inner_at_down.can_consume_up_drag);
3867        assert_eq!(seen.get().downs, 1, "the Down still reached the content");
3868
3869        // 50px of finger-up drag, past the slop: the outer stands down.
3870        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
3871        assert!(outer.deferring, "the outer deferred to the nested surface");
3872        assert!(!outer.scrolling);
3873        assert_eq!(outer.offset(), 0.0, "…and never moved");
3874        assert!(state.scrolls[OUTER].is_empty(), "…nor reported a scroll");
3875        // The one Cancel the content saw came from the INNER's own takeover —
3876        // the outer sent none, and it is the inner that is now scrolling.
3877        assert!(nested_of(&outer).scrolling, "the inner took the gesture");
3878        assert_eq!(seen.get().cancels, 1);
3879
3880        // The rest of the drag lands in the inner, still never in the outer.
3881        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
3882        assert_eq!(
3883            nested_of(&outer).offset(),
3884            440.0,
3885            "the inner consumed the 40px"
3886        );
3887        assert_eq!(outer.offset(), 0.0);
3888        assert!(state.scrolls[OUTER].is_empty());
3889        assert!(!state.scrolls[INNER].is_empty(), "the inner reported it");
3890        assert_eq!(seen.get().cancels, 1, "no second Cancel from anywhere");
3891    }
3892
3893    #[test]
3894    fn outer_takes_over_when_inner_pinned() {
3895        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3896        let seen = Rc::new(Cell::new(ContentSeen::default()));
3897        let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
3898        // At its top under a physics that rejects every past-edge proposal:
3899        // there is nothing a downward drag can do here.
3900        inner.physics = Rc::new(Clamping::new());
3901        nest(&mut outer, inner);
3902
3903        let mut state = Nest::default();
3904        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3905        assert!(
3906            outer.inner_at_down.registered,
3907            "the inner still reports itself…"
3908        );
3909        assert!(
3910            !outer.inner_at_down.can_consume_down_drag,
3911            "…pinned against a downward drag"
3912        );
3913        assert!(
3914            outer.inner_at_down.can_consume_up_drag,
3915            "…though not against an upward one"
3916        );
3917
3918        // 40px down, past the slop: the outer takes over exactly as ever.
3919        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3920        assert!(outer.scrolling);
3921        assert!(!outer.deferring);
3922        assert_eq!(
3923            seen.get().cancels,
3924            1,
3925            "the outer's takeover Cancel reached the content through the inner"
3926        );
3927        assert_eq!(
3928            outer.offset(),
3929            0.0,
3930            "the takeover move does not itself scroll"
3931        );
3932
3933        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3934        assert_eq!(
3935            outer.offset(),
3936            -10.0,
3937            "the resisted 20px past-top overscroll, as ever"
3938        );
3939        assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
3940        assert!(state.scrolls[INNER].is_empty());
3941    }
3942
3943    #[test]
3944    fn bouncing_inner_wins_even_at_edge() {
3945        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3946        let seen = Rc::new(Cell::new(ContentSeen::default()));
3947        // A `RubberBand` inner at the very top: like the bouncing default it
3948        // rejects nothing, so it can still answer a downward pull with a
3949        // rubber-band — and its resisted number is the one pinned below.
3950        let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
3951        nest(&mut outer, inner);
3952
3953        let mut state = Nest::default();
3954        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3955        assert!(
3956            outer.inner_at_down.can_consume_down_drag,
3957            "a displacement-allowing physics claims even pinned at the top"
3958        );
3959
3960        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3961        assert!(
3962            outer.deferring,
3963            "the outer defers even though the inner sits at offset 0"
3964        );
3965        assert!(nested_of(&outer).scrolling);
3966        assert_eq!(seen.get().cancels, 1, "the inner's own takeover Cancel");
3967
3968        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3969        assert_eq!(
3970            nested_of(&outer).offset(),
3971            -10.0,
3972            "the inner rubber-bands past its own top"
3973        );
3974        assert_eq!(outer.offset(), 0.0, "and the outer stays exactly put");
3975        assert!(state.scrolls[OUTER].is_empty());
3976    }
3977
3978    #[test]
3979    fn content_fits_inner_does_not_steal_the_drag() {
3980        // A bouncing-family inner whose content exactly fills its viewport —
3981        // `max_scroll_extent == min_scroll_extent`, nothing to scroll either
3982        // way — under the platform default (no `.physics(...)` override).
3983        // `should_accept_user_offset` is hardcoded `true` for the whole
3984        // bouncing family, so without a capacity conjunct in
3985        // `inner_claim_state` this would register and defer forever; the
3986        // outer must still win the drag (`inner_claim_state`'s doc comment,
3987        // the module docs' *Nested scrolling*). The outer runs `RubberBand`
3988        // (like `outer_takes_over_when_inner_pinned`) so its resisted number
3989        // is the deterministic one pinned below rather than the default's
3990        // progressive depth curve.
3991        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3992        let seen = Rc::new(Cell::new(ContentSeen::default()));
3993        // 1000px viewport over the fixture's fixed 1000px content: an exact
3994        // fit, so `max_offset() == 0.0`.
3995        let inner = nest_surface(nest_content(&seen), 1000.0, INNER);
3996        assert_eq!(
3997            inner.max_offset(),
3998            0.0,
3999            "the fixture's content exactly fits"
4000        );
4001        nest(&mut outer, inner);
4002
4003        let mut state = Nest::default();
4004        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
4005        assert!(
4006            !outer.inner_at_down.registered,
4007            "no real capacity to scroll, so the claim never registers"
4008        );
4009
4010        // 40px down, past the slop: the outer takes over exactly as the
4011        // no-nested-scrollable case always has.
4012        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
4013        assert!(outer.scrolling, "the outer takes the drag over");
4014        assert!(!outer.deferring);
4015        assert_eq!(
4016            seen.get().cancels,
4017            1,
4018            "the outer's takeover Cancel reached the content through the inner"
4019        );
4020
4021        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
4022        assert_eq!(
4023            outer.offset(),
4024            -10.0,
4025            "the resisted 20px past-top overscroll, same as a pinned-inner takeover"
4026        );
4027        assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
4028        assert_eq!(nested_of(&outer).edge_pull, 0.0, "…nor accrued any pull");
4029        assert!(state.scrolls[INNER].is_empty(), "the inner saw nothing");
4030    }
4031
4032    #[test]
4033    fn no_inner_behavior_identical() {
4034        // The pre-existing takeover, unchanged with arbitration in place: a
4035        // plain non-scrollable child registers nothing, so nothing defers.
4036        // Viewport 100 over 1000px of content — `laid_out(200, 100, 1000)`'s
4037        // geometry, so the numbers below are the shipped ones.
4038        let seen = Rc::new(Cell::new(ContentSeen::default()));
4039        let mut w = nest_surface(nest_content(&seen), 100.0, OUTER);
4040        assert_eq!(w.max_offset(), 900.0);
4041        let mut state = Nest::default();
4042
4043        run_nest(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
4044        assert!(!w.inner_at_down.registered, "a plain child claims nothing");
4045        assert_eq!(seen.get().downs, 1);
4046
4047        run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
4048        assert!(w.scrolling, "the slop still takes the gesture over");
4049        assert!(!w.deferring);
4050        assert_eq!(seen.get().cancels, 1, "exactly one child Cancel, as before");
4051        assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
4052
4053        run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
4054        assert_eq!(
4055            w.offset(),
4056            30.0,
4057            "the 30px `drag_past_slop_scrolls_the_offset` pins"
4058        );
4059        assert_eq!(seen.get().cancels, 1, "no further move reaches the child");
4060    }
4061
4062    #[test]
4063    fn three_deep_nesting_pairs_nearest() {
4064        let seen = Rc::new(Cell::new(ContentSeen::default()));
4065        let innermost = nest_surface(nest_content(&seen), 80.0, INNER);
4066        let mut middle = nest_surface(spacer_content(), 140.0, MIDDLE);
4067        nest(&mut middle, innermost);
4068        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
4069        nest(&mut outer, middle);
4070
4071        let mut state = Nest::default();
4072        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 75.0), 0.0);
4073        // Each level learns about the level it actually contains and no
4074        // further: the innermost's own child is plain content, and nothing
4075        // propagated its (absent) claim up past the middle.
4076        assert!(outer.inner_at_down.registered, "outer sees the middle");
4077        assert!(
4078            nested_of(&outer).inner_at_down.registered,
4079            "middle sees the innermost"
4080        );
4081        assert!(
4082            !nested_of(nested_of(&outer)).inner_at_down.registered,
4083            "the innermost sees no scrollable below it"
4084        );
4085
4086        // A finger-up drag every layer could consume: the innermost gets it.
4087        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 35.0), 16.0);
4088        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 5.0), 32.0);
4089        assert!(outer.deferring && nested_of(&outer).deferring);
4090        assert!(!nested_of(nested_of(&outer)).deferring);
4091        assert!(nested_of(nested_of(&outer)).scrolling);
4092        assert_eq!(outer.offset(), 0.0, "the outermost never moved");
4093        assert_eq!(nested_of(&outer).offset(), 0.0, "nor the middle");
4094        assert_eq!(
4095            nested_of(nested_of(&outer)).offset(),
4096            30.0,
4097            "only the innermost took the drag"
4098        );
4099        assert!(state.scrolls[OUTER].is_empty() && state.scrolls[MIDDLE].is_empty());
4100        assert!(!state.scrolls[INNER].is_empty());
4101    }
4102
4103    #[test]
4104    fn up_and_cancel_still_reach_child_when_deferring() {
4105        // The device-gate case end to end: a refresh surface owning its own
4106        // scroll, under a page-level scroll. The outer must forward the whole
4107        // gesture — including the release that fires the refresh.
4108        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
4109        let seen = Rc::new(Cell::new(ContentSeen::default()));
4110        let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
4111        nest(&mut outer, inner);
4112
4113        let mut state = Nest::default();
4114        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
4115        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
4116        assert!(
4117            outer.deferring,
4118            "the inner can rubber-band, so the outer defers"
4119        );
4120        // Pull the inner well past its own refresh trigger (150px raw, halved
4121        // by the rubber-band resistance to 75 > REFRESH_TRIGGER_PX).
4122        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 210.0), 32.0);
4123        assert_eq!(nested_of(&outer).offset(), -75.0);
4124        assert!(crossed_refresh_trigger(nested_of(&outer).edge_pull));
4125
4126        run_nest(&mut outer, &mut state, &ev(PointerPhase::Up, 210.0), 48.0);
4127        assert_eq!(
4128            state.refreshes[INNER], 1,
4129            "the forwarded Up fired the INNER's refresh exactly once"
4130        );
4131        assert_eq!(state.refreshes[OUTER], 0, "and never the outer's");
4132        assert_eq!(outer.offset(), 0.0, "the outer never scrolled at all");
4133        assert!(state.scrolls[OUTER].is_empty());
4134        // The Up clears the arbitration state, so the next gesture arbitrates
4135        // from scratch rather than inheriting this one's answer.
4136        assert!(!outer.deferring);
4137        assert!(!outer.inner_at_down.registered);
4138        assert!(!outer.scrolling && !outer.down_active);
4139
4140        // A second gesture, cancelled mid-drag: the Cancel is forwarded too.
4141        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 64.0);
4142        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 80.0);
4143        assert!(outer.deferring, "still the inner's gesture");
4144        run_nest(
4145            &mut outer,
4146            &mut state,
4147            &ev(PointerPhase::Cancel, 60.0),
4148            96.0,
4149        );
4150        // Three Cancels all told: the inner's takeover in each of the two
4151        // gestures, plus this forwarded one reaching the content through it.
4152        assert_eq!(seen.get().cancels, 3);
4153        assert_eq!(state.refreshes[INNER], 1, "a Cancel never fires a refresh");
4154        assert!(
4155            !outer.deferring,
4156            "the Cancel clears the arbitration state too"
4157        );
4158        assert!(!outer.inner_at_down.registered);
4159    }
4160
4161    #[test]
4162    fn a_nested_list_view_wins_the_drag_from_a_scroll_view() {
4163        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
4164        let mut list = nested_list(150.0);
4165        park(&mut list, 150.0, 300.0);
4166        assert_eq!(list.offset(), 300.0);
4167        outer.child = ChildPod::new(Box::new(list));
4168
4169        let mut state = Nest::default();
4170        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
4171        assert!(
4172            outer.inner_at_down.registered,
4173            "a nested ListView reports itself on the same seam"
4174        );
4175        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
4176        assert!(outer.deferring);
4177        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
4178        assert_eq!(outer.offset(), 0.0, "the outer never moved");
4179        assert!(state.scrolls[OUTER].is_empty());
4180        assert_eq!(
4181            nested_list_of(&outer).offset(),
4182            340.0,
4183            "the nested list took the 40px"
4184        );
4185    }
4186
4187    #[test]
4188    fn a_pinned_nested_list_view_hands_the_drag_back_to_the_scroll_view() {
4189        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
4190        let mut list = nested_list(150.0);
4191        list.physics = Rc::new(Clamping::new());
4192        outer.child = ChildPod::new(Box::new(list));
4193
4194        let mut state = Nest::default();
4195        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
4196        assert!(
4197            !outer.inner_at_down.can_consume_down_drag,
4198            "the nested list is pinned at its own top"
4199        );
4200        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
4201        assert!(outer.scrolling, "so the outer takes over as it always has");
4202        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
4203        assert_eq!(
4204            outer.offset(),
4205            -10.0,
4206            "the resisted 20px past-top overscroll"
4207        );
4208        assert_eq!(
4209            nested_list_of(&outer).offset(),
4210            0.0,
4211            "the nested list never moved"
4212        );
4213    }
4214
4215    #[test]
4216    fn a_content_fits_nested_list_view_does_not_steal_the_drag() {
4217        // The `ListView` twin of `content_fits_inner_does_not_steal_the_drag`:
4218        // both widgets route the claim through the same shared
4219        // `inner_claim_state`, so this pins that the capacity conjunct
4220        // applies here too rather than being a `ScrollWidget`-only fix.
4221        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
4222        // 10 rows of 100px in a 1000px viewport: an exact fit.
4223        let list = nested_list(1000.0);
4224        assert_eq!(list.max_offset(), 0.0, "the fixture's content exactly fits");
4225        outer.child = ChildPod::new(Box::new(list));
4226
4227        let mut state = Nest::default();
4228        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
4229        assert!(
4230            !outer.inner_at_down.registered,
4231            "no real capacity to scroll, so the claim never registers"
4232        );
4233
4234        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
4235        assert!(outer.scrolling, "the outer takes the drag over");
4236        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
4237        assert_eq!(
4238            outer.offset(),
4239            -10.0,
4240            "the resisted 20px past-top overscroll, same as a pinned-inner takeover"
4241        );
4242        assert_eq!(
4243            nested_list_of(&outer).offset(),
4244            0.0,
4245            "the nested list never moved"
4246        );
4247    }
4248
4249    // --- (07) The public builder surface: `.physics(...)`/`.overscroll_effect(...)` ---
4250
4251    #[test]
4252    fn physics_builder_installs_custom_physics() {
4253        let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
4254        let mut counter = 0u64;
4255        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
4256        let mut lctx = LayoutCtx::new();
4257        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
4258
4259        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4260        // Same slop-crossing shape as `drag_past_slop_scrolls_the_offset`, but
4261        // `NeverScrollable` refuses the drag outright.
4262        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
4263        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
4264        assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
4265        assert!(
4266            !w.scrolling,
4267            "NeverScrollable must never take the gesture over"
4268        );
4269
4270        // A default-built twin (no `.physics(...)` call) still scrolls normally
4271        // under `RubberBand` — proving the builder, not some global default
4272        // change, is what reached the widget above.
4273        let mut default_w = laid_out(200.0, 100.0, 1000.0);
4274        dispatch(&mut default_w, &ev(PointerPhase::Down, 100.0), 0.0);
4275        dispatch(&mut default_w, &ev(PointerPhase::Move, 70.0), 16.0);
4276        dispatch(&mut default_w, &ev(PointerPhase::Move, 40.0), 32.0);
4277        assert_eq!(
4278            default_w.offset(),
4279            30.0,
4280            "the default twin scrolls normally"
4281        );
4282    }
4283
4284    #[test]
4285    fn effect_builder_reaches_widget() {
4286        let view: ScrollView<()> =
4287            scroll_view(leaf(200.0, 1000.0)).overscroll_effect(OverscrollEffect::Stretch);
4288        let mut counter = 0u64;
4289        let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
4290        assert_eq!(w.effect, OverscrollEffect::Stretch);
4291
4292        // A default-built twin takes the platform pairing instead — asserted
4293        // against the selector rather than a literal, so this reads the same
4294        // on the Android arm (Stretch) as on this one (Translate).
4295        let default_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
4296        let default_w = View::<()>::build(&default_view, &mut BuildCtx::new(&mut counter));
4297        assert_eq!(
4298            default_w.effect,
4299            crate::physics::default_overscroll_effect()
4300        );
4301        // …which on this host is translate overscroll; the Android arm is
4302        // pinned beside the selector itself, in `physics`' own tests.
4303        #[cfg(not(target_os = "android"))]
4304        assert_eq!(default_w.effect, OverscrollEffect::Translate);
4305    }
4306
4307    #[test]
4308    fn rebuild_preserves_builder_physics() {
4309        let physics_view: ScrollView<()> =
4310            scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
4311        let mut counter = 0u64;
4312        let mut w = View::<()>::build(&physics_view, &mut BuildCtx::new(&mut counter));
4313        let mut lctx = LayoutCtx::new();
4314        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
4315
4316        // Rebuilding against an identical `.physics(...)`-carrying view
4317        // reinstalls it (unconditionally, like the erased callbacks) — still
4318        // refuses the drag.
4319        View::<()>::rebuild(
4320            &physics_view,
4321            &physics_view,
4322            &mut w,
4323            &mut BuildCtx::new(&mut counter),
4324        );
4325        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4326        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
4327        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
4328        assert_eq!(
4329            w.offset(),
4330            0.0,
4331            "still installed after a re-asserting rebuild"
4332        );
4333        assert!(!w.scrolling);
4334        // Clear the armed gesture before the next rebuild's own probe.
4335        dispatch(&mut w, &ev(PointerPhase::Cancel, 40.0), 48.0);
4336
4337        // Rebuilding against a view with no `.physics(...)` call at all leaves
4338        // the widget's currently-installed physics untouched — it does not
4339        // revert to the `RubberBand` default.
4340        let plain_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
4341        View::<()>::rebuild(
4342            &plain_view,
4343            &physics_view,
4344            &mut w,
4345            &mut BuildCtx::new(&mut counter),
4346        );
4347        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4348        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
4349        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
4350        assert_eq!(
4351            w.offset(),
4352            0.0,
4353            "a rebuild whose view carries no .physics(...) leaves the widget's physics untouched"
4354        );
4355        assert!(!w.scrolling);
4356    }
4357
4358    /// A focused editable inside the viewport must see a clipboard verb: an
4359    /// `EditCommand` is focus-routed, so it takes the same bypass `Key`/`Ime`
4360    /// take rather than the gesture machinery (and never a hit test).
4361    #[test]
4362    fn an_edit_command_reaches_the_focused_child() {
4363        use crate::text_input;
4364        use frust_core::{EditCommand, RenderRoot};
4365
4366        struct Field {
4367            value: String,
4368        }
4369        fn logic(state: &mut Field) -> ScrollView<Field> {
4370            scroll_view(text_input(
4371                state.value.clone(),
4372                |s: &mut Field, v: String| {
4373                    s.value = v;
4374                },
4375            ))
4376        }
4377
4378        let mut state = Field {
4379            value: "hello".to_string(),
4380        };
4381        let mut root: RenderRoot<Field, ScrollView<Field>> = RenderRoot::new();
4382        root.rebuild(&mut logic, &mut state);
4383        root.layout(Size::new(200.0, 100.0));
4384
4385        // Tap the field through the viewport so it holds the recorded focus path.
4386        root.event(&mut state, &ev(PointerPhase::Down, 10.0));
4387        root.event(&mut state, &ev(PointerPhase::Up, 10.0));
4388        assert!(root.is_focus_active(), "the tap focused the child field");
4389
4390        root.event(&mut state, &InputEvent::EditCommand(EditCommand::SelectAll));
4391        root.event(&mut state, &InputEvent::EditCommand(EditCommand::Copy));
4392
4393        assert_eq!(
4394            root.take_clipboard_write().as_deref(),
4395            Some("hello"),
4396            "the copy was answered by the child, through this router"
4397        );
4398        assert_eq!(state.value, "hello", "a copy edits nothing");
4399    }
4400}
4401
4402/// A scroll view enclosing a multi-contact recognizer, driven through a real
4403/// `RenderRoot`: the second finger reaches only the widget that opted into it,
4404/// a takeover ends that opt-in, and a hit-tested `Scale` keeps its result.
4405#[cfg(test)]
4406mod contact_tests {
4407    use super::*;
4408    use crate::{PanZoomTransform, pan_zoom, pinch_detector};
4409    use frust_core::RenderRoot;
4410    use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
4411    use std::any::Any;
4412
4413    #[derive(Default)]
4414    struct App {
4415        transforms: Vec<PanZoomTransform>,
4416        scales: Vec<ScaleEvent>,
4417    }
4418
4419    type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
4420
4421    /// A 1000-px-tall content leaf. Logs every pointer event it receives (into
4422    /// a shared log, never app state, so its `Cancel` arm stays state-free).
4423    /// With `grabs` it captures every primary `Down` and handles it — opting
4424    /// into the gesture's other contacts too with `opt_in` — and otherwise
4425    /// ignores pointers. With `consumes` it reports `Handled` for a `Scale`
4426    /// and for a broadcast; otherwise it ignores both.
4427    #[derive(Clone)]
4428    struct Tall {
4429        seen: Seen,
4430        grabs: bool,
4431        opt_in: bool,
4432        consumes: bool,
4433    }
4434    struct TallWidget(Tall);
4435    impl View<App> for Tall {
4436        type Element = TallWidget;
4437        fn build(&self, _ctx: &mut BuildCtx<'_>) -> TallWidget {
4438            TallWidget(self.clone())
4439        }
4440        fn rebuild(&self, _p: &Self, _e: &mut TallWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
4441            ChangeFlags::NONE
4442        }
4443    }
4444    impl Widget for TallWidget {
4445        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
4446            bc.constrain(Size::new(400.0, 1000.0))
4447        }
4448        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
4449        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
4450            match event {
4451                InputEvent::Pointer(p) => {
4452                    self.0.seen.borrow_mut().push((ctx.pointer_id(), p.phase));
4453                    if !self.0.grabs {
4454                        return EventResult::Ignored;
4455                    }
4456                    if p.phase == PointerPhase::Down {
4457                        ctx.capture_pointer();
4458                        if self.0.opt_in {
4459                            ctx.capture_contacts();
4460                        }
4461                    }
4462                    EventResult::Handled
4463                }
4464                InputEvent::Scale(_) | InputEvent::Housekeeping if self.0.consumes => {
4465                    EventResult::Handled
4466                }
4467                _ => EventResult::Ignored,
4468            }
4469        }
4470    }
4471
4472    fn tall(grabs: bool, opt_in: bool, consumes: bool) -> (Tall, Seen) {
4473        let seen = Seen::default();
4474        let view = Tall {
4475            seen: seen.clone(),
4476            grabs,
4477            opt_in,
4478            consumes,
4479        };
4480        (view, seen)
4481    }
4482
4483    struct NullScene;
4484    impl PaintScene for NullScene {
4485        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
4486        fn draw_text(&mut self, _o: Point, _t: &str) {}
4487    }
4488
4489    fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
4490        InputEvent::PointerContact {
4491            pointer_id: PointerId::touch(slot),
4492            event: PointerEvent {
4493                phase,
4494                position: Point::new(x, y),
4495                button: PointerButton::Primary,
4496            },
4497        }
4498    }
4499
4500    /// A root over `logic`, laid out in a 400 × 300 window.
4501    fn root_over<V: View<App>>(logic: impl Fn() -> V + 'static) -> (RenderRoot<App, V>, App) {
4502        let mut root: RenderRoot<App, V> = RenderRoot::new();
4503        let mut state = App::default();
4504        root.rebuild(&mut move |_: &mut App| logic(), &mut state);
4505        root.layout(Size::new(400.0, 300.0));
4506        (root, state)
4507    }
4508
4509    fn viewport(root: &RenderRoot<App, ScrollView<App>>) -> &ScrollWidget {
4510        let id = root.root_id().expect("root built");
4511        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
4512            .downcast_ref::<ScrollWidget>()
4513            .expect("root is a ScrollWidget")
4514    }
4515
4516    #[test]
4517    fn a_second_finger_never_rearms_the_viewport_around_a_pan_zoom() {
4518        use PointerPhase::{Down, Move};
4519        let (content, seen) = tall(false, false, false);
4520        let (mut root, mut state) = root_over(move || {
4521            scroll_view(
4522                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4523            )
4524        });
4525        let t0 = PointerId::touch(0);
4526        // Two fingers stacked vertically, then the first one pans sideways —
4527        // no vertical travel at all for the claimant. A viewport that took the
4528        // second finger's `Down` as its own would measure this move against
4529        // *that* finger's position, cross its slop, and steal the gesture.
4530        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4531        assert_eq!(root.pointer_capture_claimant(), Some(t0));
4532        assert!(root.pointer_capture_contacts(), "pan_zoom opted in");
4533        root.event(&mut state, &touch(1, Down, 100.0, 250.0));
4534        root.event(&mut state, &touch(0, Move, 110.0, 100.0));
4535        root.event(&mut state, &touch(0, Move, 120.0, 100.0));
4536
4537        let scroll = viewport(&root);
4538        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4539        assert!(!scroll.scrolling, "and never took the gesture over");
4540        assert_eq!(
4541            scroll.down_start,
4542            Point::new(100.0, 100.0),
4543            "its drag is still anchored on the claimant"
4544        );
4545        assert_eq!(
4546            state.transforms.last().map(|t| t.offset),
4547            Some(kurbo::Vec2::new(20.0, 0.0)),
4548            "pan_zoom kept receiving the claimant's moves — it was never cancelled"
4549        );
4550        assert!(
4551            !seen
4552                .borrow()
4553                .iter()
4554                .any(|(_, phase)| *phase == PointerPhase::Cancel)
4555        );
4556        assert_eq!(root.pointer_capture_claimant(), Some(t0));
4557        assert!(
4558            root.pointer_capture_contacts(),
4559            "still routing the second finger to pan_zoom"
4560        );
4561    }
4562
4563    #[test]
4564    fn a_pinch_inside_a_viewport_reports_its_scale_and_leaves_the_viewport_alone() {
4565        use PointerPhase::{Down, Move, Up};
4566        let (content, _) = tall(true, false, false);
4567        let (mut root, mut state) = root_over(move || {
4568            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4569        });
4570        let mut sink = NullScene;
4571        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4572        root.paint(&mut sink, ms(0));
4573        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4574        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4575        root.paint(&mut sink, ms(16));
4576        // The second finger spreads straight down: vertical travel a viewport
4577        // that saw it would read as a scroll drag.
4578        root.event(&mut state, &touch(1, Move, 120.0, 160.0));
4579        root.paint(&mut sink, ms(32));
4580        root.event(&mut state, &touch(1, Move, 120.0, 250.0));
4581        root.event(&mut state, &touch(1, Up, 120.0, 250.0));
4582
4583        let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
4584        assert_eq!(
4585            phases,
4586            [ScalePhase::Begin, ScalePhase::Update, ScalePhase::End],
4587            "the pinch recognizer saw the whole spread"
4588        );
4589        let scroll = viewport(&root);
4590        assert_eq!(scroll.offset(), 0.0);
4591        assert!(!scroll.scrolling);
4592        assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
4593        root.event(&mut state, &touch(0, Up, 100.0, 100.0));
4594        assert!(!root.is_pointer_captured());
4595    }
4596
4597    #[test]
4598    fn a_vertical_drag_over_a_non_panning_child_still_scrolls_and_ends_its_opt_in() {
4599        use PointerPhase::{Cancel, Down, Move, Up};
4600        let (content, seen) = tall(true, true, false);
4601        let (mut root, mut state) = root_over(move || scroll_view(content.clone()));
4602        let t0 = PointerId::touch(0);
4603        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4604        assert!(root.pointer_capture_contacts(), "the child opted in");
4605
4606        // Past the slop: the viewport takes over, cancels the child and
4607        // releases it — and the root hears about it.
4608        root.event(&mut state, &touch(0, Move, 100.0, 60.0));
4609        assert!(viewport(&root).scrolling);
4610        assert_eq!(*seen.borrow(), [(t0, Down), (t0, Cancel)]);
4611        assert_eq!(
4612            root.pointer_capture_claimant(),
4613            Some(t0),
4614            "the drag is still the first finger's"
4615        );
4616        assert!(
4617            !root.pointer_capture_contacts(),
4618            "but the cancelled child no longer gets other fingers"
4619        );
4620
4621        // A second finger now reaches nothing.
4622        let outcome = root.event(&mut state, &touch(1, Down, 100.0, 200.0));
4623        assert!(!outcome.handled);
4624        assert_eq!(seen.borrow().len(), 2);
4625
4626        // The claimant keeps scrolling, and its release ends the gesture.
4627        root.event(&mut state, &touch(0, Move, 100.0, 30.0));
4628        assert_eq!(viewport(&root).offset(), 30.0);
4629        root.event(&mut state, &touch(0, Up, 100.0, 30.0));
4630        assert!(!root.is_pointer_captured());
4631        assert_eq!(
4632            seen.borrow().len(),
4633            2,
4634            "the child heard nothing after its Cancel"
4635        );
4636    }
4637
4638    /// Build a bare scroll widget over `child` in a 400 × 300 viewport.
4639    fn bare(child: Tall) -> ScrollWidget {
4640        let view: ScrollView<App> = scroll_view(child);
4641        let mut counter = 0u64;
4642        let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
4643        w.layout(
4644            &mut LayoutCtx::new(),
4645            &BoxConstraints::tight(Size::new(400.0, 300.0)),
4646        );
4647        w
4648    }
4649
4650    /// Dispatch into a bare scroll widget: its result and whether a capture
4651    /// release bubbled out of it.
4652    fn run(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) -> (EventResult, bool) {
4653        let mut state = App::default();
4654        let sa: &mut dyn Any = &mut state;
4655        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(400.0, 300.0));
4656        let result = w.event_at(&mut ctx, event, t_ms);
4657        (result, ctx.is_capture_released())
4658    }
4659
4660    fn mouse(phase: PointerPhase, y: f64) -> InputEvent {
4661        InputEvent::Pointer(PointerEvent {
4662            phase,
4663            position: Point::new(100.0, y),
4664            button: PointerButton::Primary,
4665        })
4666    }
4667
4668    #[test]
4669    fn a_takeover_raises_the_release_only_for_an_opted_in_child() {
4670        for opt_in in [true, false] {
4671            let (content, _) = tall(true, opt_in, false);
4672            let mut w = bare(content);
4673            assert!(!run(&mut w, &mouse(PointerPhase::Down, 100.0), 0.0).1);
4674            let (_, released) = run(&mut w, &mouse(PointerPhase::Move, 60.0), 16.0);
4675            assert!(w.scrolling, "took over");
4676            assert!(!w.child.is_active(), "and released the child");
4677            assert_eq!(released, opt_in, "signalled only when the child opted in");
4678        }
4679    }
4680
4681    #[test]
4682    fn a_scale_keeps_the_childs_result_and_a_broadcast_is_never_consumed() {
4683        let scale = InputEvent::Scale(ScaleEvent {
4684            phase: ScalePhase::Update,
4685            scale_delta: 1.1,
4686            focal: Point::new(100.0, 100.0),
4687            velocity: 0.0,
4688        });
4689        let (consumer, _) = tall(false, false, true);
4690        let mut w = bare(consumer);
4691        assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Handled);
4692        assert_eq!(
4693            run(&mut w, &InputEvent::Housekeeping, 0.0).0,
4694            EventResult::Ignored,
4695            "a broadcast is never consumed, whatever the child returned"
4696        );
4697        let (bystander, _) = tall(false, false, false);
4698        let mut w = bare(bystander);
4699        assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Ignored);
4700    }
4701
4702    #[test]
4703    fn a_wheel_zoom_handled_inside_the_viewport_is_not_applied_twice() {
4704        let (content, _) = tall(false, false, false);
4705        let (mut root, mut state) = root_over(move || {
4706            pinch_detector(scroll_view(
4707                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4708            ))
4709            .on_scale(|s: &mut App, e| s.scales.push(e))
4710        });
4711        let outcome = root.event(
4712            &mut state,
4713            &InputEvent::Scale(ScaleEvent {
4714                phase: ScalePhase::Update,
4715                scale_delta: 1.5,
4716                focal: Point::new(100.0, 100.0),
4717                velocity: 0.0,
4718            }),
4719        );
4720        assert!(outcome.handled);
4721        assert_eq!(state.transforms.len(), 1, "pan_zoom zoomed once");
4722        assert!(
4723            state.scales.is_empty(),
4724            "and the enclosing recognizer did not zoom again"
4725        );
4726    }
4727
4728    // --- The multi-contact veto: surviving the claimant's own travel -------
4729
4730    #[test]
4731    fn a_pinch_survives_the_claimants_own_travel_past_slop() {
4732        use PointerPhase::{Down, Move};
4733        let (content, _seen) = tall(false, false, false);
4734        let (mut root, mut state) = root_over(move || {
4735            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4736        });
4737        let mut sink = NullScene;
4738        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4739        root.paint(&mut sink, ms(0));
4740        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4741        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4742        root.paint(&mut sink, ms(16));
4743        // The CLAIMANT's own finger spreads the pair vertically, past the
4744        // viewport's `TOUCH_SLOP` — a viewport reading only the `Down`-time
4745        // claim snapshot (always unregistered here; `pinch_detector` is not a
4746        // nested scrollable) would steal this as an ordinary scroll drag
4747        // (the counterexample this card fixes).
4748        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4749        root.paint(&mut sink, ms(32));
4750        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4751
4752        let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
4753        assert_eq!(
4754            phases,
4755            [ScalePhase::Begin, ScalePhase::Update],
4756            "the pinch recognizer saw the whole spread"
4757        );
4758        let scroll = viewport(&root);
4759        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4760        assert!(!scroll.scrolling, "and never took the gesture over");
4761        assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
4762        assert!(
4763            root.pointer_capture_contacts(),
4764            "the viewport never cancelled/released the detector's opt-in"
4765        );
4766    }
4767
4768    #[test]
4769    fn a_pinch_over_a_child_owned_press_survives_the_claimants_own_travel() {
4770        use PointerPhase::{Down, Move};
4771        // `grabs = true`: the content captures the primary `Down` itself, so
4772        // `PanZoomWidget::begin_gesture` takes the child-owned branch, which
4773        // publishes nothing into the nested-scroll claim.
4774        let (content, _seen) = tall(true, false, false);
4775        let (mut root, mut state) = root_over(move || {
4776            scroll_view(
4777                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4778            )
4779        });
4780        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4781        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4782        // The claimant's finger travels well past TOUCH_SLOP vertically while
4783        // a second contact is tracked — exactly the counterexample this card
4784        // fixes for `PanZoomView`'s child-owned-press branch.
4785        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4786        root.event(&mut state, &touch(1, Move, 180.0, 40.0));
4787
4788        let scroll = viewport(&root);
4789        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4790        assert!(!scroll.scrolling, "and never took the gesture over");
4791        assert!(
4792            !state.transforms.is_empty(),
4793            "pan_zoom's own pinch zoomed the view instead"
4794        );
4795    }
4796
4797    #[test]
4798    fn a_single_finger_drag_still_scrolls_through_a_pinch_detector_with_no_second_finger() {
4799        use PointerPhase::{Down, Move};
4800        let (content, _seen) = tall(false, false, false);
4801        let (mut root, mut state) = root_over(move || {
4802            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4803        });
4804        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4805        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4806        assert!(
4807            viewport(&root).scrolling,
4808            "no second finger ever arrived to veto the takeover"
4809        );
4810        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4811        assert_eq!(viewport(&root).offset(), 30.0);
4812        assert!(state.scales.is_empty(), "never a pinch with one finger");
4813    }
4814
4815    #[test]
4816    fn releasing_the_second_finger_clears_the_veto_and_scrolling_resumes() {
4817        use PointerPhase::{Down, Move, Up};
4818        let (content, _seen) = tall(true, false, false);
4819        let (mut root, mut state) = root_over(move || {
4820            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4821        });
4822        let mut sink = NullScene;
4823        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4824        root.paint(&mut sink, ms(0));
4825        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4826        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4827        root.paint(&mut sink, ms(16));
4828        root.event(&mut state, &touch(0, Move, 100.0, 40.0)); // past slop while paired: no takeover
4829        assert!(
4830            !viewport(&root).scrolling,
4831            "the pinch still owns the gesture"
4832        );
4833
4834        root.event(&mut state, &touch(1, Up, 120.0, 40.0)); // the second finger lifts: pinch ends
4835
4836        // The claimant's very next `Move` is measured against its original
4837        // `down_start` as usual (the veto does not replay the suppressed slop
4838        // check) — already well past `TOUCH_SLOP`, so the viewport takes the
4839        // drag over immediately once the veto clears, per the existing
4840        // single-finger scroll contract.
4841        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4842        assert!(
4843            viewport(&root).scrolling,
4844            "the viewport resumed scrolling once the pinch ended"
4845        );
4846    }
4847}
4848
4849#[cfg(test)]
4850mod controller_tests {
4851    //! The [`ScrollController`] seam: deferred application of recorded
4852    //! commands, clamping, fling interruption, publishing, and the
4853    //! one-surface-per-handle binding rules.
4854
4855    use std::any::Any;
4856
4857    use frust_core::{Curve, FrameTime, RenderRoot};
4858
4859    use super::*;
4860    use crate::physics::parity::Bouncing;
4861    use crate::physics::rubber_band::RubberBand;
4862    use crate::test_support::{RecordingScene, leaf};
4863
4864    const VIEWPORT: Size = Size::new(200.0, 100.0);
4865    const CONTENT_H: f64 = 1000.0;
4866    const MAX: f64 = CONTENT_H - 100.0;
4867
4868    fn view(controller: &ScrollController) -> ScrollView<()> {
4869        scroll_view(leaf(200.0, CONTENT_H)).controller(controller.clone())
4870    }
4871
4872    fn build(view: &ScrollView<()>) -> ScrollWidget {
4873        let mut counter = 0u64;
4874        View::<()>::build(view, &mut BuildCtx::new(&mut counter))
4875    }
4876
4877    fn rebuild(view: &ScrollView<()>, prev: &ScrollView<()>, w: &mut ScrollWidget) -> ChangeFlags {
4878        let mut counter = 100u64;
4879        View::<()>::rebuild(view, prev, w, &mut BuildCtx::new(&mut counter))
4880    }
4881
4882    fn lay(w: &mut ScrollWidget) {
4883        w.layout(&mut LayoutCtx::new(), &BoxConstraints::loose(VIEWPORT));
4884    }
4885
4886    /// Paint once, returning whether the paint asked for another frame.
4887    fn paint(w: &mut ScrollWidget) -> bool {
4888        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
4889        w.paint(&mut ctx, &mut RecordingScene::default());
4890        ctx.needs_frame()
4891    }
4892
4893    fn send(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) {
4894        let mut unit = ();
4895        let state: &mut dyn Any = &mut unit;
4896        let mut ctx = EventCtx::new(state, Point::ZERO, w.viewport);
4897        w.event_at(&mut ctx, event, t_ms);
4898    }
4899
4900    fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
4901        InputEvent::Pointer(PointerEvent {
4902            phase,
4903            position: Point::new(10.0, y),
4904            button: PointerButton::Primary,
4905        })
4906    }
4907
4908    fn wheel(dy: f64) -> InputEvent {
4909        InputEvent::Scroll {
4910            position: Point::new(10.0, 50.0),
4911            delta: ScrollDelta::Pixels(0.0, dy),
4912        }
4913    }
4914
4915    /// Drag up past the slop with enough speed that the release flings.
4916    fn fling(w: &mut ScrollWidget) {
4917        send(w, &pointer(PointerPhase::Down, 100.0), 0.0);
4918        send(w, &pointer(PointerPhase::Move, 75.0), 16.0);
4919        send(w, &pointer(PointerPhase::Move, 50.0), 32.0);
4920        send(w, &pointer(PointerPhase::Up, 50.0), 32.0);
4921    }
4922
4923    fn frame_time(ms: f64) -> FrameTime {
4924        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
4925    }
4926
4927    /// Paint the full widget (not just the fling pump) at an explicit frame
4928    /// time, so `drain_controller`/`apply_animate_to` and the ballistic pump
4929    /// both see a real, advancing clock — what an `animate_to` tween needs to
4930    /// progress across more than one frame. Returns whether the paint asked
4931    /// for another one.
4932    fn paint_at(w: &mut ScrollWidget, ms: f64) -> bool {
4933        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
4934        w.paint(&mut ctx, &mut RecordingScene::default());
4935        ctx.needs_frame()
4936    }
4937
4938    /// A themed [`PaintCtx`] at `ms`, with `motion.reduce_motion` set —
4939    /// `animate_to`'s collapse-to-jump path reads it the same way
4940    /// [`button`](crate::button)'s loading spinner does.
4941    fn paint_at_with_reduce_motion(w: &mut ScrollWidget, ms: f64, theme: &Theme) -> bool {
4942        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms)).with_theme(theme);
4943        w.paint(&mut ctx, &mut RecordingScene::default());
4944        ctx.needs_frame()
4945    }
4946
4947    #[test]
4948    fn a_jump_recorded_before_layout_applies_after_the_first_layout() {
4949        let controller = ScrollController::new();
4950        controller.jump_to(400.0);
4951        let view = view(&controller);
4952        let mut w = build(&view);
4953        assert!(controller.is_attached());
4954        assert_eq!(w.offset(), 0.0, "nothing applies before a measured extent");
4955        // A paint before any layout must not clamp the jump against zero.
4956        paint(&mut w);
4957        assert_eq!(w.offset(), 0.0);
4958        lay(&mut w);
4959        assert_eq!(w.offset(), 400.0);
4960        assert_eq!(controller.offset(), 400.0);
4961        assert_eq!(controller.max_offset(), MAX);
4962        assert_eq!(controller.viewport_extent(), VIEWPORT.height);
4963    }
4964
4965    #[test]
4966    fn a_jump_clamps_to_the_scroll_range() {
4967        let controller = ScrollController::new();
4968        let view = view(&controller);
4969        let mut w = build(&view);
4970        lay(&mut w);
4971        let mut jump = |target: f64| {
4972            controller.jump_to(target);
4973            paint(&mut w);
4974            w.offset()
4975        };
4976        assert_eq!(jump(5000.0), MAX);
4977        assert_eq!(jump(-50.0), 0.0);
4978        assert_eq!(jump(f64::INFINITY), MAX);
4979        assert_eq!(jump(f64::NAN), MAX, "NaN is ignored");
4980        assert_eq!(jump(f64::NEG_INFINITY), 0.0);
4981        assert_eq!(controller.offset(), 0.0);
4982    }
4983
4984    #[test]
4985    fn a_jump_during_a_fling_stops_it() {
4986        // Both release paths: the legacy fling (`RubberBand`) and a
4987        // physics-supplied ballistic simulation (the bouncing family).
4988        let physics: [Rc<dyn ScrollPhysics>; 2] =
4989            [Rc::new(RubberBand::new()), Rc::new(Bouncing::new())];
4990        for physics in physics {
4991            let controller = ScrollController::new();
4992            let view = view(&controller);
4993            let mut w = build(&view);
4994            w.physics = physics;
4995            lay(&mut w);
4996            fling(&mut w);
4997            assert!(w.is_flinging(), "the release starts post-release motion");
4998            controller.jump_to(500.0);
4999            let needs_frame = paint(&mut w);
5000            assert!(!w.is_flinging(), "the jump ends the fling");
5001            assert_eq!(w.offset(), 500.0);
5002            assert!(!needs_frame, "nothing is left animating");
5003            // Later frames leave the jumped-to position alone.
5004            paint(&mut w);
5005            assert!(!w.tick(16.0));
5006            assert_eq!(w.offset(), 500.0);
5007            assert_eq!(controller.offset(), 500.0);
5008        }
5009    }
5010
5011    #[test]
5012    fn on_change_hears_user_scrolls_and_jumps() {
5013        let controller = ScrollController::new();
5014        let heard = Rc::new(RefCell::new(Vec::<ScrollInfo>::new()));
5015        let sink = Rc::clone(&heard);
5016        let _subscription = controller.on_change(move |info| sink.borrow_mut().push(info));
5017        let view = view(&controller);
5018        let mut w = build(&view);
5019        lay(&mut w);
5020        assert!(
5021            heard.borrow().iter().all(|i| i.offset == 0.0),
5022            "the first layout publishes only the resting extent"
5023        );
5024        heard.borrow_mut().clear();
5025
5026        send(&mut w, &wheel(30.0), 0.0);
5027        let at = |offset: f64| ScrollInfo {
5028            offset,
5029            max_offset: MAX,
5030            overscroll: 0.0,
5031        };
5032        assert_eq!(heard.borrow().as_slice(), &[at(30.0)]);
5033
5034        // A drag: takeover at the slop, then a 25 px move.
5035        send(&mut w, &pointer(PointerPhase::Down, 100.0), 0.0);
5036        send(&mut w, &pointer(PointerPhase::Move, 75.0), 16.0);
5037        send(&mut w, &pointer(PointerPhase::Move, 50.0), 32.0);
5038        assert_eq!(heard.borrow().last().copied(), Some(at(55.0)));
5039        assert_eq!(controller.offset(), w.offset());
5040        send(&mut w, &pointer(PointerPhase::Cancel, 50.0), 48.0);
5041        heard.borrow_mut().clear();
5042
5043        controller.jump_to(600.0);
5044        lay(&mut w);
5045        assert_eq!(heard.borrow().as_slice(), &[at(600.0)]);
5046        // Re-publishing an unchanged position stays silent.
5047        paint(&mut w);
5048        lay(&mut w);
5049        assert_eq!(heard.borrow().len(), 1);
5050    }
5051
5052    #[test]
5053    fn dropping_a_subscription_unsubscribes_it() {
5054        let controller = ScrollController::new();
5055        let heard = Rc::new(Cell::new(0u32));
5056        let sink = Rc::clone(&heard);
5057        let subscription = controller.on_change(move |_| sink.set(sink.get() + 1));
5058        let view = view(&controller);
5059        let mut w = build(&view);
5060        lay(&mut w);
5061        controller.jump_to(10.0);
5062        lay(&mut w);
5063        let before = heard.get();
5064        assert!(before > 0);
5065        drop(subscription);
5066        controller.jump_to(20.0);
5067        lay(&mut w);
5068        assert_eq!(heard.get(), before);
5069    }
5070
5071    #[test]
5072    fn a_jump_wakes_a_frame_gated_shell() {
5073        let controller = ScrollController::new();
5074        let view = view(&controller);
5075        let mut w = build(&view);
5076        lay(&mut w);
5077        frust_core::take_pending_result_flush();
5078        controller.jump_to(42.0);
5079        assert!(
5080            frust_core::has_pending_result_flush(),
5081            "recording owes a frame even from outside any input path"
5082        );
5083        let same = self::view(&controller);
5084        assert!(rebuild(&same, &view, &mut w).needs_paint());
5085        lay(&mut w);
5086        assert_eq!(w.offset(), 42.0);
5087        let same_again = self::view(&controller);
5088        assert_eq!(rebuild(&same_again, &same, &mut w), ChangeFlags::NONE);
5089    }
5090
5091    #[test]
5092    fn on_scroll_hears_a_jump_through_the_next_frames_flush() {
5093        let controller = ScrollController::new();
5094        let log = Rc::new(RefCell::new(Vec::<ScrollInfo>::new()));
5095        let (handle, sink) = (controller.clone(), Rc::clone(&log));
5096        let mut logic = move |_: &mut ()| {
5097            let sink = Rc::clone(&sink);
5098            scroll_view(leaf(200.0, CONTENT_H))
5099                .on_scroll(move |_: &mut (), info| sink.borrow_mut().push(info))
5100                .controller(handle.clone())
5101        };
5102        let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
5103        let mut state = ();
5104        let mut now = 0u64;
5105        let mut frame = |root: &mut RenderRoot<(), ScrollView<()>>| {
5106            root.rebuild(&mut logic, &mut state);
5107            root.layout(VIEWPORT);
5108            now += 16;
5109            root.paint(
5110                &mut RecordingScene::default(),
5111                FrameTime::from_nanos(now * 1_000_000),
5112            )
5113        };
5114        frame(&mut root);
5115        controller.jump_to(250.0);
5116        let outcome = frame(&mut root);
5117        assert_eq!(
5118            controller.offset(),
5119            250.0,
5120            "applied in the frame after the jump"
5121        );
5122        assert!(
5123            outcome.needs_frame,
5124            "the owed notification asks for a frame"
5125        );
5126        assert!(
5127            log.borrow().is_empty(),
5128            "paint has no state to notify through"
5129        );
5130        frame(&mut root);
5131        assert_eq!(
5132            log.borrow().as_slice(),
5133            &[ScrollInfo {
5134                offset: 250.0,
5135                max_offset: MAX,
5136                overscroll: 0.0,
5137            }]
5138        );
5139    }
5140
5141    #[test]
5142    fn dropping_the_widget_detaches_the_handle() {
5143        let controller = ScrollController::new();
5144        let view = view(&controller);
5145        let mut w = build(&view);
5146        lay(&mut w);
5147        controller.jump_to(120.0);
5148        lay(&mut w);
5149        assert!(controller.is_attached());
5150        drop(w);
5151        assert!(!controller.is_attached());
5152        assert_eq!(controller.offset(), 120.0, "the last published value stays");
5153
5154        // A rebuild that names no handle detaches too.
5155        let mut w = build(&view);
5156        assert!(controller.is_attached());
5157        let bare: ScrollView<()> = scroll_view(leaf(200.0, CONTENT_H));
5158        assert!(rebuild(&bare, &view, &mut w).needs_paint());
5159        assert!(!controller.is_attached());
5160    }
5161
5162    #[test]
5163    fn a_second_scroll_view_takes_the_handle_over() {
5164        let controller = ScrollController::new();
5165        let first_view = view(&controller);
5166        let mut first = build(&first_view);
5167        lay(&mut first);
5168        let second_view = view(&controller);
5169        let mut second = build(&second_view);
5170        lay(&mut second);
5171
5172        controller.jump_to(300.0);
5173        lay(&mut first);
5174        lay(&mut second);
5175        assert_eq!(first.offset(), 0.0, "the displaced surface drains nothing");
5176        assert_eq!(second.offset(), 300.0);
5177
5178        // The displaced surface neither publishes nor takes the handle back
5179        // on a rebuild naming the same handle.
5180        send(&mut first, &wheel(30.0), 0.0);
5181        assert_eq!(first.offset(), 30.0);
5182        assert_eq!(controller.offset(), 300.0);
5183        let first_again = view(&controller);
5184        rebuild(&first_again, &first_view, &mut first);
5185        controller.jump_to(100.0);
5186        lay(&mut first);
5187        lay(&mut second);
5188        assert_eq!((first.offset(), second.offset()), (30.0, 100.0));
5189
5190        // Dropping the displaced surface leaves the holder attached; once the
5191        // holder goes too, the survivor's next rebuild binds the handle again.
5192        let mut survivor = first;
5193        drop(second);
5194        assert!(!controller.is_attached());
5195        let survivor_view = view(&controller);
5196        assert!(rebuild(&survivor_view, &first_again, &mut survivor).needs_paint());
5197        assert!(controller.is_attached());
5198        lay(&mut survivor);
5199        assert_eq!(controller.offset(), 30.0, "the new holder publishes itself");
5200        drop(survivor);
5201        assert!(!controller.is_attached());
5202    }
5203
5204    #[test]
5205    fn an_animate_to_reaches_the_target_within_its_duration() {
5206        let controller = ScrollController::new();
5207        let view = view(&controller);
5208        let mut w = build(&view);
5209        lay(&mut w);
5210        assert!(!controller.is_animating(), "nothing is animating yet");
5211
5212        controller.animate_to(
5213            400.0,
5214            AnimateTo {
5215                duration_ms: 200.0,
5216                curve: Curve::Linear,
5217            },
5218        );
5219        // The seeding frame: the command drains and starts the tween, but
5220        // the zero-delta first pump moves nothing — the same convention a
5221        // release fling's first paint takes.
5222        assert!(paint_at(&mut w, 0.0), "a live tween asks for continuation");
5223        assert!(controller.is_animating());
5224        assert_eq!(w.offset(), 0.0, "the seeding frame moves nothing");
5225
5226        // Halfway through the duration, a linear curve is halfway to the
5227        // target.
5228        assert!(paint_at(&mut w, 100.0));
5229        assert!((w.offset() - 200.0).abs() < 1e-6, "offset: {}", w.offset());
5230        assert!(controller.is_animating());
5231
5232        // At its duration the tween lands exactly on the target and ends.
5233        assert!(!paint_at(&mut w, 200.0), "nothing is left animating");
5234        assert_eq!(w.offset(), 400.0);
5235        assert_eq!(controller.offset(), 400.0, "the completion publishes it");
5236        assert!(
5237            !controller.is_animating(),
5238            "completion resolves is_animating"
5239        );
5240
5241        // A later frame leaves the arrived-at position alone.
5242        assert!(!paint_at(&mut w, 216.0));
5243        assert_eq!(w.offset(), 400.0);
5244    }
5245
5246    #[test]
5247    fn a_down_mid_animation_stops_it_at_the_current_offset() {
5248        let controller = ScrollController::new();
5249        let view = view(&controller);
5250        let mut w = build(&view);
5251        lay(&mut w);
5252        controller.animate_to(
5253            400.0,
5254            AnimateTo {
5255                duration_ms: 200.0,
5256                curve: Curve::Linear,
5257            },
5258        );
5259        paint_at(&mut w, 0.0);
5260        paint_at(&mut w, 100.0);
5261        assert!((w.offset() - 200.0).abs() < 1e-6);
5262        assert!(controller.is_animating());
5263
5264        // A Down lands mid-animation: it ends exactly where the tween had
5265        // eased to, and the ordinary gesture machinery still arms normally —
5266        // user input always wins.
5267        send(&mut w, &pointer(PointerPhase::Down, 100.0), 116.0);
5268        assert!(w.ballistic.is_none(), "the Down ends the tween");
5269        assert!(!controller.is_animating());
5270        assert!(
5271            (w.offset() - 200.0).abs() < 1e-6,
5272            "frozen at the eased-to offset, offset: {}",
5273            w.offset()
5274        );
5275        assert!(w.down_active, "the Down still arms an ordinary gesture");
5276
5277        // A later frame moves nothing on its own — the tween is really gone,
5278        // not just paused.
5279        assert!(!paint_at(&mut w, 300.0));
5280        assert!((w.offset() - 200.0).abs() < 1e-6);
5281    }
5282
5283    #[test]
5284    fn a_wheel_mid_animation_stops_it() {
5285        let controller = ScrollController::new();
5286        let view = view(&controller);
5287        let mut w = build(&view);
5288        lay(&mut w);
5289        controller.animate_to(
5290            400.0,
5291            AnimateTo {
5292                duration_ms: 200.0,
5293                curve: Curve::Linear,
5294            },
5295        );
5296        paint_at(&mut w, 0.0);
5297        paint_at(&mut w, 100.0);
5298        assert!(controller.is_animating());
5299
5300        send(&mut w, &wheel(10.0), 116.0);
5301        assert!(w.ballistic.is_none());
5302        assert!(!controller.is_animating());
5303        assert!(
5304            (w.offset() - 210.0).abs() < 1e-6,
5305            "the wheel delta still applies"
5306        );
5307    }
5308
5309    #[test]
5310    fn a_later_animate_to_replaces_a_running_one() {
5311        let controller = ScrollController::new();
5312        let view = view(&controller);
5313        let mut w = build(&view);
5314        lay(&mut w);
5315        controller.animate_to(
5316            400.0,
5317            AnimateTo {
5318                duration_ms: 200.0,
5319                curve: Curve::Linear,
5320            },
5321        );
5322        paint_at(&mut w, 0.0);
5323        paint_at(&mut w, 100.0);
5324        let mid = w.offset();
5325        assert!((mid - 200.0).abs() < 1e-6);
5326        assert!(controller.is_animating());
5327
5328        // A second `animate_to`, recorded before the first finishes, takes
5329        // over outright: the new tween starts from wherever the first had
5330        // eased to, not from the first's target.
5331        controller.animate_to(
5332            600.0,
5333            AnimateTo {
5334                duration_ms: 100.0,
5335                curve: Curve::Linear,
5336            },
5337        );
5338        assert!(paint_at(&mut w, 116.0), "the replacement keeps animating");
5339        assert!(controller.is_animating());
5340        assert_eq!(
5341            w.offset(),
5342            mid,
5343            "the seeding frame of the new tween moves nothing"
5344        );
5345
5346        assert!(
5347            !paint_at(&mut w, 216.0),
5348            "the replacement's own duration ends it"
5349        );
5350        assert_eq!(w.offset(), 600.0);
5351        assert!(!controller.is_animating());
5352    }
5353
5354    #[test]
5355    fn a_jump_recorded_after_an_animate_to_replaces_it() {
5356        let controller = ScrollController::new();
5357        let view = view(&controller);
5358        let mut w = build(&view);
5359        lay(&mut w);
5360        controller.animate_to(
5361            400.0,
5362            AnimateTo {
5363                duration_ms: 200.0,
5364                curve: Curve::Linear,
5365            },
5366        );
5367        paint_at(&mut w, 0.0);
5368        paint_at(&mut w, 100.0);
5369        assert!(controller.is_animating());
5370
5371        controller.jump_to(500.0);
5372        assert!(
5373            !paint_at(&mut w, 116.0),
5374            "the jump leaves nothing animating"
5375        );
5376        assert_eq!(w.offset(), 500.0);
5377        assert!(!controller.is_animating());
5378    }
5379
5380    #[test]
5381    fn reduce_motion_collapses_animate_to_into_an_immediate_jump() {
5382        let controller = ScrollController::new();
5383        let view = view(&controller);
5384        let mut w = build(&view);
5385        lay(&mut w);
5386        controller.animate_to(
5387            400.0,
5388            AnimateTo {
5389                duration_ms: 200.0,
5390                curve: Curve::Linear,
5391            },
5392        );
5393
5394        let mut theme = frust_theme::Theme::neutral();
5395        theme.motion.reduce_motion = true;
5396        assert!(!paint_at_with_reduce_motion(&mut w, 0.0, &theme));
5397        assert_eq!(w.offset(), 400.0, "landed at once, no tween in flight");
5398        assert_eq!(controller.offset(), 400.0);
5399        assert!(!controller.is_animating(), "no animation ever started");
5400        assert!(w.ballistic.is_none());
5401    }
5402
5403    #[test]
5404    fn an_animate_to_clamps_to_the_scroll_range() {
5405        let controller = ScrollController::new();
5406        let view = view(&controller);
5407        let mut w = build(&view);
5408        lay(&mut w);
5409
5410        let mut animate_and_run = |target: f64| -> f64 {
5411            controller.animate_to(
5412                target,
5413                AnimateTo {
5414                    duration_ms: 50.0,
5415                    curve: Curve::Linear,
5416                },
5417            );
5418            paint_at(&mut w, 0.0);
5419            paint_at(&mut w, 116.0);
5420            w.offset()
5421        };
5422        assert_eq!(animate_and_run(5000.0), MAX);
5423        assert_eq!(animate_and_run(-50.0), 0.0);
5424        assert_eq!(animate_and_run(f64::INFINITY), MAX);
5425        assert_eq!(animate_and_run(f64::NEG_INFINITY), 0.0);
5426    }
5427
5428    #[test]
5429    fn an_animate_to_with_nan_offset_is_ignored() {
5430        let controller = ScrollController::new();
5431        let view = view(&controller);
5432        let mut w = build(&view);
5433        lay(&mut w);
5434        controller.jump_to(123.0);
5435        paint_at(&mut w, 0.0);
5436
5437        controller.animate_to(
5438            f64::NAN,
5439            AnimateTo {
5440                duration_ms: 100.0,
5441                curve: Curve::Linear,
5442            },
5443        );
5444        assert!(!paint_at(&mut w, 116.0));
5445        assert_eq!(w.offset(), 123.0, "NaN is ignored, nothing moves");
5446        assert!(!controller.is_animating());
5447    }
5448
5449    // --- A command applied mid-drag ends the drag. ---
5450
5451    /// 1000 px of content logging every pointer phase that reaches it, so a
5452    /// test can see where a `Move`/`Up` was routed once the surface stopped
5453    /// scrolling. Logged through an `Rc` rather than `EventCtx::state_mut`
5454    /// because a `Cancel` arm never touches state.
5455    struct PhaseLog(Rc<RefCell<Vec<PointerPhase>>>);
5456    /// Retained widget for [`PhaseLog`].
5457    struct PhaseLogW(Rc<RefCell<Vec<PointerPhase>>>);
5458
5459    impl View<()> for PhaseLog {
5460        type Element = PhaseLogW;
5461        fn build(&self, _c: &mut BuildCtx<'_>) -> PhaseLogW {
5462            PhaseLogW(Rc::clone(&self.0))
5463        }
5464        fn rebuild(&self, _p: &Self, _e: &mut PhaseLogW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
5465            ChangeFlags::NONE
5466        }
5467    }
5468
5469    impl Widget for PhaseLogW {
5470        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
5471            bc.constrain(Size::new(200.0, CONTENT_H))
5472        }
5473        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
5474        fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
5475            if let InputEvent::Pointer(p) = e {
5476                self.0.borrow_mut().push(p.phase);
5477            }
5478            EventResult::Ignored
5479        }
5480    }
5481
5482    /// A laid-out surface over a [`PhaseLog`] child running `physics`, with
5483    /// `controller` attached, plus the child's phase log.
5484    fn logged(
5485        controller: &ScrollController,
5486        physics: Rc<dyn ScrollPhysics>,
5487    ) -> (ScrollWidget, Rc<RefCell<Vec<PointerPhase>>>) {
5488        let log = Rc::new(RefCell::new(Vec::new()));
5489        let view = scroll_view(PhaseLog(Rc::clone(&log))).controller(controller.clone());
5490        let mut w = build(&view);
5491        w.physics = physics;
5492        lay(&mut w);
5493        (w, log)
5494    }
5495
5496    /// Down, then two moves upward — the first crosses the slop and takes
5497    /// over, the second scrolls 25 px — leaving the drag live.
5498    fn drag_without_release(w: &mut ScrollWidget) {
5499        send(w, &pointer(PointerPhase::Down, 100.0), 0.0);
5500        send(w, &pointer(PointerPhase::Move, 75.0), 16.0);
5501        send(w, &pointer(PointerPhase::Move, 50.0), 32.0);
5502        assert!(w.scrolling && w.down_active, "the drag is live");
5503    }
5504
5505    /// How many of the three post-release motions are live at once — the
5506    /// invariant is that this never exceeds one.
5507    fn live_motions(w: &ScrollWidget) -> usize {
5508        usize::from(w.fling.is_some())
5509            + usize::from(w.settling)
5510            + usize::from(w.ballistic.is_some())
5511    }
5512
5513    fn both_physics() -> [Rc<dyn ScrollPhysics>; 2] {
5514        [Rc::new(RubberBand::new()), Rc::new(Bouncing::new())]
5515    }
5516
5517    #[test]
5518    fn an_animate_to_mid_drag_ends_the_drag_and_owns_the_offset() {
5519        for physics in both_physics() {
5520            let controller = ScrollController::new();
5521            let (mut w, log) = logged(&controller, physics);
5522            drag_without_release(&mut w);
5523            let start = w.offset();
5524            assert!(start > 0.0, "the drag moved the content");
5525            let stale_veto = Rc::clone(&w.live_veto);
5526
5527            controller.animate_to(
5528                start + 200.0,
5529                AnimateTo {
5530                    duration_ms: 200.0,
5531                    curve: Curve::Linear,
5532                },
5533            );
5534            assert!(paint_at(&mut w, 0.0), "the tween asks for frames");
5535            assert!(!w.scrolling, "the command ended the drag");
5536            assert!(!w.down_active, "and disarmed the gesture");
5537            assert!(!w.deferring);
5538            assert!(
5539                !Rc::ptr_eq(&w.live_veto, &stale_veto),
5540                "the gesture's veto cell is replaced, like on a Down"
5541            );
5542            assert!(controller.is_animating());
5543
5544            assert!(paint_at(&mut w, 100.0));
5545            let mid = w.offset();
5546            assert!(
5547                (mid - (start + 100.0)).abs() < 1e-6,
5548                "the offset tracks the tween, offset: {mid}"
5549            );
5550
5551            // The finger keeps going: the move takes the unarmed path to the
5552            // child and never touches the offset the tween owns.
5553            log.borrow_mut().clear();
5554            send(&mut w, &pointer(PointerPhase::Move, 20.0), 116.0);
5555            assert_eq!(w.offset(), mid, "a later Move does not move the content");
5556            assert_eq!(log.borrow().as_slice(), &[PointerPhase::Move]);
5557            assert!(!w.scrolling, "and never re-arms the drag");
5558
5559            // The release takes the non-scrolling branch: forwarded to the
5560            // child, starting no fling or settle of its own.
5561            send(&mut w, &pointer(PointerPhase::Up, 20.0), 120.0);
5562            assert_eq!(
5563                log.borrow().as_slice(),
5564                &[PointerPhase::Move, PointerPhase::Up]
5565            );
5566            assert!(w.fling.is_none(), "no stray fling");
5567            assert!(!w.settling, "no stray settle");
5568            assert_eq!(live_motions(&w), 1, "only the tween is live");
5569            assert!(controller.is_animating(), "the tween runs on");
5570
5571            assert!(!paint_at(&mut w, 200.0), "nothing is left animating");
5572            assert_eq!(w.offset(), start + 200.0);
5573            assert!(!controller.is_animating(), "completion resolves it");
5574            assert!(!w.is_flinging() && !w.settling);
5575            assert!(!paint_at(&mut w, 216.0));
5576            assert_eq!(w.offset(), start + 200.0);
5577        }
5578    }
5579
5580    #[test]
5581    fn a_jump_mid_drag_ends_the_drag_and_its_release_starts_nothing() {
5582        for physics in both_physics() {
5583            let controller = ScrollController::new();
5584            let (mut w, log) = logged(&controller, physics);
5585            drag_without_release(&mut w);
5586            controller.jump_to(500.0);
5587            lay(&mut w);
5588            assert_eq!(w.offset(), 500.0);
5589            assert!(!w.scrolling && !w.down_active, "the jump ended the drag");
5590
5591            log.borrow_mut().clear();
5592            send(&mut w, &pointer(PointerPhase::Move, 10.0), 48.0);
5593            assert_eq!(w.offset(), 500.0, "the finger no longer drags");
5594            // A fast upward flick that would have flung a live drag.
5595            send(&mut w, &pointer(PointerPhase::Up, 10.0), 50.0);
5596            assert_eq!(
5597                log.borrow().as_slice(),
5598                &[PointerPhase::Move, PointerPhase::Up],
5599                "both reached the child through the unarmed path"
5600            );
5601            assert_eq!(live_motions(&w), 0, "the release starts nothing");
5602            assert!(!controller.is_animating());
5603            assert!(!paint_at(&mut w, 64.0), "nothing animates");
5604            assert_eq!(w.offset(), 500.0);
5605            assert_eq!(controller.offset(), 500.0);
5606        }
5607    }
5608
5609    #[test]
5610    fn a_release_tears_down_a_tween_before_starting_its_own_motion() {
5611        // Defence in depth behind the rule above: even with a tween somehow
5612        // live under a still-armed drag, the release replaces it through the
5613        // single tear-down path rather than writing over it — under the
5614        // legacy fling/settle path (`RubberBand`) and the simulation path.
5615        for physics in both_physics() {
5616            let controller = ScrollController::new();
5617            let (mut w, _log) = logged(&controller, physics);
5618            drag_without_release(&mut w);
5619            controller.animate_to(
5620                800.0,
5621                AnimateTo {
5622                    duration_ms: 400.0,
5623                    curve: Curve::Linear,
5624                },
5625            );
5626            paint_at(&mut w, 0.0);
5627            assert!(controller.is_animating());
5628            // Force the drag back on, as if the command had not ended it.
5629            w.scrolling = true;
5630            w.down_active = true;
5631
5632            send(&mut w, &pointer(PointerPhase::Up, 50.0), 40.0);
5633            assert!(
5634                live_motions(&w) <= 1,
5635                "fling, settle and simulation are never live together"
5636            );
5637            assert!(
5638                !controller.is_animating(),
5639                "the replaced tween no longer reports itself"
5640            );
5641            assert!(!w.controller_animating);
5642            // Whatever the release started runs to rest on its own, never
5643            // resurrecting the tween's flag.
5644            let mut ms = 40.0;
5645            while paint_at(&mut w, ms) {
5646                assert!(live_motions(&w) <= 1);
5647                assert!(!controller.is_animating());
5648                ms += 16.0;
5649                assert!(ms < 10_000.0, "the release motion never settled");
5650            }
5651            assert_eq!(live_motions(&w), 0);
5652        }
5653    }
5654
5655    #[test]
5656    fn a_command_during_the_pre_slop_hold_keeps_the_drag_startable() {
5657        // A finger that is down but still inside the slop writes no offset,
5658        // so a command drained meanwhile leaves the hold armed: the slop
5659        // takeover (and the child `Cancel` it sends) still happens, the
5660        // finger still scrolls, and the lift never activates the child.
5661        for physics in both_physics() {
5662            let controller = ScrollController::new();
5663            let (mut w, log) = logged(&controller, physics);
5664            send(&mut w, &pointer(PointerPhase::Down, 100.0), 0.0);
5665            send(
5666                &mut w,
5667                &pointer(PointerPhase::Move, 100.0 - TOUCH_SLOP / 2.0),
5668                16.0,
5669            );
5670            assert!(w.down_active && !w.scrolling, "armed, not yet a drag");
5671            assert_eq!(log.borrow().first(), Some(&PointerPhase::Down));
5672
5673            controller.jump_to(20.0);
5674            paint_at(&mut w, 32.0);
5675            assert!(w.offset() > 0.0, "the jump applied");
5676            assert!(w.down_active, "the hold stays armed");
5677            assert!(!w.scrolling);
5678
5679            log.borrow_mut().clear();
5680            send(&mut w, &pointer(PointerPhase::Move, 60.0), 48.0);
5681            assert!(w.scrolling, "the slop takeover still happens");
5682            assert_eq!(
5683                log.borrow().as_slice(),
5684                &[PointerPhase::Cancel],
5685                "the child's press is cancelled at takeover"
5686            );
5687            let after_takeover = w.offset();
5688            send(&mut w, &pointer(PointerPhase::Move, 50.0), 64.0);
5689            assert!(
5690                w.offset() > after_takeover,
5691                "the finger scrolls the content"
5692            );
5693
5694            send(&mut w, &pointer(PointerPhase::Up, 50.0), 80.0);
5695            assert_eq!(
5696                log.borrow().as_slice(),
5697                &[PointerPhase::Cancel],
5698                "no Up reaches the child"
5699            );
5700            assert!(!w.down_active && !w.scrolling);
5701        }
5702    }
5703
5704    #[test]
5705    fn a_nan_command_mid_drag_leaves_the_drag_alone() {
5706        let controller = ScrollController::new();
5707        let (mut w, _log) = logged(&controller, Rc::new(Bouncing::new()));
5708        drag_without_release(&mut w);
5709        let before = w.offset();
5710        controller.jump_to(f64::NAN);
5711        lay(&mut w);
5712        assert!(w.scrolling, "an ignored command does not end the drag");
5713        send(&mut w, &pointer(PointerPhase::Move, 40.0), 48.0);
5714        assert!(w.offset() > before, "the finger still drags");
5715    }
5716
5717    #[test]
5718    fn dropping_the_widget_mid_tween_resolves_is_animating() {
5719        let controller = ScrollController::new();
5720        let view = view(&controller);
5721        let mut w = build(&view);
5722        lay(&mut w);
5723        controller.animate_to(
5724            400.0,
5725            AnimateTo {
5726                duration_ms: 200.0,
5727                curve: Curve::Linear,
5728            },
5729        );
5730        paint_at(&mut w, 0.0);
5731        assert!(controller.is_animating(), "the tween is running");
5732        assert!(controller.is_attached());
5733
5734        drop(w);
5735        assert!(
5736            !controller.is_animating(),
5737            "the dropped surface's tween no longer reports itself"
5738        );
5739        assert!(!controller.is_attached());
5740    }
5741
5742    #[test]
5743    fn a_second_surface_attaching_mid_tween_reports_not_animating() {
5744        let controller = ScrollController::new();
5745        let first_view = view(&controller);
5746        let mut first = build(&first_view);
5747        lay(&mut first);
5748        controller.animate_to(
5749            400.0,
5750            AnimateTo {
5751                duration_ms: 200.0,
5752                curve: Curve::Linear,
5753            },
5754        );
5755        paint_at(&mut first, 0.0);
5756        assert!(controller.is_animating(), "the first surface's tween runs");
5757
5758        // A second surface takes the handle over mid-tween — it has never
5759        // started a tween of its own, so the handle it now holds reports
5760        // not animating at once, not the first surface's stale `true`.
5761        let second_view = view(&controller);
5762        let mut second = build(&second_view);
5763        assert!(
5764            !controller.is_animating(),
5765            "the new holder has no tween of its own"
5766        );
5767
5768        // The displaced first surface's tween keeps running on its own
5769        // widget state regardless, but it no longer has a current binding
5770        // to publish through.
5771        lay(&mut second);
5772        assert!(
5773            paint_at(&mut first, 100.0),
5774            "the orphaned tween still pumps"
5775        );
5776        assert!(
5777            !controller.is_animating(),
5778            "the orphaned tween cannot resurrect the handle's flag"
5779        );
5780        drop(second);
5781        drop(first);
5782    }
5783
5784    #[test]
5785    fn rebinding_to_a_different_handle_mid_tween_moves_the_live_flag() {
5786        let old_controller = ScrollController::new();
5787        let old_view = view(&old_controller);
5788        let mut w = build(&old_view);
5789        lay(&mut w);
5790        old_controller.animate_to(
5791            400.0,
5792            AnimateTo {
5793                duration_ms: 200.0,
5794                curve: Curve::Linear,
5795            },
5796        );
5797        paint_at(&mut w, 0.0);
5798        assert!(
5799            old_controller.is_animating(),
5800            "the tween runs on the old handle"
5801        );
5802
5803        // A rebuild rebinds the same widget to a different handle while the
5804        // tween is still live — the widget's own tween survives the rebind
5805        // (it belongs to the widget, not the handle), so the new handle
5806        // reports it live and the old one is released.
5807        let new_controller = ScrollController::new();
5808        let new_view = view(&new_controller);
5809        assert!(rebuild(&new_view, &old_view, &mut w).needs_paint());
5810
5811        assert!(
5812            !old_controller.is_animating(),
5813            "the superseded handle no longer reports the tween"
5814        );
5815        assert!(!old_controller.is_attached());
5816        assert!(
5817            new_controller.is_animating(),
5818            "the freshly bound handle publishes the still-running tween"
5819        );
5820
5821        // Running the tween to completion resolves it on the new handle.
5822        assert!(!paint_at(&mut w, 200.0), "nothing is left animating");
5823        assert!(
5824            !new_controller.is_animating(),
5825            "completion resolves is_animating on the current handle"
5826        );
5827    }
5828}