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
137use std::cell::{Cell, RefCell};
138use std::rc::Rc;
139
140use frust_core::accesskit::Role;
141use frust_core::{
142    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FLING_STOP,
143    FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
144    PointerPhase, ScrollDelta, SemanticsCtx, TOUCH_SLOP, VelocityTracker, View, WHEEL_LINE_PX,
145    Widget, any, fling_decay, fling_displacement,
146};
147use kurbo::{Affine, Point, Rect, Size};
148
149use crate::authoring::{ErasedArgCallback, ErasedCallback, presses};
150use crate::physics::effect::OverscrollEffect;
151use crate::physics::{
152    MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR, ScrollMetrics, ScrollPhysics, Simulation,
153    default_overscroll_effect, default_physics,
154};
155
156/// iOS-style rubber-band resistance applied to the past-edge portion of a drag
157/// under [`RubberBand`](crate::RubberBand): the visible out-of-range
158/// displacement is `raw_excess * OVERSCROLL_RESISTANCE`. No longer any
159/// platform's default — the platform-parity physics carry their own curves —
160/// but still the constant the opt-in pre-seam feel is defined by.
161///
162/// **Community-approximate**: UIScrollView's rubber-banding is a
163/// diminishing-returns curve (roughly `d·(1 − 1/(1 + d/dim·c))`), not a
164/// published constant. A flat `0.5` factor is the common community linear
165/// approximation — half the raw finger travel shows past the edge, giving the
166/// pull a heavier feel the further it is dragged in *raw* terms while staying
167/// cheap and deterministic to reason about. Tunable in one place if a
168/// diminishing curve is wanted later.
169///
170/// `pub(crate)`, not `pub`: [`crate::list_view::ListViewWidget`] shares this
171/// exact value (and the three constants below) for overscroll feel parity
172/// (lazy-list 06) — never redeclare a second copy there.
173pub(crate) const OVERSCROLL_RESISTANCE: f64 = 0.5;
174
175/// Pull-past-top distance (logical px, measured on [`ScrollWidget::edge_pull`]
176/// — the physics-mapped pull, so a resisting physics needs proportionally more
177/// raw finger travel to reach it and a clamping one exactly this much) beyond
178/// which releasing fires [`ScrollView::on_refresh_release`] (and
179/// `ListView::on_refresh_release`, which shares this constant and
180/// [`crossed_refresh_trigger`]) — the pull-to-refresh trigger.
181///
182/// **Community-approximate**: iOS's `UIRefreshControl` trigger distance is not a
183/// published constant; ~64pt is the value community reimplementations converge
184/// on for a comfortable pull.
185pub(crate) const REFRESH_TRIGGER_PX: f64 = 64.0;
186
187/// Per-millisecond retain factor for the release-settle animation that returns
188/// an overscrolled surface to its clamped edge: after `dt` ms the remaining
189/// distance to the edge is scaled by `SETTLE_DECAY.powf(dt)`.
190///
191/// **Community-approximate**: `0.988` settles ~95% of the way in ≈250 ms, an
192/// iOS-like snap-back with no published spring spec to match.
193pub(crate) const SETTLE_DECAY: f64 = 0.988;
194
195/// Distance (logical px) below which the settle animation snaps exactly to the
196/// edge and stops, so it terminates instead of asymptotically approaching.
197pub(crate) const SETTLE_STOP_PX: f64 = 0.5;
198
199/// The [`ScrollMetrics::device_pixel_ratio`] both scroll surfaces report.
200///
201/// **No context exposes a real one**: density is resolved at the shell's FFI
202/// boundary and everything above it speaks logical pixels
203/// (`docs/CODE_STANDARDS.md`'s Interaction Semantics), so neither `EventCtx`
204/// nor `PaintCtx` carries a scale factor a widget could thread in. `1.0` is
205/// therefore reported rather than guessed. Nothing in the shipped feel reads
206/// it — every constant above is dpr-independent, and it feeds only
207/// [`crate::physics::Tolerance::for_device_pixel_ratio`] for a physics that
208/// builds a [`Simulation`]. Shared with [`crate::list_view::ListViewWidget`]
209/// so the two surfaces cannot report different densities.
210pub(crate) const METRICS_FALLBACK_DPR: f64 = 1.0;
211
212/// Whether a past-top overscroll displacement crossed [`REFRESH_TRIGGER_PX`] —
213/// the pull-to-refresh release condition, shared with
214/// [`crate::list_view::ListViewWidget`] so both surfaces trigger at exactly the
215/// same pull distance (lazy-list 06) rather than two independently-typed
216/// comparisons drifting apart.
217pub(crate) fn crossed_refresh_trigger(overscroll: f64) -> bool {
218    overscroll < -REFRESH_TRIGGER_PX
219}
220
221/// The scale [`OverscrollEffect::Stretch`] adds per unit of normalized pull —
222/// the linear term's slope and, equally, the exponential term's ceiling, so a
223/// full-viewport pull stretches by at most `2 · STRETCH_INTENSITY`.
224///
225/// **Published value**, not a guess: Flutter's `_StretchController`
226/// (`widgets/overscroll_indicator.dart`) uses exactly this in its port of
227/// Android 12's overscroll effect, and the port here is term-for-term the
228/// same curve. Shared with [`crate::list_view::ListViewWidget`] like every
229/// constant above.
230pub(crate) const STRETCH_INTENSITY: f64 = 0.016;
231
232/// How fast [`stretch_intensity`]'s exponential term saturates. Flutter's
233/// `_StretchController.exponentialScalar` verbatim (`e / 0.33`): the pull is
234/// ~95% of the way to the term's ceiling at a third of a viewport.
235pub(crate) const STRETCH_EXP_SCALAR: f64 = std::f64::consts::E / 0.33;
236
237/// The added scale [`OverscrollEffect::Stretch`] paints for a pull of
238/// `edge_pull` (signed, in the [`ScrollWidget::edge_pull`] sense) against a
239/// viewport `viewport_dimension` px along the scroll axis: a linear plus a
240/// saturating-exponential term over the normalized pull
241/// `x = |edge_pull| / viewport_dimension`, clamped to `[0, 1]`.
242///
243/// Magnitude-only — which edge is held decides the anchor
244/// ([`stretch_about_edge`]), never the amount. `0.0` for an unpulled surface
245/// or a degenerate (zero-height) viewport, and never above
246/// `2 · STRETCH_INTENSITY`.
247pub(crate) fn stretch_intensity(edge_pull: f64, viewport_dimension: f64) -> f64 {
248    if viewport_dimension <= 0.0 {
249        return 0.0;
250    }
251    let x = (edge_pull.abs() / viewport_dimension).clamp(0.0, 1.0);
252    STRETCH_INTENSITY * x + STRETCH_INTENSITY * (1.0 - (-x * STRETCH_EXP_SCALAR).exp())
253}
254
255/// The paint-side affine [`OverscrollEffect::Stretch`] wraps a viewport's
256/// content in: a scroll-axis-only scale of `1 + stretch_intensity(…)` about
257/// the **held** edge — the viewport's top for a pull past the top (negative
258/// `edge_pull`), its bottom edge otherwise — so the content grows away from
259/// the finger while the edge under it stays pinned.
260///
261/// `origin`/`size` are the viewport's absolute paint geometry
262/// ([`PaintCtx::origin`]/[`PaintCtx::size`]), which is the space
263/// [`PaintScene::push_transform`] composes in. `None` when there is nothing to
264/// paint (no pull, or a degenerate viewport), so a caller pushes no transform
265/// at all rather than an identity one.
266pub(crate) fn stretch_about_edge(origin: Point, size: Size, edge_pull: f64) -> Option<Affine> {
267    let intensity = stretch_intensity(edge_pull, size.height);
268    if intensity == 0.0 {
269        return None;
270    }
271    let anchor = if edge_pull < 0.0 {
272        origin.y
273    } else {
274        origin.y + size.height
275    };
276    // The standard scale-about-a-point sandwich (`motion::animated`'s
277    // `scale_about`, `nav::transition`'s `rect_to_rect`), non-uniform so only
278    // the scroll axis stretches.
279    Some(
280        Affine::translate((0.0, anchor))
281            * Affine::scale_non_uniform(1.0, 1.0 + intensity)
282            * Affine::translate((0.0, -anchor)),
283    )
284}
285
286// --- Nested-scroll arbitration: the ambient inner-scroll claim ---------------
287
288/// How far past an edge [`inner_claim_state`] probes
289/// [`ScrollPhysics::apply_boundary_conditions`] to ask "would a pull past this
290/// edge be rejected?" (logical px).
291///
292/// The question is categorical — *does this physics hold an out-of-range
293/// position at all* — not metric, and every physics in [`crate::physics`]
294/// answers it the same way at any depth, so one pixel is enough; it is far
295/// enough past the extent that no rounding step can land back inside it.
296const CLAIM_PROBE_PX: f64 = 1.0;
297
298/// A `Down`-time snapshot of what a nested scroll surface could do with the
299/// gesture, written by that surface into its nearest enclosing one's ambient
300/// claim cell and read back there at the takeover site (the module docs'
301/// *Nested scrolling*).
302///
303/// "Down"/"up" name the **finger's** direction, never the offset's: a
304/// finger-moving-down drag reveals content *above* it (the offset falls toward
305/// the leading edge), a finger-moving-up drag reveals content below.
306#[derive(Clone, Copy, Debug, Default)]
307pub(crate) struct InnerScrollState {
308    /// Whether a nested scroll surface reported at all. `false` — the default
309    /// a never-written cell reads back — is the no-nested-scrollable case
310    /// every gesture took before arbitration existed.
311    pub(crate) registered: bool,
312    /// Whether the inner can consume a finger-moving-DOWN drag: it still holds
313    /// content above (`pixels > min_scroll_extent`), or its physics allows
314    /// past-leading-edge displacement, which lets a bouncing-family surface
315    /// answer a pull with a rubber-band even pinned at the top.
316    pub(crate) can_consume_down_drag: bool,
317    /// Whether the inner can consume a finger-moving-UP drag: content below
318    /// (`pixels < max_scroll_extent`), or past-trailing displacement allowed.
319    pub(crate) can_consume_up_drag: bool,
320}
321
322impl InnerScrollState {
323    /// Whether an outer surface must stand down at its takeover site for a
324    /// cumulative drag of `dy` finger px (positive = the finger moved down) —
325    /// innermost-wins: a registered inner that can consume *this* direction
326    /// owns the gesture; one pinned against it does not, and the outer takes
327    /// over as it always has.
328    ///
329    /// Only ever asked past [`TOUCH_SLOP`], so `dy` is never zero; a zero would
330    /// read as an up-drag rather than warranting a third branch.
331    pub(crate) fn defers(self, dy: f64) -> bool {
332        if !self.registered {
333            return false;
334        }
335        if dy > 0.0 {
336            self.can_consume_down_drag
337        } else {
338            self.can_consume_up_drag
339        }
340    }
341}
342
343thread_local! {
344    /// The stack of per-surface **inner-scroll claim** cells for the scroll
345    /// surfaces currently forwarding a pointer `Down` — the seam a nested
346    /// scrollable reports itself to its nearest enclosing one through
347    /// (mirrors `nav::ambient`'s `SWIPE_CLAIM`/`with_swipe_claim`).
348    ///
349    /// A `Down` does not decide anything: the outer surface captures, forwards
350    /// it, and only at the later `Move` slop does it choose between taking the
351    /// drag over and deferring — by which point dispatch order has already put
352    /// it upstream of the inner. So the outer pushes a fresh cell before
353    /// forwarding the `Down` ([`with_scroll_claim`]), the nested surface writes
354    /// its [`InnerScrollState`] into whatever cell is ambient
355    /// ([`ambient_scroll_claim`]) as that `Down` reaches it, and the outer
356    /// reads it back once forwarding returns.
357    ///
358    /// A **stack**, not a single slot, because the pairing must be
359    /// *nearest*-inner: a surface writes its own state into the ambient cell
360    /// **before** pushing its own cell for its own children, so its write lands
361    /// in its enclosing surface's cell while everything deeper lands in its
362    /// own. Three levels deep, the outermost therefore learns only about the
363    /// middle surface and the middle only about the innermost — nothing
364    /// propagates a grandchild's claim up past its own parent, which is what
365    /// makes each layer arbitrate against the layer it actually contains.
366    ///
367    /// Reactive-free by construction (`frust-widgets` carries no
368    /// `reactive_graph` dependency): a plain `Rc<Cell<_>>`, never a signal, and
369    /// UI-thread-affine for the same reason `nav::ambient`'s cells are.
370    static SCROLL_CLAIM: RefCell<Vec<Rc<Cell<InnerScrollState>>>> =
371        const { RefCell::new(Vec::new()) };
372}
373
374/// Pops [`SCROLL_CLAIM`] on drop, so an unwinding child dispatch cannot leave a
375/// stale scope behind for the rest of the thread's life (mirrors
376/// `nav::ambient`'s `SwipeClaimGuard`).
377struct ScrollClaimGuard;
378
379impl Drop for ScrollClaimGuard {
380    fn drop(&mut self) {
381        SCROLL_CLAIM.with(|stack| {
382            stack.borrow_mut().pop();
383        });
384    }
385}
386
387/// Run `f` (a `Down` forward into this surface's own children) with `claim`
388/// installed as the ambient inner-scroll claim cell, so the nearest scroll
389/// surface reached underneath can report itself into it via
390/// [`ambient_scroll_claim`].
391///
392/// The [`SCROLL_CLAIM`] borrow is released *before* `f` runs, so `f` may itself
393/// nest another `with_scroll_claim` call (a third level of scroll nesting).
394pub(crate) fn with_scroll_claim<R>(claim: &Rc<Cell<InnerScrollState>>, f: impl FnOnce() -> R) -> R {
395    SCROLL_CLAIM.with(|stack| stack.borrow_mut().push(Rc::clone(claim)));
396    let _guard = ScrollClaimGuard;
397    f()
398}
399
400/// The claim cell of the scroll surface currently forwarding a `Down` — the
401/// *nearest enclosing* one — or `None` if none is (a top-level surface's own
402/// `Down`, or any non-`Down` event).
403pub(crate) fn ambient_scroll_claim() -> Option<Rc<Cell<InnerScrollState>>> {
404    SCROLL_CLAIM.with(|stack| stack.borrow().last().cloned())
405}
406
407thread_local! {
408    /// The stack of per-surface **multi-contact veto** cells for the scroll
409    /// surfaces currently forwarding a pointer `Down` — [`SCROLL_CLAIM`]'s
410    /// live counterpart (the module docs' *Multi-contact veto*). A nested
411    /// recognizer that opts into the gesture's other contacts
412    /// (`pinch_detector`, [`crate::pan_zoom`]'s child-owned-press branch)
413    /// captures the ambient cell on the claimant's `Down`, while it is still
414    /// reachable, and keeps writing to it for the rest of the gesture — long
415    /// after the `Down` forward that exposed it has returned, which is
416    /// exactly what a `Down`-time snapshot like [`InnerScrollState`] cannot
417    /// do (it is read back once, synchronously, before a second contact can
418    /// possibly have arrived).
419    ///
420    /// A separate stack from [`SCROLL_CLAIM`] rather than a field folded into
421    /// it: `InnerScrollState` stays `Copy` (and byte-identical) for
422    /// [`crate::list_view::ListViewWidget`]'s existing consumption of it, and
423    /// a plain `Rc<Cell<bool>>` is exactly as `Copy`-free a value as this
424    /// seam needs.
425    static MULTI_CONTACT_VETO: RefCell<Vec<Rc<Cell<bool>>>> = const { RefCell::new(Vec::new()) };
426}
427
428/// Pops [`MULTI_CONTACT_VETO`] on drop, so an unwinding child dispatch cannot
429/// leave a stale scope behind for the rest of the thread's life (mirrors
430/// [`ScrollClaimGuard`]).
431struct MultiContactVetoGuard;
432
433impl Drop for MultiContactVetoGuard {
434    fn drop(&mut self) {
435        MULTI_CONTACT_VETO.with(|stack| {
436            stack.borrow_mut().pop();
437        });
438    }
439}
440
441/// Run `f` (a `Down` forward into this surface's own children) with `veto`
442/// installed as the ambient multi-contact veto cell, so a recognizer reached
443/// underneath that opts into the gesture's other contacts can capture it and
444/// raise it later, once a second contact actually joins the gesture.
445///
446/// The [`MULTI_CONTACT_VETO`] borrow is released *before* `f` runs, mirroring
447/// [`with_scroll_claim`].
448pub(crate) fn with_scroll_veto<R>(veto: &Rc<Cell<bool>>, f: impl FnOnce() -> R) -> R {
449    MULTI_CONTACT_VETO.with(|stack| stack.borrow_mut().push(Rc::clone(veto)));
450    let _guard = MultiContactVetoGuard;
451    f()
452}
453
454/// The multi-contact veto cell of the scroll surface currently forwarding a
455/// `Down` — the *nearest enclosing* one — or `None` outside that forward (a
456/// top-level surface's own `Down`, or any non-`Down` event).
457pub(crate) fn ambient_scroll_veto() -> Option<Rc<Cell<bool>>> {
458    MULTI_CONTACT_VETO.with(|stack| stack.borrow().last().cloned())
459}
460
461/// What a surface sitting at `metrics` under `physics` claims it could do with
462/// a drag starting now — the value a nested scrollable writes into its host's
463/// claim cell.
464///
465/// Both directions are answered the same way: *room* in that direction, or a
466/// physics willing to hold a position past that edge. The second half is a
467/// one-pixel [`CLAIM_PROBE_PX`] probe of
468/// [`ScrollPhysics::apply_boundary_conditions`] — nothing rejected means the
469/// surface can answer the drag with a rubber-band even pinned against the edge
470/// (the `Bouncing`/[`RubberBand`](crate::RubberBand) family), while a clamping
471/// physics rejects the probe and genuinely has nothing to give. `registered`
472/// is the physics' own drag gate, the same one the takeover site consults —
473/// **and, on top of it, real capacity** (`max_scroll_extent >
474/// min_scroll_extent`): the bouncing family's `should_accept_user_offset` is
475/// hardcoded `true` regardless of content, which without this conjunct would
476/// make a content-fits inner claim (and defer to) every drag forever, even
477/// though it has nothing to show for it. This deliberately parts from
478/// Flutter, whose `BouncingScrollPhysics` would still bounce a fits-viewport
479/// surface, in favor of UIKit's own default
480/// (`UIScrollView.alwaysBounceVertical == false`): a scrollable with nothing
481/// to scroll does not intercept the gesture. A `NeverScrollable` inner, or
482/// one with real capacity but no physics willing to accept the drag, never
483/// takes a drag from its host either way.
484pub(crate) fn inner_claim_state(
485    physics: &dyn ScrollPhysics,
486    metrics: &ScrollMetrics,
487) -> InnerScrollState {
488    let leading_free = physics
489        .apply_boundary_conditions(metrics, metrics.min_scroll_extent - CLAIM_PROBE_PX)
490        == 0.0;
491    let trailing_free = physics
492        .apply_boundary_conditions(metrics, metrics.max_scroll_extent + CLAIM_PROBE_PX)
493        == 0.0;
494    let has_capacity = metrics.max_scroll_extent > metrics.min_scroll_extent;
495    InnerScrollState {
496        registered: has_capacity && physics.should_accept_user_offset(metrics),
497        can_consume_down_drag: metrics.pixels > metrics.min_scroll_extent || leading_free,
498        can_consume_up_drag: metrics.pixels < metrics.max_scroll_extent || trailing_free,
499    }
500}
501
502/// A scroll observation snapshot handed to [`ScrollView::on_scroll`].
503///
504/// `offset` is the clamped scroll position in `[0, max_offset]`; `overscroll` is
505/// the signed past-edge displacement (negative = pulled past the top, positive =
506/// pulled past the bottom), zero while the surface rests in range. During a
507/// drag past an edge, `offset` pins at the edge and `overscroll` carries the
508/// (resisted) pull.
509#[derive(Debug, Clone, Copy, PartialEq)]
510pub struct ScrollInfo {
511    /// The clamped scroll offset in `[0, max_offset]` (px scrolled down).
512    pub offset: f64,
513    /// The maximum scroll offset (`content − viewport`, never negative).
514    pub max_offset: f64,
515    /// Signed past-edge displacement: negative past the top, positive past the
516    /// bottom, `0.0` while in range.
517    pub overscroll: f64,
518}
519
520/// A view-held scroll-observation callback (erased to [`ErasedArgCallback`] on
521/// build).
522type OnScroll<State> = Rc<dyn Fn(&mut State, ScrollInfo)>;
523
524/// A view-held pull-to-refresh release callback (erased to [`ErasedCallback`] on
525/// build).
526type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
527
528/// A declarative vertical scroll surface. See the [module docs](self).
529pub struct ScrollView<State: 'static> {
530    child: AnyView<State>,
531    /// Fired whenever the offset/overscroll changes (drag, wheel, or — one event
532    /// late — fling/settle). See [`ScrollView::on_scroll`].
533    on_scroll: Option<OnScroll<State>>,
534    /// Fired on pointer `Up` when the past-top overscroll exceeded
535    /// [`REFRESH_TRIGGER_PX`]. See [`ScrollView::on_refresh_release`].
536    on_refresh_release: Option<OnRefresh<State>>,
537    /// A custom [`ScrollPhysics`] installed via [`ScrollView::physics`], or
538    /// `None` to leave whatever is already installed on the widget alone.
539    /// `Rc`, not `Box`: [`ScrollWidget::physics`] is shared-immutable widget
540    /// state, so a cheap `Rc::clone` is what a `rebuild` (which only ever sees
541    /// `&self`) can hand across without a `Box<dyn ScrollPhysics>`-isn't-`Clone`
542    /// reconstruction problem. See [`ScrollView::physics`] for the full
543    /// build/rebuild contract.
544    physics: Option<Rc<dyn ScrollPhysics>>,
545    /// How past-edge pull is visualized, carried down to
546    /// [`ScrollWidget::effect`] on every build/rebuild. See
547    /// [`ScrollView::overscroll_effect`].
548    pub(crate) effect: OverscrollEffect,
549}
550
551impl<State: 'static> ScrollView<State> {
552    /// Wrap `child` in a vertical scroll view.
553    pub fn new<V: View<State>>(child: V) -> Self {
554        Self {
555            child: any(child),
556            on_scroll: None,
557            on_refresh_release: None,
558            physics: None,
559            effect: default_overscroll_effect(),
560        }
561    }
562
563    /// Observe scroll position changes. The callback receives a [`ScrollInfo`]
564    /// snapshot each time the offset or overscroll changes due to input (drag,
565    /// wheel), and — one event late — for fling/settle motion driven at paint
566    /// time (the paint pass carries no [`EventCtx`], so the notification is
567    /// recorded and delivered on the next event, the same controlled-component
568    /// convention the interactive widgets follow). A `Cancel` clears any pending
569    /// notification without firing it.
570    pub fn on_scroll<F: Fn(&mut State, ScrollInfo) + 'static>(mut self, callback: F) -> Self {
571        self.on_scroll = Some(Rc::new(callback));
572        self
573    }
574
575    /// The pull-to-refresh trigger: fires on pointer `Up` when the surface was
576    /// pulled past the top by more than [`REFRESH_TRIGGER_PX`] (post-resistance),
577    /// so an app gets a single "release past threshold" signal without
578    /// reimplementing overscroll thresholding. Never fires on a `Cancel`.
579    pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
580        self.on_refresh_release = Some(Rc::new(callback));
581        self
582    }
583
584    /// Install a custom [`ScrollPhysics`] strategy — the pluggable
585    /// drag-mapping/boundary-rejection/post-release-motion contract described
586    /// in [`crate::physics`], with the platform-parity physics
587    /// ([`crate::physics::parity`]'s `Bouncing`/`Clamping`/
588    /// `AlwaysScrollable`/`NeverScrollable`) and the pre-seam
589    /// [`RubberBand`](crate::RubberBand) as the built-in implementations.
590    ///
591    /// ```
592    /// use frust_widgets::{NeverScrollable, ScrollView, scroll_view, text};
593    /// let view: ScrollView<()> = scroll_view(text("hi")).physics(NeverScrollable::new());
594    /// # let _ = view;
595    /// ```
596    ///
597    /// # Build/rebuild semantics
598    ///
599    /// A view built (or rebuilt) *with* `.physics(...)` installs it on the
600    /// widget every time — like [`ScrollView::on_scroll`]'s erased callback,
601    /// a trait object isn't comparable, so this reinstalls unconditionally
602    /// rather than diffing. A view built (or rebuilt) *without*
603    /// `.physics(...)` leaves whatever the widget already has installed
604    /// untouched: a fresh `build` still starts the widget at
605    /// [`crate::physics::default_physics`] (the widget's own constructor
606    /// default), but rebuilding *from* a `.physics(...)`-carrying view *to* a
607    /// plain one does not revert it — there is no spelling for "go back to the
608    /// default" versus "no opinion this rebuild", and this picks the latter,
609    /// the same shape [`ScrollWidget::effect`] already followed before this
610    /// method existed.
611    ///
612    /// Defaults to the platform-adaptive physics
613    /// ([`crate::physics::default_physics`] — Android clamping, elsewhere
614    /// bouncing) if never called; `.physics(RubberBand::new())` is how an app
615    /// asks for the pre-seam rubber-band feel instead.
616    pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
617        self.physics = Some(Rc::new(physics));
618        self
619    }
620
621    /// Select how past-edge pull is visualized. See [`OverscrollEffect`]
622    /// ([`crate::physics::effect`]) for the full contract: move the content
623    /// with the pull, paint a Material-3-Expressive edge stretch about the
624    /// held edge, or show no visual at all.
625    ///
626    /// ```
627    /// use frust_widgets::{OverscrollEffect, ScrollView, scroll_view, text};
628    /// let view: ScrollView<()> = scroll_view(text("hi")).overscroll_effect(OverscrollEffect::Stretch);
629    /// # let _ = view;
630    /// ```
631    ///
632    /// Plain view-owned data, unlike [`ScrollView::physics`]: every
633    /// build/rebuild carries the current value down to
634    /// [`ScrollWidget::effect`] unconditionally.
635    ///
636    /// Defaults to the effect paired with the platform's default physics
637    /// ([`crate::physics::default_overscroll_effect`]): the M3E
638    /// [`OverscrollEffect::Stretch`] on Android, translate overscroll
639    /// ([`OverscrollEffect::Translate`]) everywhere else.
640    pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
641        self.effect = effect;
642        self
643    }
644}
645
646/// Wrap `child` in a vertical [`ScrollView`] — the free-function spelling of
647/// [`ScrollView::new`].
648pub fn scroll_view<State: 'static, V: View<State>>(child: V) -> ScrollView<State> {
649    ScrollView::new(child)
650}
651
652/// A running [`Simulation`] and the frame clock it is measured from — the
653/// state behind the generic ballistic driver both scroll surfaces run when
654/// their physics hands one back (`pub(crate)`: shared with
655/// [`crate::list_view::ListViewWidget`]'s parallel pump rather than
656/// hand-copied, like the constants above).
657pub(crate) struct BallisticState {
658    /// The physics-built curve, in **seconds** from its own start.
659    pub(crate) sim: Box<dyn Simulation>,
660    /// The frame time the first paint-time pump seeded, or `None` before it —
661    /// the same zero-delta seeding convention `last_anim` uses, so the release
662    /// itself (which carries no clock) never has to guess a start.
663    pub(crate) start: Option<FrameTime>,
664}
665
666impl BallisticState {
667    /// Seconds elapsed at frame time `now`; `0.0` until the clock is seeded.
668    pub(crate) fn elapsed_secs(&self, now: FrameTime) -> f64 {
669        match self.start {
670            Some(start) => now.saturating_sub(start).as_secs_f64(),
671            None => 0.0,
672        }
673    }
674}
675
676/// The retained widget for a [`ScrollView`].
677pub struct ScrollWidget {
678    child: ChildPod,
679    /// Current *effective* scroll offset (px scrolled down). Normally in
680    /// `[0, max_offset]`, but a drag past an edge lets it go out of range (with
681    /// [`OVERSCROLL_RESISTANCE`] applied) until the release-settle brings it back;
682    /// the fling/wheel/layout paths still hard-clamp via [`ScrollWidget::set_offset`].
683    offset: f64,
684    /// Resolved viewport size (this widget's own size).
685    viewport: Size,
686    /// The child's (content) size after an unbounded-height layout.
687    content: Size,
688    /// Whether we have taken the gesture over as a scroll drag.
689    scrolling: bool,
690    /// The physics-mapped drag position accumulated during an active scroll
691    /// drag, **before** boundary rejection: seeded from `offset` at takeover
692    /// and advanced by each move's mapped delta
693    /// ([`ScrollWidget::apply_drag_offset`]). It equals `offset` under any
694    /// physics that rejects nothing, and runs off past the edge under a
695    /// clamping one — which is exactly what lets a pinned surface still report
696    /// how hard the finger is pulling.
697    drag_position: f64,
698    /// Whether a release-settle animation is returning an overscrolled surface to
699    /// its clamped edge (driven at paint via [`ScrollWidget::settle_tick`]).
700    settling: bool,
701    /// The installed scroll physics — [`crate::physics::default_physics`]'s
702    /// platform-adaptive choice unless [`ScrollView::physics`] replaces it.
703    /// `Rc`, not `Box`: every [`ScrollPhysics`] method takes `&self`, so a
704    /// shared, immutable handle is both cheap to (re)install (a `Rc::clone`,
705    /// not a fresh reconstruction — `Box<dyn ScrollPhysics>` isn't `Clone`)
706    /// and sufficient, since nothing here ever needs `&mut` access to it.
707    /// Survives a rebuild whose view carries no `.physics(...)` call
708    /// untouched; see [`ScrollView::physics`] for the full contract and the
709    /// [module docs](self)' *Physics seam*.
710    pub(crate) physics: Rc<dyn ScrollPhysics>,
711    /// How past-edge pull is visualized —
712    /// [`crate::physics::default_overscroll_effect`]'s platform pairing unless
713    /// [`ScrollView::overscroll_effect`] names one. Read at paint alone (see
714    /// [`ScrollWidget::painted_offset`] and the module docs' *Overscroll
715    /// visuals*), never by layout or by the physics.
716    pub(crate) effect: OverscrollEffect,
717    /// The signed pull past an edge, in the same sense as
718    /// [`ScrollInfo::overscroll`]: **negative past the top**, positive past the
719    /// bottom, `0.0` in range. Its two halves are the displacement the physics
720    /// *allowed* (what [`ScrollInfo::overscroll`] reports) plus whatever
721    /// [`ScrollPhysics::apply_boundary_conditions`] *rejected* while the
722    /// position was pinned at the edge — so a clamping physics, whose position
723    /// never leaves range, still reports how hard the finger is pulling
724    /// (`physics`' design ruling). Under a physics that rejects nothing
725    /// (`Bouncing`, [`RubberBand`](crate::RubberBand)) this is exactly the
726    /// overscroll, which is why basing the [`REFRESH_TRIGGER_PX`] check on it
727    /// changes no trigger distance for either of them.
728    ///
729    /// Re-derived from [`ScrollWidget::drag_position`] on every drag move
730    /// (never summed across moves, which would strand a rejected pull the
731    /// finger has since eased back), re-seeded from the live displacement on
732    /// `Down`, re-derived again on every ballistic pump, decayed by the
733    /// release-settle, and zeroed by the wheel/`Cancel` hard clamps. A
734    /// ballistic that ends with a pull outstanding hands it to that same settle
735    /// ([`ScrollWidget::settle_ballistic_residual`]) rather than leaving it
736    /// standing — the settle is the only path back to `0.0` that animates.
737    pub(crate) edge_pull: f64,
738    /// A generic ballistic simulation handed back by
739    /// [`ScrollPhysics::create_ballistic_simulation`] on release, or `None` —
740    /// always `None` under [`RubberBand`](crate::RubberBand), which keeps the
741    /// legacy [`ScrollWidget::fling`]/[`ScrollWidget::settling`] path instead.
742    ballistic: Option<BallisticState>,
743    /// Velocity (px/s of offset) of the motion a new `Down` interrupted, fed to
744    /// [`ScrollPhysics::carried_momentum`] at the next fling start. `Down` is
745    /// its only writer and always writes it (`0.0` when the press landed on a
746    /// resting surface), so it can never carry a stale value into a later
747    /// gesture.
748    carried_velocity: f64,
749    /// A scroll notification produced by the paint-time fling/settle pump (which
750    /// carries no [`EventCtx`]); delivered to `on_scroll` on the next event and
751    /// cleared by a `Cancel` without firing.
752    pending_scroll_notify: bool,
753    /// The scroll observation callback (`None` if the view set none).
754    on_scroll: Option<ErasedArgCallback<ScrollInfo>>,
755    /// The pull-to-refresh release callback (`None` if the view set none).
756    on_refresh_release: Option<ErasedCallback>,
757    /// Whether a `Down` has armed an active gesture (distinct from `scrolling`,
758    /// which only becomes true after the drag passes the slop). Set on `Down`
759    /// and cleared on `Up`/`Cancel`; the slop/takeover math runs only while it
760    /// is true, so a hover `Move` (dispatched on every cursor motion) is never
761    /// mistaken for a drag and can never take the gesture from a child.
762    down_active: bool,
763    /// What the nearest nested scroll surface claimed it could do with this
764    /// gesture, read back out of the claim cell this widget pushed around the
765    /// `Down`'s forward — the whole input to the innermost-wins decision at the
766    /// takeover site (the module docs' *Nested scrolling*). Reset on `Down`
767    /// (before that forward) and on `Up`/`Cancel`, so it can never carry a
768    /// previous gesture's answer.
769    ///
770    /// **Accepted staleness**: this is a `Down`-time snapshot. An inner
771    /// revealed (scrolled off the edge it was pinned against) or re-pinned
772    /// *during* the gesture never re-registers, and the decision taken from it
773    /// is never revisited — the same limitation the navigator's edge-swipe
774    /// claim accepts for exactly the same reason (one arbitration point per
775    /// gesture, decided once).
776    pub(crate) inner_at_down: InnerScrollState,
777    /// Whether this gesture was handed to the nested surface at the takeover
778    /// site. A **sticky** decision for the rest of the gesture: every remaining
779    /// `Move` is forwarded untouched and the takeover math never runs again, so
780    /// a mid-gesture direction reversal cannot steal the drag back (v1 — the
781    /// finger is already inside the inner's own drag by then, and taking over
782    /// would mean cancelling a scroll in flight). Cleared with
783    /// [`ScrollWidget::inner_at_down`].
784    pub(crate) deferring: bool,
785    /// A live veto a nested multi-contact recognizer (`pinch_detector`,
786    /// [`crate::pan_zoom`]'s child-owned-press branch) can raise for the rest
787    /// of this gesture once it is tracking more than the claimant's own
788    /// contact — the live counterpart to `inner_at_down` for a case the
789    /// `Down`-time snapshot cannot see (the module docs' *Multi-contact
790    /// veto*). Checked on every `Move` at the takeover site, not read once:
791    /// the recognizer flips it live as a second contact joins and leaves.
792    /// Replaced with a fresh, unset cell on every `Down` (and cleared again
793    /// on `Up`/`Cancel`) so a stale recognizer handle from a previous gesture
794    /// can never veto this one.
795    live_veto: Rc<Cell<bool>>,
796    down_start: Point,
797    last_drag: Point,
798    tracker: VelocityTracker,
799    /// Active fling velocity, in px/s of *offset* (opposite the finger), or
800    /// `None` when not flinging.
801    fling: Option<f64>,
802    /// The most recent frame time observed during [`Widget::paint`], reused as the
803    /// event-pass timestamp for velocity tracking (the event pass carries no clock
804    /// of its own — time is provided only at paint; the fling starts from the
805    /// last paint clock, which is today's behavior too).
806    last_frame_time: FrameTime,
807    /// Last animation frame time for the paint-time fling pump; `None` seeds the
808    /// clock (zero-delta) on the first paint after a release.
809    last_anim: Option<FrameTime>,
810}
811
812impl ScrollWidget {
813    fn new(child: ChildPod) -> Self {
814        Self {
815            child,
816            offset: 0.0,
817            viewport: Size::ZERO,
818            content: Size::ZERO,
819            scrolling: false,
820            drag_position: 0.0,
821            settling: false,
822            physics: default_physics(),
823            effect: default_overscroll_effect(),
824            edge_pull: 0.0,
825            ballistic: None,
826            carried_velocity: 0.0,
827            pending_scroll_notify: false,
828            on_scroll: None,
829            on_refresh_release: None,
830            down_active: false,
831            inner_at_down: InnerScrollState::default(),
832            deferring: false,
833            live_veto: Rc::new(Cell::new(false)),
834            down_start: Point::ZERO,
835            last_drag: Point::ZERO,
836            tracker: VelocityTracker::new(),
837            fling: None,
838            last_frame_time: FrameTime::ZERO,
839            last_anim: None,
840        }
841    }
842
843    /// The current scroll offset.
844    pub fn offset(&self) -> f64 {
845        self.offset
846    }
847
848    /// The maximum scroll offset (`content − viewport`, never negative).
849    pub fn max_offset(&self) -> f64 {
850        (self.content.height - self.viewport.height).max(0.0)
851    }
852
853    /// Whether post-release motion is in flight — the legacy fling, or a
854    /// physics-supplied [`Simulation`] the generic driver is running (never
855    /// both, and never either one under [`RubberBand`](crate::RubberBand)'s
856    /// legacy-only path).
857    pub fn is_flinging(&self) -> bool {
858        self.fling.is_some() || self.ballistic.is_some()
859    }
860
861    /// This surface's extent/position snapshot for the physics, reading
862    /// `pixels` from a caller-supplied position rather than the live offset —
863    /// the drag path asks about the *raw* (un-resisted) drag position, clamped
864    /// into range, so resistance is derived from the accumulator instead of
865    /// compounding across moves.
866    fn metrics_at(&self, pixels: f64) -> ScrollMetrics {
867        ScrollMetrics {
868            pixels,
869            min_scroll_extent: 0.0,
870            max_scroll_extent: self.max_offset(),
871            viewport_dimension: self.viewport.height,
872            device_pixel_ratio: METRICS_FALLBACK_DPR,
873        }
874    }
875
876    /// This surface's extent/position snapshot at its current effective offset.
877    fn metrics(&self) -> ScrollMetrics {
878        self.metrics_at(self.offset)
879    }
880
881    /// The signed distance the effective offset currently sits past an edge
882    /// (negative past the top, positive past the bottom, `0.0` in range) — the
883    /// value [`ScrollInfo::overscroll`] reports and the allowed half of
884    /// [`ScrollWidget::edge_pull`].
885    fn displacement(&self) -> f64 {
886        self.offset - self.offset.clamp(0.0, self.max_offset())
887    }
888
889    /// The velocity (px/s of offset) of whatever post-release motion is live
890    /// right now — the legacy fling's own, or a running simulation's at the
891    /// last painted frame — and `0.0` when the surface is at rest.
892    fn live_velocity(&self) -> f64 {
893        if let Some(v) = self.fling {
894            return v;
895        }
896        match self.ballistic.as_ref() {
897            Some(state) => state.sim.dx(state.elapsed_secs(self.last_frame_time)),
898            None => 0.0,
899        }
900    }
901
902    /// A new fling's starting velocity: the `release` velocity plus whatever
903    /// [`ScrollPhysics::carried_momentum`] carries over from the motion this
904    /// gesture's `Down` interrupted. `Down` is the only writer of
905    /// [`ScrollWidget::carried_velocity`] (and always writes it), so the
906    /// remembered value is never stale; [`RubberBand`](crate::RubberBand)
907    /// carries `0.0`, leaving `release` untouched.
908    ///
909    /// **Momentum is carried only onto a release that plainly continues the
910    /// interrupted motion**: same sign, and faster than
911    /// [`MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR`] of the physics' own
912    /// **mapped** share of the interrupted velocity —
913    /// `physics.carried_momentum(carried)`, the exact value the release is
914    /// about to add, not the raw interrupted speed. Flutter's two
915    /// `ScrollDragController.end` guards compare against `carriedMomentum`
916    /// the same way; `Bouncing`'s fitted power curve sits below the raw speed
917    /// under ~1563 px/s and above it beyond, so gating on the mapped value
918    /// (rather than the raw one) changes where the threshold actually sits.
919    /// Both guards are load-bearing, not polish — the carried term is
920    /// comparable in magnitude to an ordinary release, so adding it to a
921    /// flick back the other way cancels the finger's own velocity out or
922    /// reverses it outright.
923    ///
924    /// **Accepted gap**: Flutter drops the carried velocity a third way, when
925    /// the finger held still before letting go (`_maybeLoseMomentum`); that
926    /// guard is not ported. A press that stalls live motion, pauses, then
927    /// releases slowly in the same direction still carries momentum here.
928    ///
929    /// The single carry site for both release branches — the generic
930    /// ballistic driver ([`ScrollWidget::release_simulation`]) and the legacy
931    /// fling — so neither can grow a rule of its own.
932    fn fling_start_velocity(&self, release: f64) -> f64 {
933        let mapped = self.physics.carried_momentum(self.carried_velocity);
934        let continues_it = release.signum() == mapped.signum()
935            && release.abs() > MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped.abs();
936        if continues_it {
937            release + mapped
938        } else {
939            release
940        }
941    }
942
943    /// Ask the installed physics for post-release motion at the release
944    /// velocity its own bounds allow: under
945    /// [`ScrollPhysics::min_fling_velocity`] the release is not a fling at all
946    /// (the physics may still want to spring an overscrolled surface back,
947    /// just from rest), and over [`ScrollPhysics::max_fling_velocity`] it
948    /// clamps. **Those bounds govern the generic driver only** — the legacy
949    /// fling path keeps its own pinned [`FLING_STOP`] threshold and no upper
950    /// clamp, so installing a physics that returns `None` here (as
951    /// [`RubberBand`](crate::RubberBand) does) cannot change a single shipped
952    /// fling.
953    fn release_simulation(&self) -> Option<Box<dyn Simulation>> {
954        // The offset moves opposite the finger, like every other release path.
955        let released = self.fling_start_velocity(-self.tracker.velocity());
956        let max = self.physics.max_fling_velocity();
957        let velocity = if released.abs() < self.physics.min_fling_velocity() {
958            0.0
959        } else {
960            released.clamp(-max, max)
961        };
962        self.physics
963            .create_ballistic_simulation(&self.metrics(), velocity)
964    }
965
966    /// The last painted frame time as milliseconds — the event-pass timestamp
967    /// source for velocity tracking (see [`ScrollWidget::last_frame_time`]).
968    fn event_time_ms(&self) -> f64 {
969        self.last_frame_time.as_secs_f64() * 1000.0
970    }
971
972    fn set_offset(&mut self, value: f64) {
973        self.offset = value.clamp(0.0, self.max_offset());
974    }
975
976    /// The offset the content is actually **painted** at, which is the live
977    /// `offset` — past-edge displacement and all — only under
978    /// [`OverscrollEffect::Translate`]. [`OverscrollEffect::Stretch`] paints
979    /// the pull as a scale about the held edge instead, and
980    /// [`OverscrollEffect::None`] paints it not at all, so both leave the
981    /// content exactly where an in-range offset would put it.
982    ///
983    /// Subtracted back off rather than never computed: the offset itself still
984    /// moves precisely as the physics dictates under every effect (the module
985    /// docs' *Overscroll visuals*), so only this one read differs.
986    fn painted_offset(&self) -> f64 {
987        match self.effect {
988            OverscrollEffect::Translate => self.offset,
989            OverscrollEffect::Stretch | OverscrollEffect::None => self.offset - self.displacement(),
990        }
991    }
992
993    fn sync_child_origin(&mut self) {
994        self.child
995            .set_origin(Point::new(0.0, -self.painted_offset()));
996    }
997
998    /// A snapshot of the current scroll position for [`ScrollView::on_scroll`]:
999    /// the clamped `offset`, the `max_offset`, and the signed past-edge
1000    /// `overscroll` (negative past the top). See [`ScrollInfo`].
1001    fn scroll_info(&self) -> ScrollInfo {
1002        let max = self.max_offset();
1003        ScrollInfo {
1004            offset: self.offset.clamp(0.0, max),
1005            max_offset: max,
1006            overscroll: self.displacement(),
1007        }
1008    }
1009
1010    /// Fire `on_scroll` (if set) with the current [`ScrollInfo`]. Called from the
1011    /// event pass after an input-driven offset/overscroll change.
1012    fn notify_scroll(&mut self, ctx: &mut EventCtx) {
1013        let info = self.scroll_info();
1014        if let Some(cb) = self.on_scroll.as_mut() {
1015            cb(ctx, info);
1016        }
1017    }
1018
1019    /// Deliver a fling/settle notification recorded at paint time (which had no
1020    /// [`EventCtx`]) on the next event — one event of latency, the same
1021    /// controlled-component convention the fling clock already relies on.
1022    fn deliver_pending_scroll(&mut self, ctx: &mut EventCtx) {
1023        if self.pending_scroll_notify {
1024            self.pending_scroll_notify = false;
1025            self.notify_scroll(ctx);
1026        }
1027    }
1028
1029    /// Advance the drag by `delta` px of raw finger travel in offset space
1030    /// (positive = the content scrolls down), asking the physics what that
1031    /// delta is worth from where the surface actually sits — the module docs'
1032    /// *Drag convention*.
1033    ///
1034    /// Three steps, in order: the physics maps the raw delta at metrics
1035    /// reporting the **live** position (so a depth-aware curve reads a real
1036    /// overscroll depth); the mapped delta accumulates into
1037    /// [`ScrollWidget::drag_position`]; and the boundary rule decides how much
1038    /// of that accumulated position the surface may actually hold. What it
1039    /// rejects never reaches `offset` but is still reported through
1040    /// [`ScrollWidget::edge_pull`], which is what lets a clamping surface drive
1041    /// pull-to-refresh and a stretch effect.
1042    fn apply_drag_offset(&mut self, delta: f64) {
1043        let metrics = self.metrics_at(self.offset);
1044        let mapped = self.physics.apply_physics_to_user_offset(&metrics, delta);
1045        self.drag_position += mapped;
1046        let rejected = self
1047            .physics
1048            .apply_boundary_conditions(&metrics, self.drag_position);
1049        self.offset = self.drag_position - rejected;
1050        // Read the allowed half back off the offset rather than reusing the
1051        // term above, so this is the *same* number `scroll_info` reports and
1052        // the two can never disagree by a rounding step at the bottom edge.
1053        self.edge_pull = self.displacement() + rejected;
1054    }
1055
1056    /// Advance a release-settle by `dt_ms`, easing the effective `offset` back to
1057    /// its clamped edge and returning whether it is still animating. Pure and
1058    /// deterministic (mirrors [`ScrollWidget::tick`]); the paint pump and the
1059    /// tests both drive it.
1060    pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
1061        if !self.settling {
1062            return false;
1063        }
1064        let max = self.max_offset();
1065        let target = self.offset.clamp(0.0, max);
1066        let remaining = target - self.offset;
1067        // `edge_pull`'s boundary-rejected half (always `0.0` under
1068        // `RubberBand`, where the pull *is* the displacement) has no position
1069        // to ride back, so it decays on the same curve of its own — otherwise
1070        // a clamping physics' stretch would snap off at release.
1071        let rejected = self.edge_pull - self.displacement();
1072        if remaining.abs() <= SETTLE_STOP_PX && rejected.abs() <= SETTLE_STOP_PX {
1073            self.offset = target;
1074            self.settling = false;
1075            self.edge_pull = 0.0;
1076            self.sync_child_origin();
1077            return false;
1078        }
1079        let retained = SETTLE_DECAY.powf(dt_ms);
1080        self.offset = target - remaining * retained;
1081        self.edge_pull = self.displacement() + rejected * retained;
1082        self.sync_child_origin();
1083        true
1084    }
1085
1086    /// Advance an in-flight fling by `dt_ms`, returning whether it is still
1087    /// animating. Pure and deterministic — the paint-time pump and the tests
1088    /// both drive it.
1089    pub fn tick(&mut self, dt_ms: f64) -> bool {
1090        let Some(v) = self.fling else {
1091            return false;
1092        };
1093        self.set_offset(self.offset + fling_displacement(v, dt_ms));
1094        self.sync_child_origin();
1095        let next_v = fling_decay(v, dt_ms);
1096        let at_bound = self.offset <= 0.0 || self.offset >= self.max_offset();
1097        if next_v.abs() < FLING_STOP || at_bound {
1098            self.fling = None;
1099            false
1100        } else {
1101            self.fling = Some(next_v);
1102            true
1103        }
1104    }
1105
1106    /// Whether the running simulation can no longer move anything on screen:
1107    /// the position it proposed sits outside the range, the physics rejected
1108    /// **all** of that excess (so the painted offset is already pinned at the
1109    /// edge), and the curve is still travelling further out. Nothing it reports
1110    /// after that can reach the offset, so the driver ends it here rather than
1111    /// asking the shell for the rest of the spline's worth of frames — an
1112    /// Android-style fling into an edge otherwise pumps a second of them with
1113    /// the surface stock-still.
1114    ///
1115    /// **Deliberately conservative.** A *partial* rejection (a physics holding
1116    /// some of the excess) and an inward velocity each keep the simulation
1117    /// running, because either can still bring the position back in range: an
1118    /// edge spring released outward crosses back within a few frames, and only
1119    /// the fully-rejected-and-still-outward case is one-way for every curve in
1120    /// [`crate::physics::simulation`]. The comparison against the raw excess is
1121    /// exact rather than tolerant for the same reason — a physics whose
1122    /// rejection merely rounds to the excess keeps the old pump-to-done
1123    /// behavior instead of being guessed at.
1124    fn ballistic_is_pinned_outward(&self, proposed: f64, rejected: f64, velocity: f64) -> bool {
1125        let excess = proposed - proposed.clamp(0.0, self.max_offset());
1126        excess != 0.0 && rejected == excess && velocity * excess > 0.0
1127    }
1128
1129    /// Hand a residual [`ScrollWidget::edge_pull`] left behind by a finished
1130    /// ballistic to the release-settle, so it decays on [`SETTLE_DECAY`]
1131    /// exactly as a drag release's pull does instead of standing on screen
1132    /// until the next `Down`/wheel/`Cancel`. Only the *rejected* half can be
1133    /// left over — the offset is wherever the simulation put it — and
1134    /// [`ScrollWidget::settle_tick`] already decays that half on its own curve.
1135    ///
1136    /// A simulation that ends in range leaves nothing to settle and this is a
1137    /// no-op: the guard is [`SETTLE_STOP_PX`], the same distance `settle_tick`
1138    /// itself calls settled, so a bouncing spring's sub-pixel float residue
1139    /// never arms an animation that would stop on its first tick.
1140    fn settle_ballistic_residual(&mut self) {
1141        if self.edge_pull.abs() > SETTLE_STOP_PX {
1142            self.settling = true;
1143        }
1144    }
1145
1146    /// Advance a physics-supplied [`Simulation`] to frame time `now`: the
1147    /// position it reports, minus whatever
1148    /// [`ScrollPhysics::apply_boundary_conditions`] rejects of it. Subtracting
1149    /// the rejection is what keeps the driver honest for **any** physics — a
1150    /// clamping one can never paint an out-of-range offset even if its
1151    /// simulation overshoots, while a bouncing one (rejecting nothing) is free
1152    /// to run past the edge and back.
1153    ///
1154    /// Clears the simulation once it reports itself done — or once it is
1155    /// [pinned outward](ScrollWidget::ballistic_is_pinned_outward) and can
1156    /// never move the offset again — which is what stops the pump asking for
1157    /// frames, and hands any pull the rejection left behind to the settle
1158    /// ([`ScrollWidget::settle_ballistic_residual`]).
1159    fn drive_ballistic(&mut self, now: FrameTime) {
1160        let Some((proposed, velocity, done)) = self.ballistic.as_ref().map(|state| {
1161            let t = state.elapsed_secs(now);
1162            (state.sim.x(t), state.sim.dx(t), state.sim.is_done(t))
1163        }) else {
1164            return;
1165        };
1166        let rejected = self
1167            .physics
1168            .apply_boundary_conditions(&self.metrics(), proposed);
1169        self.offset = proposed - rejected;
1170        self.edge_pull = self.displacement() + rejected;
1171        self.sync_child_origin();
1172        if done || self.ballistic_is_pinned_outward(proposed, rejected, velocity) {
1173            self.ballistic = None;
1174            self.settle_ballistic_residual();
1175        }
1176    }
1177
1178    /// Advance the ballistic simulation, the legacy fling, *or* the
1179    /// release-settle by the delta since the last paint, and signal
1180    /// [`PaintCtx::request_frame`] while any of them is still running so the
1181    /// shell keeps scheduling frames (the desktop `ControlFlow::Wait` loop
1182    /// would otherwise idle). A fling stops once [`ScrollWidget::tick`] brings it
1183    /// to rest (`|velocity|` below [`FLING_STOP`], or a scroll bound reached); a
1184    /// settle stops once [`ScrollWidget::settle_tick`] reaches the edge; a
1185    /// simulation stops when it reports itself done or is pinned outward.
1186    /// Because this path carries no [`EventCtx`], an offset change here records
1187    /// a pending `on_scroll` notification delivered on the next event.
1188    ///
1189    /// The three are mutually exclusive at any one instant — a release picks
1190    /// one — though a simulation ending against an edge hands the pull it left
1191    /// behind to the settle for the frames after it
1192    /// ([`ScrollWidget::settle_ballistic_residual`]). Under
1193    /// [`RubberBand`](crate::RubberBand) the simulation arm is never taken at
1194    /// all.
1195    fn pump_fling(&mut self, ctx: &mut PaintCtx) {
1196        if self.fling.is_none() && !self.settling && self.ballistic.is_none() {
1197            self.last_anim = None;
1198            return;
1199        }
1200        let now = ctx.frame_time();
1201        let dt = match self.last_anim {
1202            Some(t) => now.saturating_sub(t).as_secs_f64() * 1000.0,
1203            None => 0.0,
1204        };
1205        self.last_anim = Some(now);
1206        // A simulation measures time from its own start, so the first pump
1207        // after the release seeds it — the same zero-delta seeding frame
1208        // `last_anim` takes, so neither clock ever jumps on frame one.
1209        if let Some(state) = self.ballistic.as_mut() {
1210            state.start.get_or_insert(now);
1211        }
1212        if dt > 0.0 {
1213            if self.ballistic.is_some() {
1214                self.drive_ballistic(now);
1215            } else if self.fling.is_some() {
1216                self.tick(dt);
1217            } else {
1218                self.settle_tick(dt);
1219            }
1220            // The offset moved from a non-input source — record a notification the
1221            // next event delivers (the paint pass has no EventCtx to fire it now).
1222            self.pending_scroll_notify = true;
1223        }
1224        // While any animation is still in flight, ask the shell for another
1225        // frame to continue it.
1226        if self.fling.is_some() || self.settling || self.ballistic.is_some() {
1227            ctx.request_frame();
1228        }
1229    }
1230
1231    fn send_child_cancel(&mut self, ctx: &mut EventCtx, pos: Point) {
1232        let cancel = InputEvent::Pointer(PointerEvent {
1233            phase: PointerPhase::Cancel,
1234            position: pos,
1235            button: PointerButton::Primary,
1236        });
1237        self.child.event_child(ctx, &cancel);
1238    }
1239
1240    /// The event body, parameterised on an explicit timestamp so velocity math
1241    /// is deterministic in tests; [`Widget::event`] supplies the real clock.
1242    fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
1243        // A fling/settle notification recorded at paint time is delivered on the
1244        // next event — except a Cancel, which clears it without firing (below).
1245        if !matches!(
1246            event,
1247            InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
1248        ) {
1249            self.deliver_pending_scroll(ctx);
1250        }
1251        match event {
1252            // A broadcast is not user input: it bypasses the whole gesture
1253            // machinery, reaches the child whether or not it is focused or
1254            // captured, and is never consumed (`crate::authoring::route_event`'s
1255            // contract, applied to this widget's hand-rolled routing). A floated
1256            // surface's own input travels the same way — it has to reach an
1257            // overlay owner anywhere below this viewport, and a scroll gesture
1258            // must never swallow it.
1259            InputEvent::Housekeeping | InputEvent::Overlay(_) => {
1260                self.child.event_child(ctx, event);
1261                EventResult::Ignored
1262            }
1263            // Focus-routed events (Key/Ime, and the clipboard verbs an
1264            // `EditCommand` carries) bypass the scroll gesture machinery and go
1265            // straight to the child if it holds the recorded focus path.
1266            InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
1267                if self.child.is_focused() {
1268                    self.child.event_child(ctx, event)
1269                } else {
1270                    EventResult::Ignored
1271                }
1272            }
1273            InputEvent::Scroll { delta, .. } => {
1274                let dy = match delta {
1275                    ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
1276                    ScrollDelta::Pixels(_, y) => *y,
1277                };
1278                // Wheel scrolling stays hard-clamped — no overscroll rubber-band on
1279                // desktop wheel input, and no physics consulted: the clamp is a
1280                // property of the input device, not of the installed feel, so
1281                // this arm is identical under every physics.
1282                self.fling = None;
1283                self.settling = false;
1284                self.ballistic = None;
1285                self.edge_pull = 0.0;
1286                self.set_offset(self.offset + dy);
1287                self.sync_child_origin();
1288                self.notify_scroll(ctx);
1289                ctx.request_redraw();
1290                EventResult::Handled
1291            }
1292            InputEvent::Pointer(p) => match p.phase {
1293                PointerPhase::Down => {
1294                    // Only a primary press arms a drag. A secondary press is a
1295                    // context gesture: it still reaches the child (a
1296                    // context-menu consumer inside the viewport must see it),
1297                    // but opens no capture and can never start a scroll. The
1298                    // wheel arm above is unaffected — it carries no button.
1299                    if !presses(p) {
1300                        return self.child.event_child(ctx, event);
1301                    }
1302                    self.scrolling = false;
1303                    self.down_active = true;
1304                    self.inner_at_down = InnerScrollState::default();
1305                    self.deferring = false;
1306                    // A fresh, unset cell for this gesture — never the
1307                    // previous one, which a since-torn-down recognizer may
1308                    // still hold a clone of.
1309                    self.live_veto = Rc::new(Cell::new(false));
1310                    // Remember what this press interrupted before killing it —
1311                    // the next fling asks the physics how much of it to carry
1312                    // forward (`0.0` under `RubberBand`, i.e. start cold).
1313                    self.carried_velocity = self.live_velocity();
1314                    self.fling = None;
1315                    self.settling = false;
1316                    self.ballistic = None;
1317                    // A `Down` deliberately leaves a mid-bounce displacement on
1318                    // screen (the regrab continues from it), so the pull is
1319                    // re-seeded from that displacement rather than zeroed —
1320                    // what "reset" means here is "carries nothing stale from
1321                    // the previous gesture".
1322                    self.edge_pull = self.displacement();
1323                    self.last_anim = None;
1324                    self.down_start = p.position;
1325                    self.last_drag = p.position;
1326                    self.tracker.clear();
1327                    self.tracker.record(t_ms, p.position.y);
1328                    ctx.capture_pointer();
1329                    // Innermost-wins arbitration, both halves in dispatch
1330                    // order. First report THIS surface into whatever cell is
1331                    // ambient — the nearest *enclosing* scrollable's, if any —
1332                    // while that is still the cell on top; only then push this
1333                    // surface's own cell for the forward below, so a nested
1334                    // scrollable's write lands here and never in the
1335                    // grandparent's.
1336                    if let Some(host) = ambient_scroll_claim() {
1337                        host.set(inner_claim_state(self.physics.as_ref(), &self.metrics()));
1338                    }
1339                    let claim = Rc::new(Cell::new(InnerScrollState::default()));
1340                    let veto = Rc::clone(&self.live_veto);
1341                    let child = &mut self.child;
1342                    with_scroll_claim(&claim, || {
1343                        with_scroll_veto(&veto, || child.event_child(ctx, event))
1344                    });
1345                    self.inner_at_down = claim.get();
1346                    EventResult::Handled
1347                }
1348                PointerPhase::Move => {
1349                    // Without an armed `Down`, this is a hover move (the desktop
1350                    // shell dispatches `Move` on every cursor motion): never run
1351                    // the slop/takeover math against a stale `down_start`, just
1352                    // forward it to the child.
1353                    if !self.down_active {
1354                        return self.child.event_child(ctx, event);
1355                    }
1356                    self.tracker.record(t_ms, p.position.y);
1357                    if self.scrolling {
1358                        let dy = p.position.y - self.last_drag.y;
1359                        self.last_drag = p.position;
1360                        // Hand the physics this move's raw delta (the offset
1361                        // moves opposite the finger) and let it decide what
1362                        // reaches the position past an edge.
1363                        self.apply_drag_offset(-dy);
1364                        self.sync_child_origin();
1365                        self.notify_scroll(ctx);
1366                        ctx.request_redraw();
1367                    } else if !self.deferring
1368                        && !self.live_veto.get()
1369                        && (p.position.y - self.down_start.y).abs() > TOUCH_SLOP
1370                        && self.physics.should_accept_user_offset(&self.metrics())
1371                    {
1372                        if self.inner_at_down.defers(p.position.y - self.down_start.y) {
1373                            // Innermost wins: a nested scrollable registered on
1374                            // this gesture's `Down` and can consume this
1375                            // direction, so take nothing over — no `Cancel`, no
1376                            // capture handover — and keep forwarding. The
1377                            // decision is sticky (`deferring` gates this whole
1378                            // branch), and the inner's own slop machinery
1379                            // cancels its own child from here.
1380                            self.deferring = true;
1381                            self.child.event_child(ctx, event);
1382                        } else {
1383                            // Take the gesture over: cancel the child, stop
1384                            // forwarding — unless the physics refuses drags
1385                            // outright, in which case the move keeps flowing to
1386                            // the child. `Bouncing`/`RubberBand` accept
1387                            // unconditionally (even content that fits
1388                            // rubber-bands), so that gate is inert on the
1389                            // bouncing-family default.
1390                            self.scrolling = true;
1391                            self.settling = false;
1392                            self.last_drag = p.position;
1393                            // Seed the drag accumulator from the live position
1394                            // — including a mid-bounce displacement, so a
1395                            // regrab continues from what is on screen.
1396                            self.drag_position = self.offset;
1397                            self.send_child_cancel(ctx, p.position);
1398                            // A takeover, not the gesture's end: releasing the
1399                            // child through the context also ends a contact
1400                            // opt-in held below it, so the root stops routing
1401                            // other fingers to a captor that was just cancelled.
1402                            ctx.release_captured_child(&mut self.child);
1403                            ctx.request_redraw();
1404                        }
1405                    } else {
1406                        self.child.event_child(ctx, event);
1407                    }
1408                    EventResult::Handled
1409                }
1410                PointerPhase::Up => {
1411                    if self.scrolling {
1412                        // Pull-to-refresh: released past the top trigger fires the
1413                        // app hook (an Up, so mutating state is allowed). Measured
1414                        // on `edge_pull`, so a clamping physics — which never lets
1415                        // the position leave range — can still trigger it; under
1416                        // `RubberBand` this is bit-for-bit the overscroll the
1417                        // check has always read.
1418                        if crossed_refresh_trigger(self.edge_pull)
1419                            && let Some(cb) = self.on_refresh_release.as_mut()
1420                        {
1421                            cb(ctx);
1422                        }
1423                        // Ask the physics for post-release motion first: one that
1424                        // hands back a simulation owns the release outright, and
1425                        // one that does not (`RubberBand`) falls through to the
1426                        // legacy settle/fling below untouched.
1427                        if let Some(sim) = self.release_simulation() {
1428                            self.fling = None;
1429                            self.settling = false;
1430                            self.ballistic = Some(BallisticState { sim, start: None });
1431                            self.last_anim = None;
1432                        } else if self.edge_pull != 0.0 {
1433                            // Released while overscrolled: settle back to the edge,
1434                            // never fling out of range.
1435                            self.fling = None;
1436                            self.settling = true;
1437                            self.last_anim = None;
1438                        } else {
1439                            // The legacy path keeps its own FLING_STOP threshold
1440                            // (the trait's min/max fling bounds govern the generic
1441                            // driver only) and takes carried momentum, which is
1442                            // `0.0` under `RubberBand`.
1443                            let finger_v = self.tracker.velocity();
1444                            if finger_v.abs() > FLING_STOP {
1445                                // Offset moves opposite the finger.
1446                                self.fling = Some(self.fling_start_velocity(-finger_v));
1447                                self.last_anim = None;
1448                            }
1449                        }
1450                        self.notify_scroll(ctx);
1451                    } else {
1452                        self.child.event_child(ctx, event);
1453                    }
1454                    self.child.set_active(false);
1455                    self.scrolling = false;
1456                    self.down_active = false;
1457                    self.inner_at_down = InnerScrollState::default();
1458                    self.deferring = false;
1459                    self.live_veto = Rc::new(Cell::new(false));
1460                    ctx.request_redraw();
1461                    EventResult::Handled
1462                }
1463                PointerPhase::Cancel => {
1464                    self.child.event_child(ctx, event);
1465                    self.child.set_active(false);
1466                    self.scrolling = false;
1467                    self.down_active = false;
1468                    self.inner_at_down = InnerScrollState::default();
1469                    self.deferring = false;
1470                    self.live_veto = Rc::new(Cell::new(false));
1471                    // Cancel never mutates state and never fires a callback: drop
1472                    // any pending notification and snap an overscrolled surface back
1473                    // into range (no settle animation, no on_scroll/on_refresh) —
1474                    // including any live simulation and the pull it was riding.
1475                    self.settling = false;
1476                    self.ballistic = None;
1477                    self.edge_pull = 0.0;
1478                    self.pending_scroll_notify = false;
1479                    self.set_offset(self.offset);
1480                    self.sync_child_origin();
1481                    ctx.request_redraw();
1482                    EventResult::Handled
1483                }
1484            },
1485            // Any other variant — a hit-tested `Scale` today — belongs to the
1486            // child, and so does its result: a child that handled it consumed
1487            // it, so an enclosing recognizer (a `pinch_detector` offered the
1488            // same gesture next) must not act on it a second time. Only a
1489            // broadcast is never consumed, whatever the child returned; the arm
1490            // above catches both today, and the check keeps that true for a
1491            // broadcast variant added later.
1492            _ => {
1493                let result = self.child.event_child(ctx, event);
1494                if event.is_broadcast() {
1495                    EventResult::Ignored
1496                } else {
1497                    result
1498                }
1499            }
1500        }
1501    }
1502}
1503
1504impl<State: 'static> View<State> for ScrollView<State> {
1505    type Element = ScrollWidget;
1506
1507    fn build(&self, ctx: &mut BuildCtx<'_>) -> ScrollWidget {
1508        let mut widget = ScrollWidget::new(crate::authoring::build_child(&self.child, ctx));
1509        widget.on_scroll = self
1510            .on_scroll
1511            .as_ref()
1512            .map(crate::authoring::erase_callback_arg);
1513        widget.on_refresh_release = self
1514            .on_refresh_release
1515            .as_ref()
1516            .map(crate::authoring::erase_callback);
1517        if let Some(physics) = self.physics.clone() {
1518            widget.physics = physics;
1519        }
1520        widget.effect = self.effect;
1521        widget
1522    }
1523
1524    fn rebuild(
1525        &self,
1526        prev: &Self,
1527        element: &mut ScrollWidget,
1528        ctx: &mut BuildCtx<'_>,
1529    ) -> ChangeFlags {
1530        // Closures are not comparable — always reinstall the erased adapters.
1531        element.on_scroll = self
1532            .on_scroll
1533            .as_ref()
1534            .map(crate::authoring::erase_callback_arg);
1535        element.on_refresh_release = self
1536            .on_refresh_release
1537            .as_ref()
1538            .map(crate::authoring::erase_callback);
1539        // A `.physics(...)`-carrying view reinstalls it every rebuild, like the
1540        // erased callbacks above; a view with no opinion (`None`) leaves the
1541        // widget's currently-installed physics alone — see
1542        // `ScrollView::physics`'s doc for the full contract.
1543        if let Some(physics) = self.physics.clone() {
1544            element.physics = physics;
1545        }
1546        // The visual effect is plain data the view owns and is always carried
1547        // down unconditionally.
1548        element.effect = self.effect;
1549        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
1550    }
1551
1552    fn teardown(&self, element: &mut ScrollWidget, ctx: &mut BuildCtx<'_>) {
1553        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
1554    }
1555}
1556
1557impl Widget for ScrollWidget {
1558    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1559        let vw = if bc.max().width.is_finite() {
1560            bc.max().width
1561        } else {
1562            0.0
1563        };
1564        // The child is laid out at the viewport width with unbounded height.
1565        let child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
1566        self.content = self.child.layout_child(ctx, &child_bc);
1567        let vh = if bc.max().height.is_finite() {
1568            bc.max().height
1569        } else {
1570            self.content.height
1571        };
1572        self.viewport = Size::new(vw, vh);
1573        // Invariant: an in-flight gesture/settle/simulation owns an
1574        // out-of-range offset; layout must not snap it. While `scrolling` (an
1575        // active past-slop drag), `settling` (the post-release decay back to
1576        // the edge), or a physics-driven ballistic simulation (which for a
1577        // bouncing physics legitimately runs past an edge and back) is live,
1578        // `self.offset` legitimately carries the resisted past-edge overscroll —
1579        // re-clamping it here would snap the child to rest mid-gesture, and the
1580        // next pointer `Move` (or settle tick) would re-apply the displacement,
1581        // producing a visible alternation between rest and dragged positions at
1582        // display rate on a page where something else requests layout every
1583        // frame (device-gate G6, the "phantom clone" bug). Only the clamp is
1584        // conditional: origin sync and the viewport/content bookkeeping above
1585        // still run unconditionally either way. A resize mid-drag (content or
1586        // viewport shrinking under an out-of-range offset) still resolves
1587        // correctly without an immediate clamp here: `Up`'s handler always
1588        // recomputes `scroll_info()`/settles/flings off the freshly-updated
1589        // `max_offset`, and any non-drag layout after the gesture ends clamps
1590        // normally on its own next pass.
1591        if !self.scrolling && !self.settling && self.ballistic.is_none() {
1592            self.set_offset(self.offset); // re-clamp against new content/viewport
1593        }
1594        self.sync_child_origin();
1595        bc.constrain(self.viewport)
1596    }
1597
1598    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1599        // Record the shared frame clock so the between-frames event pass (which
1600        // carries no clock) has a timestamp for velocity tracking.
1601        self.last_frame_time = ctx.frame_time();
1602        self.pump_fling(ctx);
1603        scene.push_clip(ctx.origin(), ctx.size());
1604        self.sync_child_origin();
1605        // Publish this viewport as the paint-time visible rect (absolute coords),
1606        // so a `Flex` in the scrolled content can cull children fully below/above
1607        // the fold — suppressing offscreen animators' frame requests. Intersects
1608        // (never widens) any rect an outer scroll surface already threaded down.
1609        ctx.constrain_visible_rect(Rect::from_origin_size(ctx.origin(), ctx.size()));
1610        // The stretch is PAINT-ONLY, and load-bearingly so: no layout pass
1611        // reads `edge_pull` or the intensity derived from it, and none may
1612        // start to. A layout-affecting animation must request a relayout on
1613        // every frame of its motion or the mobile shell's intra-frame layout
1614        // skip leaves it frozen (`docs/WIDGETS_CODE_STANDARDS.md`'s
1615        // animation-pacing rule) — keeping the stretch out of every layout
1616        // read is what makes that irrelevant here, and is why there is no
1617        // `request_layout` in this path either: the settle/ballistic pump
1618        // above already asks for every frame the decaying stretch needs.
1619        // Pushed INSIDE the viewport clip so stretched content can never
1620        // paint past the viewport's edges.
1621        let stretch = match self.effect {
1622            OverscrollEffect::Stretch => {
1623                stretch_about_edge(ctx.origin(), ctx.size(), self.edge_pull)
1624            }
1625            OverscrollEffect::Translate | OverscrollEffect::None => None,
1626        };
1627        if let Some(transform) = stretch {
1628            scene.push_transform(transform);
1629        }
1630        self.child.paint_child(ctx, scene);
1631        if stretch.is_some() {
1632            scene.pop_transform();
1633        }
1634        scene.pop_clip();
1635    }
1636
1637    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1638        let t = self.event_time_ms();
1639        self.event_at(ctx, event, t)
1640    }
1641
1642    fn semantics(&self, ctx: &mut SemanticsCtx) {
1643        // A ScrollView node exposing the vertical scroll offset and its range,
1644        // wrapping its scrolled content: `semantics_child` translates by the
1645        // child's origin (which carries `-offset`), so descendant bounds reflect
1646        // the scrolled position.
1647        let max_offset = (self.content.height - self.viewport.height).max(0.0);
1648        ctx.push_container(
1649            Role::ScrollView,
1650            |node| {
1651                node.set_scroll_y(self.offset);
1652                node.set_scroll_y_min(0.0);
1653                node.set_scroll_y_max(max_offset);
1654            },
1655            |ctx| self.child.semantics_child(ctx),
1656        );
1657    }
1658
1659    crate::authoring::visit_children!(child);
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::*;
1665    use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
1666    use crate::physics::rubber_band::RubberBand;
1667    use crate::test_support::leaf;
1668    use std::any::Any;
1669
1670    /// Build and lay out a scroll widget over `()` state with a `content_h`-tall
1671    /// leaf child inside a `vw`×`vh` viewport.
1672    fn laid_out(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
1673        let view: ScrollView<()> = scroll_view(leaf(vw, content_h));
1674        let mut counter = 0u64;
1675        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1676        let mut lctx = LayoutCtx::new();
1677        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(vw, vh)));
1678        w
1679    }
1680
1681    /// [`laid_out`] with the pre-seam [`RubberBand`] feel pinned explicitly —
1682    /// the fixture every *rubber-band* pin below builds from now that the
1683    /// widget's own default is the platform-adaptive physics (bouncing here,
1684    /// clamping on Android), each with curves of its own. A test whose
1685    /// assertions are a `0.5`-resisted displacement, a `SETTLE_DECAY` trace or
1686    /// a legacy-fling trace is pinning *this* physics, not the default.
1687    fn laid_out_rubber_band(vw: f64, vh: f64, content_h: f64) -> ScrollWidget {
1688        let mut w = laid_out(vw, vh, content_h);
1689        w.physics = Rc::new(RubberBand::new());
1690        w
1691    }
1692
1693    fn scroll(y: f64, lines: bool, amount: f64) -> InputEvent {
1694        let delta = if lines {
1695            ScrollDelta::Lines(0.0, amount)
1696        } else {
1697            ScrollDelta::Pixels(0.0, amount)
1698        };
1699        InputEvent::Scroll {
1700            position: Point::new(10.0, y),
1701            delta,
1702        }
1703    }
1704
1705    fn ev(phase: PointerPhase, y: f64) -> InputEvent {
1706        InputEvent::Pointer(PointerEvent {
1707            phase,
1708            position: Point::new(10.0, y),
1709            button: PointerButton::Primary,
1710        })
1711    }
1712
1713    fn dispatch(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) {
1714        let mut unit = ();
1715        let state_any: &mut dyn Any = &mut unit;
1716        let mut ctx = EventCtx::new(state_any, Point::ZERO, w.viewport);
1717        w.event_at(&mut ctx, event, t_ms);
1718    }
1719
1720    #[test]
1721    fn max_offset_is_content_minus_viewport() {
1722        let w = laid_out(200.0, 100.0, 1000.0);
1723        assert_eq!(w.max_offset(), 900.0);
1724        assert_eq!(w.offset(), 0.0);
1725    }
1726
1727    #[test]
1728    fn wheel_scrolls_and_clamps_with_no_overscroll() {
1729        let mut w = laid_out(200.0, 100.0, 1000.0);
1730        // 3 lines * 40 px = 120.
1731        dispatch(&mut w, &scroll(50.0, true, 3.0), 0.0);
1732        assert_eq!(w.offset(), 120.0);
1733        // A huge line delta clamps to max_offset (no overscroll past the end).
1734        dispatch(&mut w, &scroll(50.0, true, 100.0), 0.0);
1735        assert_eq!(w.offset(), 900.0);
1736        // Scrolling back past the top clamps to 0.
1737        dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
1738        assert_eq!(w.offset(), 0.0);
1739    }
1740
1741    #[test]
1742    fn drag_past_slop_scrolls_the_offset() {
1743        let mut w = laid_out(200.0, 100.0, 1000.0);
1744        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
1745        // First move crosses the slop → takeover (no scroll on this move).
1746        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
1747        assert_eq!(w.offset(), 0.0);
1748        assert!(w.scrolling);
1749        // Next move drags the finger up 30 px → content scrolls down 30 px.
1750        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
1751        assert_eq!(w.offset(), 30.0);
1752    }
1753
1754    #[test]
1755    fn takeover_sends_the_child_a_cancel() {
1756        // A child that records the pointer phases it receives.
1757        #[derive(Default)]
1758        struct Rec {
1759            downs: u32,
1760            cancels: u32,
1761        }
1762        struct Probe;
1763        struct ProbeW;
1764        impl View<Rec> for Probe {
1765            type Element = ProbeW;
1766            fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
1767                ProbeW
1768            }
1769            fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1770                ChangeFlags::NONE
1771            }
1772        }
1773        impl Widget for ProbeW {
1774            fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1775                bc.constrain(Size::new(200.0, 1000.0))
1776            }
1777            fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1778            fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
1779                if let InputEvent::Pointer(p) = e {
1780                    let rec = ctx.state_mut::<Rec>();
1781                    match p.phase {
1782                        PointerPhase::Down => rec.downs += 1,
1783                        PointerPhase::Cancel => rec.cancels += 1,
1784                        _ => {}
1785                    }
1786                }
1787                EventResult::Handled
1788            }
1789        }
1790
1791        let view: ScrollView<Rec> = scroll_view(Probe);
1792        let mut counter = 0u64;
1793        let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
1794        w.viewport = Size::new(200.0, 100.0);
1795
1796        let mut state = Rec::default();
1797        let run = |w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64| {
1798            let sa: &mut dyn Any = state;
1799            let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
1800            w.event_at(&mut ctx, e, t);
1801        };
1802        run(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
1803        // Drag 30 px past the slop → takeover fires a Cancel at the child.
1804        run(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
1805        assert_eq!(state.downs, 1);
1806        assert_eq!(state.cancels, 1);
1807        // Subsequent scrolling moves are not forwarded to the child.
1808        run(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0);
1809        assert_eq!(state.cancels, 1);
1810    }
1811
1812    /// A recording child probe, shared by the takeover/hover tests.
1813    #[derive(Default)]
1814    struct Rec {
1815        downs: u32,
1816        cancels: u32,
1817        moves: u32,
1818    }
1819    struct Probe;
1820    struct ProbeW;
1821    impl View<Rec> for Probe {
1822        type Element = ProbeW;
1823        fn build(&self, _c: &mut BuildCtx<'_>) -> ProbeW {
1824            ProbeW
1825        }
1826        fn rebuild(&self, _p: &Self, _e: &mut ProbeW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1827            ChangeFlags::NONE
1828        }
1829    }
1830    impl Widget for ProbeW {
1831        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1832            bc.constrain(Size::new(200.0, 1000.0))
1833        }
1834        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1835        fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
1836            if let InputEvent::Pointer(p) = e {
1837                let rec = ctx.state_mut::<Rec>();
1838                match p.phase {
1839                    PointerPhase::Down => rec.downs += 1,
1840                    PointerPhase::Cancel => rec.cancels += 1,
1841                    PointerPhase::Move => rec.moves += 1,
1842                    _ => {}
1843                }
1844            }
1845            EventResult::Ignored
1846        }
1847    }
1848
1849    fn probe_scroll() -> ScrollWidget {
1850        let view: ScrollView<Rec> = scroll_view(Probe);
1851        let mut counter = 0u64;
1852        let mut w = View::<Rec>::build(&view, &mut BuildCtx::new(&mut counter));
1853        w.viewport = Size::new(200.0, 100.0);
1854        w
1855    }
1856
1857    fn run_rec(w: &mut ScrollWidget, state: &mut Rec, e: &InputEvent, t: f64) -> bool {
1858        let sa: &mut dyn Any = state;
1859        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
1860        w.event_at(&mut ctx, e, t);
1861        ctx.needs_redraw()
1862    }
1863
1864    #[test]
1865    fn an_overlay_broadcast_reaches_the_child_and_is_never_consumed() {
1866        use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
1867
1868        /// What a floated surface's owner below this viewport would see.
1869        #[derive(Default)]
1870        struct Seen {
1871            overlays: u32,
1872            pointers: u32,
1873        }
1874        struct Owner;
1875        struct OwnerW;
1876        impl View<Seen> for Owner {
1877            type Element = OwnerW;
1878            fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
1879                OwnerW
1880            }
1881            fn rebuild(&self, _p: &Self, _e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1882                ChangeFlags::NONE
1883            }
1884        }
1885        impl Widget for OwnerW {
1886            fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1887                bc.constrain(Size::new(200.0, 1000.0))
1888            }
1889            fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
1890            fn event(&mut self, ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
1891                match e {
1892                    InputEvent::Overlay(_) => {
1893                        ctx.state_mut::<Seen>().overlays += 1;
1894                        // Even a `Handled` must not be reported upward: a
1895                        // broadcast is never consumed.
1896                        EventResult::Handled
1897                    }
1898                    InputEvent::Pointer(_) => {
1899                        ctx.state_mut::<Seen>().pointers += 1;
1900                        EventResult::Handled
1901                    }
1902                    _ => EventResult::Ignored,
1903                }
1904            }
1905        }
1906
1907        let view: ScrollView<Seen> = scroll_view(Owner);
1908        let mut counter = 0u64;
1909        let mut w = View::<Seen>::build(&view, &mut BuildCtx::new(&mut counter));
1910        w.viewport = Size::new(200.0, 100.0);
1911        let mut state = Seen::default();
1912        let broadcast = InputEvent::Overlay(OverlayEvent {
1913            key: OverlayKey::next(),
1914            kind: OverlayEventKind::OutsideDown,
1915        });
1916        let result = {
1917            let sa: &mut dyn Any = &mut state;
1918            let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, 100.0));
1919            w.event_at(&mut ctx, &broadcast, 0.0)
1920        };
1921        assert_eq!(
1922            state.overlays, 1,
1923            "a floated surface's own input reaches its owner through the viewport"
1924        );
1925        assert_eq!(
1926            result,
1927            EventResult::Ignored,
1928            "and is never consumed, whatever the child returned"
1929        );
1930        assert_eq!(state.pointers, 0, "it is not a pointer event");
1931        // The gesture machinery is untouched by it: no capture was opened and
1932        // no drag armed.
1933        assert!(!w.down_active && !w.scrolling);
1934    }
1935
1936    #[test]
1937    fn hover_move_without_down_never_scrolls_or_cancels_child() {
1938        let mut w = probe_scroll();
1939        let mut state = Rec::default();
1940        // A cursor drifting over the list with no prior Down: no takeover, no
1941        // Cancel to the child, no offset change, no self redraw request.
1942        let redraw = run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 16.0);
1943        assert!(!w.scrolling, "hover must not enter scrolling");
1944        assert!(!w.down_active);
1945        assert_eq!(w.offset(), 0.0, "hover must not move the offset");
1946        assert_eq!(state.cancels, 0, "hover must not cancel the child");
1947        assert!(!redraw, "hover must not request a redraw");
1948        // The hover move is forwarded to the child (which ignores it).
1949        assert_eq!(state.moves, 1);
1950    }
1951
1952    #[test]
1953    fn cancel_clears_down_active() {
1954        let mut w = probe_scroll();
1955        let mut state = Rec::default();
1956        run_rec(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
1957        assert!(w.down_active);
1958        run_rec(&mut w, &mut state, &ev(PointerPhase::Cancel, 100.0), 16.0);
1959        assert!(!w.down_active, "Cancel disarms the gesture");
1960        assert!(!w.scrolling);
1961        // A subsequent hover Move must not run the takeover math.
1962        run_rec(&mut w, &mut state, &ev(PointerPhase::Move, 20.0), 32.0);
1963        assert!(!w.scrolling, "hover after Cancel must not take over");
1964        assert_eq!(w.offset(), 0.0);
1965    }
1966
1967    #[test]
1968    fn fling_after_release_decays_and_clamps() {
1969        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
1970        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
1971        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
1972        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // scroll, builds velocity
1973        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
1974        assert!(w.is_flinging(), "release with velocity starts a fling");
1975        // Integrate to completion.
1976        let mut steps = 0;
1977        while w.tick(16.0) {
1978            steps += 1;
1979            assert!(steps < 100_000, "fling failed to terminate");
1980        }
1981        assert!(!w.is_flinging());
1982        assert!(w.offset() >= 0.0 && w.offset() <= w.max_offset());
1983    }
1984
1985    #[test]
1986    fn pump_fling_signals_needs_frame_until_at_rest() {
1987        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
1988        // Drive a release-with-velocity to start a fling (deterministic seam).
1989        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
1990        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
1991        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // build velocity
1992        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
1993        assert!(w.is_flinging(), "release with velocity starts a fling");
1994
1995        // A paint-time pump while flinging asks for another frame.
1996        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
1997        w.pump_fling(&mut ctx);
1998        assert!(
1999            ctx.needs_frame(),
2000            "an in-flight fling requests continuation"
2001        );
2002        assert!(w.is_flinging());
2003
2004        // Integrate the fling to rest via the deterministic tick seam.
2005        while w.tick(16.0) {}
2006        assert!(!w.is_flinging());
2007
2008        // At rest, the pump no longer signals — the shell can idle again.
2009        let mut ctx_rest = PaintCtx::new(Point::ZERO, w.viewport);
2010        w.pump_fling(&mut ctx_rest);
2011        assert!(
2012            !ctx_rest.needs_frame(),
2013            "a fling at rest stops requesting frames"
2014        );
2015    }
2016
2017    #[test]
2018    fn tick_without_a_fling_is_a_noop() {
2019        let mut w = laid_out(200.0, 100.0, 1000.0);
2020        assert!(!w.tick(16.0));
2021        assert_eq!(w.offset(), 0.0);
2022    }
2023
2024    /// A no-op paint sink for the RenderRoot clock test.
2025    struct NullScene;
2026    impl PaintScene for NullScene {
2027        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2028        fn draw_text(&mut self, _o: Point, _t: &str) {}
2029    }
2030
2031    fn scroll_widget(root: &frust_core::RenderRoot<(), ScrollView<()>>) -> &ScrollWidget {
2032        let id = root.root_id().expect("root built");
2033        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
2034            .downcast_ref::<ScrollWidget>()
2035            .expect("root is a ScrollWidget")
2036    }
2037
2038    #[test]
2039    fn fling_advances_from_injected_paint_frame_time() {
2040        // End-to-end through the real paint path: the
2041        // event pass reads the last painted frame time for velocity tracking, and
2042        // the fling pump advances off the injected `RenderRoot::paint` frame time
2043        // — no wall clock anywhere. Paints are interleaved with the drag so the
2044        // velocity tracker sees distinct (paint-clock) timestamps.
2045        use frust_core::{FrameTime, RenderRoot};
2046
2047        fn logic(_: &mut ()) -> ScrollView<()> {
2048            scroll_view(leaf(200.0, 1000.0))
2049        }
2050        let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
2051        let mut state = ();
2052        root.rebuild(&mut logic, &mut state);
2053        root.layout(Size::new(200.0, 100.0));
2054
2055        let ft = |ms: f64| FrameTime::from_nanos((ms * 1_000_000.0) as u64);
2056        let mut sink = NullScene;
2057        root.paint(&mut sink, ft(0.0));
2058        root.event(&mut state, &ev(PointerPhase::Down, 100.0));
2059        root.paint(&mut sink, ft(16.0));
2060        root.event(&mut state, &ev(PointerPhase::Move, 75.0)); // crosses slop → takeover
2061        root.paint(&mut sink, ft(32.0));
2062        root.event(&mut state, &ev(PointerPhase::Move, 50.0)); // scroll, builds velocity
2063        root.event(&mut state, &ev(PointerPhase::Up, 50.0)); // release → fling
2064
2065        assert!(
2066            scroll_widget(&root).is_flinging(),
2067            "release with paint-clock velocity starts a fling"
2068        );
2069        let before = scroll_widget(&root).offset();
2070
2071        // Advancing frame times drive the fling: the first paint seeds the fling
2072        // clock (zero delta), the next advances the offset.
2073        root.paint(&mut sink, ft(48.0));
2074        root.paint(&mut sink, ft(64.0));
2075        assert!(
2076            scroll_widget(&root).offset() > before,
2077            "the fling advanced from the injected paint frame time"
2078        );
2079    }
2080
2081    /// A perpetual animator: requests a continuation frame on every paint.
2082    /// Stands in for an offscreen shimmer/spinner whose frame requests
2083    /// paint-time culling must suppress.
2084    struct Ticker;
2085    struct TickerWidget;
2086    impl View<()> for Ticker {
2087        type Element = TickerWidget;
2088        fn build(&self, _c: &mut BuildCtx<'_>) -> TickerWidget {
2089            TickerWidget
2090        }
2091        fn rebuild(&self, _p: &Self, _e: &mut TickerWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2092            ChangeFlags::NONE
2093        }
2094    }
2095    impl Widget for TickerWidget {
2096        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2097            bc.constrain(Size::new(100.0, 100.0))
2098        }
2099        fn paint(&mut self, ctx: &mut PaintCtx, _s: &mut dyn PaintScene) {
2100            ctx.request_frame();
2101        }
2102    }
2103
2104    #[test]
2105    fn offscreen_flex_animator_culled_until_scrolled_into_view() {
2106        // End-to-end frame suppression: a perpetual animator below
2107        // the fold in a Column inside a ScrollView is culled from paint, so its
2108        // request_frame never bubbles and the root PaintOutcome asks for no
2109        // continuation frame. Scroll it into view and the requests resume.
2110        use frust_core::RenderRoot;
2111
2112        fn logic(_: &mut ()) -> ScrollView<()> {
2113            // A 1000px spacer, then a 100px perpetual animator (content 1100 tall).
2114            scroll_view(crate::Column(vec![any(leaf(100.0, 1000.0)), any(Ticker)]))
2115        }
2116        let mut root: RenderRoot<(), ScrollView<()>> = RenderRoot::new();
2117        let mut state = ();
2118        root.rebuild(&mut logic, &mut state);
2119        root.layout(Size::new(100.0, 100.0));
2120
2121        let mut sink = NullScene;
2122        // At rest (offset 0) the animator sits at y=1000, far below the warm band
2123        // (viewport 100 + one-viewport margin → y ∈ [-100, 200]); it is culled, so
2124        // no continuation frame is requested.
2125        let outcome = root.paint(&mut sink, FrameTime::ZERO);
2126        assert!(
2127            !outcome.needs_frame,
2128            "an offscreen animator's frame request is culled"
2129        );
2130
2131        // Scroll to the bottom (wheel clamps to max_offset = 1000): the animator
2132        // comes into view and its request_frame bubbles out of paint again.
2133        root.event(&mut state, &scroll(50.0, false, 5000.0));
2134        let outcome = root.paint(&mut sink, FrameTime::ZERO);
2135        assert!(
2136            outcome.needs_frame,
2137            "scrolling the animator into view resumes its frame requests"
2138        );
2139    }
2140
2141    #[test]
2142    fn offset_reclamps_when_content_shrinks() {
2143        let mut w = laid_out(200.0, 100.0, 1000.0);
2144        dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
2145        assert_eq!(w.offset(), 800.0);
2146        // Content shrinks to just above the viewport → max_offset drops to 50,
2147        // and a re-clamp (what layout does) pulls the stale offset back in range.
2148        w.content = Size::new(200.0, 150.0);
2149        w.set_offset(w.offset);
2150        assert_eq!(w.max_offset(), 50.0);
2151        assert_eq!(w.offset(), 50.0);
2152    }
2153
2154    // --- Overscroll (pull-to-refresh seam) ---
2155
2156    #[test]
2157    fn drag_past_top_overscrolls_with_resistance_then_settles_back() {
2158        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2159        // Down, then a drag downward crossing the slop takes the gesture over.
2160        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2161        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2162        assert!(w.scrolling);
2163        assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
2164        // Drag 20px further down past the already-at-top edge → resisted overscroll.
2165        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2166        assert!(w.offset() < 0.0, "a drag past the top overscrolls negative");
2167        assert_eq!(
2168            w.offset(),
2169            -10.0,
2170            "overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5)"
2171        );
2172        // Release → a settle animation, not a fling; it returns to the edge.
2173        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
2174        assert!(
2175            !w.is_flinging(),
2176            "an overscrolled release settles, never flings"
2177        );
2178        let mut steps = 0;
2179        while w.settle_tick(16.0) {
2180            steps += 1;
2181            assert!(steps < 10_000, "settle failed to terminate");
2182        }
2183        assert_eq!(
2184            w.offset(),
2185            0.0,
2186            "the surface settles back to the clamped edge"
2187        );
2188    }
2189
2190    #[test]
2191    fn wheel_never_overscrolls_past_top() {
2192        let mut w = laid_out(200.0, 100.0, 1000.0);
2193        // A large negative wheel delta at the top stays hard-clamped at 0 — no
2194        // rubber-band on wheel input.
2195        dispatch(&mut w, &scroll(50.0, false, -5000.0), 0.0);
2196        assert_eq!(w.offset(), 0.0);
2197        assert!(!w.settling, "wheel input starts no settle animation");
2198    }
2199
2200    /// A state that records every `ScrollInfo` its `on_scroll` observes.
2201    #[derive(Default)]
2202    struct ScrollLog {
2203        infos: Vec<ScrollInfo>,
2204        refreshes: u32,
2205    }
2206
2207    /// A fixed-size content view generic over the state type (the shared `leaf`
2208    /// fixture is `View<()>` only), so a scroll view can wrap it over `ScrollLog`.
2209    struct Content(Size);
2210    struct ContentW(Size);
2211    impl<S: 'static> View<S> for Content {
2212        type Element = ContentW;
2213        fn build(&self, _c: &mut BuildCtx<'_>) -> ContentW {
2214            ContentW(self.0)
2215        }
2216        fn rebuild(&self, _p: &Self, _e: &mut ContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2217            ChangeFlags::NONE
2218        }
2219    }
2220    impl Widget for ContentW {
2221        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2222            bc.constrain(self.0)
2223        }
2224        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2225    }
2226
2227    /// Build+lay out a scroll widget over `ScrollLog` state with the given
2228    /// callbacks installed.
2229    fn observed(with_refresh: bool) -> ScrollWidget {
2230        let mut view: ScrollView<ScrollLog> = scroll_view(Content(Size::new(200.0, 1000.0)))
2231            .on_scroll(|s: &mut ScrollLog, info| s.infos.push(info));
2232        if with_refresh {
2233            view = view.on_refresh_release(|s: &mut ScrollLog| s.refreshes += 1);
2234        }
2235        let mut counter = 0u64;
2236        let mut w = View::<ScrollLog>::build(&view, &mut BuildCtx::new(&mut counter));
2237        let mut lctx = LayoutCtx::new();
2238        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2239        w
2240    }
2241
2242    /// [`observed`] with the pre-seam [`RubberBand`] feel pinned explicitly,
2243    /// for the same reason [`laid_out_rubber_band`] exists.
2244    fn observed_rubber_band(with_refresh: bool) -> ScrollWidget {
2245        let mut w = observed(with_refresh);
2246        w.physics = Rc::new(RubberBand::new());
2247        w
2248    }
2249
2250    fn run_log(w: &mut ScrollWidget, state: &mut ScrollLog, e: &InputEvent, t: f64) {
2251        let sa: &mut dyn Any = state;
2252        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
2253        w.event_at(&mut ctx, e, t);
2254    }
2255
2256    #[test]
2257    fn on_scroll_observes_drag_deltas() {
2258        let mut w = observed(false);
2259        let mut state = ScrollLog::default();
2260        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2261        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0); // takeover
2262        // Two scrolling drags upward move the content down.
2263        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
2264        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 10.0), 48.0);
2265        assert!(!state.infos.is_empty(), "on_scroll fired on the drag");
2266        let last = state.infos.last().unwrap();
2267        assert!(
2268            last.offset > 0.0,
2269            "the observed offset grew as content scrolled"
2270        );
2271        assert_eq!(last.overscroll, 0.0, "an in-range drag has no overscroll");
2272        assert_eq!(last.max_offset, 900.0);
2273    }
2274
2275    #[test]
2276    fn on_refresh_release_fires_only_past_trigger_and_only_on_release() {
2277        // A small pull (under the trigger) does not fire on release.
2278        let mut w = observed_rubber_band(true);
2279        let mut state = ScrollLog::default();
2280        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2281        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2282        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 180.0), 32.0); // raw -90 → -45
2283        assert_eq!(w.offset(), -45.0, "under the trigger (|-45| < 64)");
2284        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 180.0), 48.0);
2285        assert_eq!(
2286            state.refreshes, 0,
2287            "release under the trigger does not refresh"
2288        );
2289
2290        // A large pull past the trigger fires exactly once, on release.
2291        let mut w = observed_rubber_band(true);
2292        let mut state = ScrollLog::default();
2293        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2294        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2295        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); // raw -200 → -100
2296        assert_eq!(w.offset(), -100.0, "past the trigger (|-100| > 64)");
2297        assert_eq!(state.refreshes, 0, "no fire before release");
2298        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 290.0), 48.0);
2299        assert_eq!(state.refreshes, 1, "release past the trigger fires once");
2300    }
2301
2302    #[test]
2303    fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
2304        let mut w = observed_rubber_band(true);
2305        let mut state = ScrollLog::default();
2306        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2307        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2308        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 290.0), 32.0); // past trigger
2309        assert_eq!(w.offset(), -100.0);
2310        // A Cancel (gesture steal) must not fire on_refresh_release and snaps the
2311        // overscroll away with no settle animation.
2312        run_log(&mut w, &mut state, &ev(PointerPhase::Cancel, 290.0), 48.0);
2313        assert_eq!(
2314            state.refreshes, 0,
2315            "Cancel never fires the refresh callback"
2316        );
2317        assert_eq!(w.offset(), 0.0, "Cancel snaps the surface back into range");
2318        assert!(!w.settling);
2319    }
2320
2321    // --- Device-gate G6 (the "phantom clone" bug): a layout pass mid-gesture
2322    // must not snap an out-of-range offset back into range. ---
2323
2324    #[test]
2325    fn layout_mid_drag_preserves_top_overscroll() {
2326        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2327        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2328        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2329        assert!(w.scrolling);
2330        // Drag 20px further down past the already-at-top edge → resisted overscroll.
2331        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2332        assert_eq!(
2333            w.offset(),
2334            -10.0,
2335            "resisted overscroll before the layout pass"
2336        );
2337
2338        // A layout pass fires mid-drag (e.g. a sibling requesting relayout every
2339        // frame, like a wavy progress indicator). Without the fix this snaps the
2340        // child back to rest (offset 0.0) — the phantom-clone bug: the next
2341        // pointer Move re-applies the displacement, so the presented frame
2342        // alternates between rest and dragged at display rate.
2343        let mut lctx = LayoutCtx::new();
2344        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2345        assert_eq!(
2346            w.offset(),
2347            -10.0,
2348            "a layout pass mid-drag must not snap the overscroll back into range"
2349        );
2350        assert!(w.scrolling, "still an active drag after the layout pass");
2351    }
2352
2353    #[test]
2354    fn layout_mid_settle_preserves_decaying_overscroll() {
2355        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2356        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2357        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2358        dispatch(&mut w, &ev(PointerPhase::Move, 290.0), 32.0); // raw -200 → -100
2359        assert_eq!(w.offset(), -100.0, "past the refresh trigger (|-100| > 64)");
2360        dispatch(&mut w, &ev(PointerPhase::Up, 290.0), 48.0);
2361        assert!(
2362            w.settling,
2363            "an overscrolled release enters the settle animation"
2364        );
2365        assert!(!w.is_flinging());
2366
2367        // Advance one settle tick: the offset has eased toward the edge but has
2368        // not arrived yet.
2369        let still_settling = w.settle_tick(16.0);
2370        assert!(still_settling);
2371        let after_tick = w.offset();
2372        assert!(
2373            after_tick < 0.0,
2374            "one settle tick eases toward the edge but is still out of range"
2375        );
2376
2377        // A layout pass fires mid-settle. Without the fix this snaps the
2378        // decaying offset straight to 0.0, visibly skipping the rest of the
2379        // settle animation (the same bug class as the mid-drag case above).
2380        let mut lctx = LayoutCtx::new();
2381        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
2382        assert_eq!(
2383            w.offset(),
2384            after_tick,
2385            "a layout pass mid-settle must not snap the decaying overscroll back into range"
2386        );
2387        assert!(w.settling, "still settling after the layout pass");
2388    }
2389
2390    // --- The physics seam: the default `RubberBand` install, the generic
2391    //     ballistic driver, and the carried-momentum hook. ---
2392
2393    #[test]
2394    fn edge_pull_equals_overscroll_under_rubber_band() {
2395        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2396        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2397        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2398        // 20px past the already-at-top edge: resistance halves it, and nothing
2399        // is boundary-rejected, so the pull *is* the displacement.
2400        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2401        assert_eq!(w.offset(), -10.0);
2402        assert_eq!(w.edge_pull, -10.0, "negative past the top, like overscroll");
2403        assert_eq!(
2404            w.edge_pull,
2405            w.scroll_info().overscroll,
2406            "nothing rejected → the two are the same number"
2407        );
2408
2409        // The settle decays both together, and both land exactly on zero.
2410        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
2411        assert!(w.settling);
2412        assert!(w.settle_tick(16.0));
2413        assert!(
2414            w.edge_pull < 0.0 && w.edge_pull > -10.0,
2415            "one settle tick eases the pull toward the edge: {}",
2416            w.edge_pull
2417        );
2418        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2419        while w.settle_tick(16.0) {}
2420        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
2421        assert_eq!(w.scroll_info().overscroll, 0.0);
2422
2423        // The bottom edge is the same story with the opposite sign.
2424        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
2425        dispatch(&mut w, &scroll(50.0, false, 5000.0), 0.0); // clamp to max_offset
2426        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2427        dispatch(&mut w, &ev(PointerPhase::Move, 60.0), 16.0); // takeover
2428        dispatch(&mut w, &ev(PointerPhase::Move, 0.0), 32.0); // 60px past the bottom
2429        assert_eq!(w.edge_pull, 30.0, "positive past the bottom");
2430        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2431    }
2432
2433    /// A scripted ballistic curve: a straight 100 px/s ramp from where the
2434    /// release left the position, done after 100ms.
2435    struct Ramp {
2436        from: f64,
2437    }
2438    impl Simulation for Ramp {
2439        fn x(&self, time: f64) -> f64 {
2440            self.from + 100.0 * time
2441        }
2442        fn dx(&self, _time: f64) -> f64 {
2443            100.0
2444        }
2445        fn is_done(&self, time: f64) -> bool {
2446            time >= 0.1
2447        }
2448    }
2449
2450    /// A toy physics that *does* hand back a simulation — the counterpart of
2451    /// `RubberBand`'s `None`, exercising the generic driver.
2452    #[derive(Debug)]
2453    struct RampPhysics;
2454    impl ScrollPhysics for RampPhysics {
2455        fn create_ballistic_simulation(
2456            &self,
2457            metrics: &ScrollMetrics,
2458            _velocity: f64,
2459        ) -> Option<Box<dyn Simulation>> {
2460            Some(Box::new(Ramp {
2461                from: metrics.pixels,
2462            }))
2463        }
2464    }
2465
2466    fn frame_time(ms: f64) -> FrameTime {
2467        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
2468    }
2469
2470    #[test]
2471    fn ballistic_driver_runs_generic_simulation() {
2472        let mut w = observed(false);
2473        w.physics = Rc::new(RampPhysics);
2474        let mut state = ScrollLog::default();
2475        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
2476        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2477        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 32.0); // in-range drag
2478        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 50.0), 32.0);
2479        assert!(
2480            w.ballistic.is_some(),
2481            "a physics handing back a simulation owns the release"
2482        );
2483        assert!(w.fling.is_none(), "…and the legacy fling never starts");
2484        assert!(!w.settling);
2485        let start = w.offset();
2486        let observed_before = state.infos.len();
2487
2488        // The first pump seeds the simulation clock: zero delta, nothing moves,
2489        // nothing recorded — the same seeding frame the legacy pump takes.
2490        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(100.0));
2491        w.pump_fling(&mut ctx);
2492        assert!(ctx.needs_frame(), "a live simulation asks for continuation");
2493        assert_eq!(w.offset(), start, "the seeding frame moves nothing");
2494        assert!(!w.pending_scroll_notify);
2495
2496        // 16ms on, the offset is exactly the curve's own position.
2497        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(116.0));
2498        w.pump_fling(&mut ctx);
2499        assert!(
2500            (w.offset() - (start + 1.6)).abs() < 1e-9,
2501            "the offset follows sim.x(t): {}",
2502            w.offset()
2503        );
2504        assert!(w.pending_scroll_notify, "recorded at paint, not fired");
2505        assert_eq!(
2506            state.infos.len(),
2507            observed_before,
2508            "the notification stays one event late"
2509        );
2510
2511        // …and the next event delivers it (a hover move, the gesture is over).
2512        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 50.0), 132.0);
2513        assert!(!w.pending_scroll_notify);
2514        assert_eq!(state.infos.len(), observed_before + 1);
2515
2516        // Past the curve's own end the driver drops it and the pump goes quiet.
2517        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(300.0));
2518        w.pump_fling(&mut ctx);
2519        assert!((w.offset() - (start + 20.0)).abs() < 1e-9);
2520        assert!(w.ballistic.is_none(), "a done simulation is dropped");
2521        assert!(!w.is_flinging());
2522        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(316.0));
2523        w.pump_fling(&mut ctx);
2524        assert!(!ctx.needs_frame(), "at rest the shell can idle again");
2525    }
2526
2527    /// A toy physics carrying a fixed +100 px/s out of *interrupted* motion
2528    /// (nothing to carry from a press onto a resting surface), leaving
2529    /// post-release motion to the legacy path like `RubberBand` does.
2530    #[derive(Debug)]
2531    struct CarryPhysics;
2532    impl ScrollPhysics for CarryPhysics {
2533        fn carried_momentum(&self, existing_velocity: f64) -> f64 {
2534            if existing_velocity == 0.0 { 0.0 } else { 100.0 }
2535        }
2536    }
2537
2538    /// Drag-release twice, the second press landing on the live fling of the
2539    /// first, and report the two fling velocities.
2540    fn fling_then_refling(w: &mut ScrollWidget) -> (f64, f64) {
2541        dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
2542        dispatch(w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2543        dispatch(w, &ev(PointerPhase::Move, 50.0), 32.0); // builds velocity
2544        dispatch(w, &ev(PointerPhase::Up, 50.0), 32.0);
2545        let first = w.fling.expect("release with velocity flings");
2546        // The second press interrupts that fling, and releases identically.
2547        dispatch(w, &ev(PointerPhase::Down, 100.0), 48.0);
2548        dispatch(w, &ev(PointerPhase::Move, 75.0), 64.0);
2549        dispatch(w, &ev(PointerPhase::Move, 50.0), 80.0);
2550        dispatch(w, &ev(PointerPhase::Up, 50.0), 80.0);
2551        (first, w.fling.expect("the second release flings too"))
2552    }
2553
2554    #[test]
2555    fn carried_momentum_hook_feeds_new_fling() {
2556        let mut w = laid_out(200.0, 100.0, 5000.0);
2557        w.physics = Rc::new(CarryPhysics);
2558        let (first, second) = fling_then_refling(&mut w);
2559        assert_eq!(
2560            second,
2561            first + 100.0,
2562            "a fling started during live motion carries the physics' momentum"
2563        );
2564
2565        // `RubberBand` carries nothing, so the identical sequence produces
2566        // the identical velocity twice (and keeps the legacy fling the helper
2567        // above reads — the bouncing default hands back a simulation instead,
2568        // pinned by `default_carried_momentum_compounds_a_refling`).
2569        let mut w = laid_out_rubber_band(200.0, 100.0, 5000.0);
2570        let (first, second) = fling_then_refling(&mut w);
2571        assert_eq!(second, first, "RubberBand starts every fling cold");
2572    }
2573
2574    // --- The platform default (`physics::default_physics`): bouncing on this
2575    //     host, clamping on Android. What a surface that names no physics of
2576    //     its own actually does — the depth-aware drag curve, the spring-back
2577    //     release, carried momentum, and the fling gate. ---
2578
2579    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
2580        assert!(
2581            (actual - expected).abs() < epsilon,
2582            "{what}: {actual} is not within {epsilon} of {expected}"
2583        );
2584    }
2585
2586    /// The host default, named once so every test below reads as "the default"
2587    /// rather than "Bouncing" — and so the pairing itself is asserted.
2588    #[test]
2589    fn a_fresh_surface_installs_the_platform_default() {
2590        let w = laid_out(200.0, 100.0, 1000.0);
2591        assert_eq!(
2592            format!("{:?}", w.physics),
2593            format!("{:?}", crate::physics::default_physics())
2594        );
2595        assert_eq!(w.effect, crate::physics::default_overscroll_effect());
2596    }
2597
2598    #[test]
2599    fn default_drag_tension_tightens_with_depth() {
2600        let mut w = laid_out(200.0, 100.0, 1000.0);
2601        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2602        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2603
2604        // 20px past the already-at-top edge, from zero depth: the friction
2605        // factor is 0.52·(1 − 0)² = 0.52, so 20 · 0.52 = 10.4 shows.
2606        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2607        let first = w.offset();
2608        assert_close(
2609            first,
2610            -20.0 * DecelerationRate::NORMAL_FRICTION,
2611            1e-12,
2612            "the first past-edge move, at zero depth",
2613        );
2614
2615        // 20px more. The surface now sits 10.4px out of a 100px viewport, so
2616        // the factor has tightened to 0.52·(1 − 0.104)² = 0.41746432 and this
2617        // move only adds 20 · 0.41746432 = 8.3492864 — a total of 18.7492864.
2618        dispatch(&mut w, &ev(PointerPhase::Move, 130.0), 48.0);
2619        let second = w.offset() - first;
2620        assert_close(second, -8.349_286_4, 1e-9, "the second, deeper move");
2621        assert_close(w.offset(), -18.749_286_4, 1e-9, "the accumulated pull");
2622        assert!(
2623            second.abs() < first.abs(),
2624            "the deeper pull must displace LESS per raw px: {second} vs {first}"
2625        );
2626        // The whole pull is displacement, none of it boundary-rejected.
2627        assert_eq!(w.edge_pull, w.scroll_info().overscroll);
2628    }
2629
2630    #[test]
2631    fn default_release_past_the_edge_springs_back_to_the_boundary() {
2632        let mut w = laid_out(200.0, 100.0, 1000.0);
2633        dispatch(&mut w, &ev(PointerPhase::Down, 50.0), 0.0);
2634        dispatch(&mut w, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2635        dispatch(&mut w, &ev(PointerPhase::Move, 110.0), 32.0);
2636        assert!(w.offset() < 0.0, "the drag left the surface past its top");
2637
2638        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
2639        assert!(
2640            w.ballistic.is_some(),
2641            "the default physics owns the release with a spring"
2642        );
2643        assert!(w.fling.is_none() && !w.settling, "…so no legacy path runs");
2644        assert!(w.is_flinging(), "post-release motion is live");
2645
2646        // Pump the shared frame clock until the spring reports itself done.
2647        let mut ms = 100.0;
2648        let mut steps = 0;
2649        let mut moved = false;
2650        while w.is_flinging() {
2651            let before = w.offset();
2652            let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
2653            w.pump_fling(&mut ctx);
2654            moved |= w.offset() != before;
2655            ms += 16.0;
2656            steps += 1;
2657            assert!(steps < 2_000, "the bounce-back never settled");
2658        }
2659        assert!(moved, "the spring must actually animate, not snap");
2660        assert!(
2661            w.offset().abs() < 1e-6,
2662            "the spring converges onto the boundary: {}",
2663            w.offset()
2664        );
2665        assert!(
2666            w.edge_pull.abs() < 1e-6,
2667            "…and leaves no pull behind: {}",
2668            w.edge_pull
2669        );
2670    }
2671
2672    #[test]
2673    fn default_carried_momentum_compounds_a_refling() {
2674        let mut w = laid_out(200.0, 100.0, 5000.0);
2675        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2676        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2677        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0); // builds velocity
2678        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
2679        let first = w
2680            .ballistic
2681            .as_ref()
2682            .expect("a release above the fling minimum is ballistic")
2683            .sim
2684            .dx(0.0);
2685        // 50px of finger travel over 32ms, and the offset moves opposite it.
2686        assert_close(first, 1562.5, 1e-9, "the first release velocity");
2687
2688        // The second press lands on that live curve, so its release carries
2689        // the physics' own fitted share of it forward.
2690        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
2691        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 64.0);
2692        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 80.0);
2693        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 80.0);
2694        let second = w
2695            .ballistic
2696            .as_ref()
2697            .expect("the second release is ballistic too")
2698            .sim
2699            .dx(0.0);
2700        assert_close(
2701            second,
2702            first + Bouncing::new().carried_momentum(first),
2703            1e-9,
2704            "the re-fling carries Bouncing::carried_momentum(first)",
2705        );
2706        assert!(second > first, "…which is a genuine speed-up");
2707    }
2708
2709    /// The signed start velocity of whatever post-release motion the last `Up`
2710    /// produced — the physics-supplied curve's own, the legacy fling's, or
2711    /// `0.0` for a release that started no motion at all. Reads all three
2712    /// outcomes through one number so a direction assertion does not depend on
2713    /// which release path caught the gesture.
2714    fn release_velocity(w: &ScrollWidget) -> f64 {
2715        match w.ballistic.as_ref() {
2716            Some(state) => state.sim.dx(0.0),
2717            None => w.fling.unwrap_or(0.0),
2718        }
2719    }
2720
2721    #[test]
2722    fn reverse_refling_keeps_the_fingers_velocity() {
2723        // Parked mid-content, so both releases are judged on velocity alone
2724        // with no edge spring in play.
2725        let mut w = laid_out(200.0, 100.0, 5000.0);
2726        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
2727
2728        // A downward fling: 50px of finger travel up over 32ms.
2729        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2730        dispatch(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
2731        dispatch(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
2732        dispatch(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
2733        assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
2734
2735        // The finger lands on that live curve and flicks back the other way,
2736        // just as fast. The interrupted motion's momentum must not be added to
2737        // a release pointing the other way — it would cancel the flick out (or
2738        // reverse it), and the surface would ignore the finger entirely.
2739        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
2740        dispatch(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); // takeover
2741        dispatch(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
2742        dispatch(&mut w, &ev(PointerPhase::Up, 150.0), 80.0);
2743        assert_close(
2744            release_velocity(&w),
2745            -1562.5,
2746            1e-9,
2747            "the reverse re-fling runs at the finger's own velocity",
2748        );
2749        assert!(
2750            w.ballistic.is_some(),
2751            "…as a real ballistic curve, not a stalled remnant"
2752        );
2753    }
2754
2755    #[test]
2756    fn small_reverse_flick_is_not_inverted() {
2757        let mut w = laid_out(200.0, 100.0, 5000.0);
2758        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
2759
2760        // Live downward motion at 1000 px/s: 32px of finger travel over 32ms.
2761        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2762        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
2763        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
2764        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
2765        assert_close(release_velocity(&w), 1000.0, 1e-9, "the live motion");
2766
2767        // A *modest* drag back the other way — 24px down over 60ms, 400 px/s,
2768        // well under the interrupted motion's own speed. Slower than what it
2769        // interrupted, but still unambiguously the other way: the surface must
2770        // never answer it by accelerating onward in the old direction.
2771        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
2772        dispatch(&mut w, &ev(PointerPhase::Move, 108.0), 68.0);
2773        dispatch(&mut w, &ev(PointerPhase::Move, 116.0), 88.0);
2774        dispatch(&mut w, &ev(PointerPhase::Move, 124.0), 108.0); // takeover
2775        dispatch(&mut w, &ev(PointerPhase::Up, 124.0), 108.0);
2776        let released = release_velocity(&w);
2777        assert!(
2778            released <= 0.0,
2779            "a reverse flick must never relaunch the surface the way it was \
2780             already going: {released}"
2781        );
2782        assert_close(
2783            released,
2784            -400.0,
2785            1e-9,
2786            "…it runs at the finger's own velocity instead",
2787        );
2788    }
2789
2790    /// The retain gate compares a release against the physics' **mapped**
2791    /// share of the interrupted velocity, not the raw interrupted speed —
2792    /// pinned here because the two diverge (`Bouncing`'s power curve sits
2793    /// below the raw value under ~1563 px/s). Interrupted at 1000 px/s,
2794    /// `Bouncing::new().carried_momentum(1000.0)` maps to ~649.7, putting the
2795    /// retain threshold at ~324.8 — well under the raw-carried threshold
2796    /// (500) the pre-fix gate used.
2797    #[test]
2798    fn momentum_retain_threshold_refuses_a_weak_refling() {
2799        // Parked mid-content, so the release is judged on velocity alone.
2800        let mut w = laid_out(200.0, 100.0, 5000.0);
2801        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
2802
2803        // Interrupted motion at 1000 px/s (the same drag
2804        // `small_reverse_flick_is_not_inverted` uses to establish it).
2805        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2806        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
2807        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
2808        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
2809        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
2810
2811        let mapped = Bouncing::new().carried_momentum(1000.0);
2812        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
2813        assert!(
2814            (300.0..350.0).contains(&threshold),
2815            "the fixture's release values must straddle the threshold: {threshold}"
2816        );
2817
2818        // Same-direction re-flick at 300 px/s — under the mapped threshold.
2819        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
2820        dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
2821        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); // 30px / 100ms → 300 px/s
2822        dispatch(&mut w, &ev(PointerPhase::Up, 70.0), 148.0);
2823        assert_close(
2824            release_velocity(&w),
2825            300.0,
2826            1e-9,
2827            "a release under the mapped threshold carries nothing forward",
2828        );
2829    }
2830
2831    /// The strong-side twin of `momentum_retain_threshold_refuses_a_weak_refling`:
2832    /// a release over the same mapped threshold carries `mapped` forward
2833    /// exactly, pre-clamp.
2834    #[test]
2835    fn momentum_retain_threshold_carries_a_strong_refling() {
2836        let mut w = laid_out(200.0, 100.0, 5000.0);
2837        dispatch(&mut w, &scroll(50.0, false, 1000.0), 0.0);
2838
2839        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
2840        dispatch(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
2841        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
2842        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
2843        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
2844
2845        let mapped = Bouncing::new().carried_momentum(1000.0);
2846        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
2847        assert!(
2848            (300.0..350.0).contains(&threshold),
2849            "the fixture's release values must straddle the threshold: {threshold}"
2850        );
2851
2852        // Same-direction re-flick at 350 px/s — over the mapped threshold.
2853        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
2854        dispatch(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
2855        dispatch(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); // 35px / 100ms → 350 px/s
2856        dispatch(&mut w, &ev(PointerPhase::Up, 65.0), 148.0);
2857        assert_close(
2858            release_velocity(&w),
2859            350.0 + mapped,
2860            1e-9,
2861            "a release over the mapped threshold carries `mapped` forward exactly",
2862        );
2863    }
2864
2865    #[test]
2866    fn default_min_fling_gate_is_one_hundred() {
2867        // Both halves start parked mid-content, so the release is judged on
2868        // velocity alone — an out-of-range release always gets its spring back
2869        // whatever the speed (`default_release_past_the_edge_springs_back…`).
2870        let mut slow = laid_out(200.0, 100.0, 5000.0);
2871        dispatch(&mut slow, &scroll(50.0, false, 1000.0), 0.0);
2872        dispatch(&mut slow, &ev(PointerPhase::Down, 100.0), 0.0);
2873        dispatch(&mut slow, &ev(PointerPhase::Move, 80.0), 16.0); // takeover
2874        // 6px of finger travel over the tracker's whole 100ms window: 60 px/s.
2875        dispatch(&mut slow, &ev(PointerPhase::Move, 94.0), 100.0);
2876        dispatch(&mut slow, &ev(PointerPhase::Up, 94.0), 100.0);
2877        assert!(
2878            slow.ballistic.is_none(),
2879            "60 px/s is under the bouncing minimum (100), so no ballistic"
2880        );
2881        // The physics declining leaves the release on the widget's own legacy
2882        // path, whose threshold is the pinned FLING_STOP (30 px/s) instead —
2883        // the one place the two ladders disagree, pinned so it cannot drift
2884        // unnoticed.
2885        assert_close(
2886            slow.fling.expect("the legacy fling catches it instead"),
2887            60.0,
2888            1e-9,
2889            "the legacy fallback runs at the raw release velocity",
2890        );
2891
2892        let mut fast = laid_out(200.0, 100.0, 5000.0);
2893        dispatch(&mut fast, &scroll(50.0, false, 1000.0), 0.0);
2894        dispatch(&mut fast, &ev(PointerPhase::Down, 100.0), 0.0);
2895        dispatch(&mut fast, &ev(PointerPhase::Move, 80.0), 16.0); // takeover
2896        // 15px over the same window: 150 px/s, over the minimum.
2897        dispatch(&mut fast, &ev(PointerPhase::Move, 85.0), 100.0);
2898        dispatch(&mut fast, &ev(PointerPhase::Up, 85.0), 100.0);
2899        let sim = fast
2900            .ballistic
2901            .as_ref()
2902            .expect("150 px/s clears the bouncing minimum");
2903        assert_close(sim.sim.dx(0.0), 150.0, 1e-9, "…at the release velocity");
2904        assert!(fast.fling.is_none(), "and the legacy fling stays out of it");
2905        assert_eq!(Bouncing::new().min_fling_velocity(), 100.0);
2906    }
2907
2908    // --- Pull-to-refresh under both shipped defaults: the trigger is measured
2909    //     on `edge_pull`, which a bouncing surface fills with displacement and
2910    //     a clamping one with boundary-rejected pull. ---
2911
2912    #[test]
2913    fn refresh_trigger_under_the_bouncing_default() {
2914        // 110px of raw pull at zero depth maps to 110 · 0.52 = 57.2, under the
2915        // 64px trigger.
2916        let mut w = observed(true);
2917        let mut state = ScrollLog::default();
2918        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2919        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2920        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 200.0), 32.0);
2921        assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
2922        assert_eq!(
2923            w.edge_pull,
2924            w.scroll_info().overscroll,
2925            "a bouncing surface rejects nothing, so the pull IS the displacement"
2926        );
2927        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 200.0), 48.0);
2928        assert_eq!(state.refreshes, 0, "release under the trigger never fires");
2929
2930        // 150px of raw pull maps to 78.0 — past it, so the release fires once.
2931        let mut w = observed(true);
2932        let mut state = ScrollLog::default();
2933        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2934        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2935        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 240.0), 32.0);
2936        assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
2937        assert!(crossed_refresh_trigger(w.edge_pull));
2938        assert_eq!(state.refreshes, 0, "no fire before release");
2939        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 240.0), 48.0);
2940        assert_eq!(state.refreshes, 1, "release past the trigger fires once");
2941    }
2942
2943    #[test]
2944    fn refresh_trigger_under_a_clamping_physics() {
2945        // Android's default, simulated on the host: the position never leaves
2946        // range, so the trigger is reached at the RAW pull distance.
2947        let mut w = observed(true);
2948        w.physics = Rc::new(Clamping::new());
2949        let mut state = ScrollLog::default();
2950        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2951        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2952        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 140.0), 32.0);
2953        assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
2954        assert_eq!(w.scroll_info().overscroll, 0.0);
2955        assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
2956        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 140.0), 48.0);
2957        assert_eq!(state.refreshes, 0, "50px of raw pull is under the trigger");
2958
2959        let mut w = observed(true);
2960        w.physics = Rc::new(Clamping::new());
2961        let mut state = ScrollLog::default();
2962        run_log(&mut w, &mut state, &ev(PointerPhase::Down, 50.0), 0.0);
2963        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 90.0), 16.0); // takeover
2964        run_log(&mut w, &mut state, &ev(PointerPhase::Move, 190.0), 32.0);
2965        assert_eq!(w.offset(), 0.0);
2966        assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
2967        run_log(&mut w, &mut state, &ev(PointerPhase::Up, 190.0), 48.0);
2968        assert_eq!(state.refreshes, 1, "past 64px of raw pull, it fires once");
2969        // Nothing to spring back (the position never moved), so the rejected
2970        // pull decays on the settle instead — which is what keeps a stretch
2971        // effect from snapping off at release.
2972        assert!(w.settling, "the rejected pull settles rather than springs");
2973        while w.settle_tick(16.0) {}
2974        assert_eq!(w.edge_pull, 0.0);
2975    }
2976
2977    // --- The M3E stretch effect: a paint-side affine about the pulled edge,
2978    //     with the content origin left where an in-range offset puts it. See
2979    //     the module docs' *Overscroll visuals*. ---
2980
2981    /// `Down`, a 40px past-slop takeover drag, then 20px further past the
2982    /// already-at-top edge — the same gesture the overscroll tests above use,
2983    /// so every effect below sees byte-identical input.
2984    fn drag_20px_past_top(w: &mut ScrollWidget) {
2985        dispatch(w, &ev(PointerPhase::Down, 50.0), 0.0);
2986        dispatch(w, &ev(PointerPhase::Move, 90.0), 16.0); // 40px > slop → takeover
2987        dispatch(w, &ev(PointerPhase::Move, 110.0), 32.0);
2988    }
2989
2990    /// Scroll to the bottom, then drag 60px past that edge.
2991    fn drag_60px_past_bottom(w: &mut ScrollWidget) {
2992        dispatch(w, &scroll(50.0, false, 5000.0), 0.0); // clamp to max_offset
2993        dispatch(w, &ev(PointerPhase::Down, 100.0), 0.0);
2994        dispatch(w, &ev(PointerPhase::Move, 60.0), 16.0); // takeover
2995        dispatch(w, &ev(PointerPhase::Move, 0.0), 32.0);
2996    }
2997
2998    /// Paint `w` into a recording scene and read back the one transform the
2999    /// stretch pushed, as `(anchor_y, scale_y)` in absolute paint space —
3000    /// painted-scene inspection, no test-only accessor. The affine is
3001    /// `translate(anchor)·scale(1, s)·translate(−anchor)`, whose coefficients
3002    /// are `[1, 0, 0, s, 0, anchor·(1 − s)]`, so both terms read straight back
3003    /// off it. `None` when the paint pushed no transform at all.
3004    fn painted_stretch(w: &mut ScrollWidget) -> Option<(f64, f64)> {
3005        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
3006        let mut scene = crate::test_support::RecordingScene::default();
3007        w.paint(&mut ctx, &mut scene);
3008        assert_eq!(
3009            scene.transforms.len(),
3010            scene.transform_pops as usize,
3011            "every pushed transform must be popped in the same paint"
3012        );
3013        assert!(
3014            scene.transforms.len() <= 1,
3015            "the stretch pushes at most one transform"
3016        );
3017        assert_eq!(scene.rects.len(), 1, "the child paints either way");
3018        let [a, b, c, d, e, f] = scene.transforms.first()?.as_coeffs();
3019        assert_eq!(
3020            [a, b, c, e],
3021            [1.0, 0.0, 0.0, 0.0],
3022            "a scroll-axis-only scale: no x scale, no skew, no x translation"
3023        );
3024        Some((f / (1.0 - d), d))
3025    }
3026
3027    #[test]
3028    fn stretch_intensity_curve_pins() {
3029        // An unpulled surface stretches not at all.
3030        assert_eq!(stretch_intensity(0.0, 100.0), 0.0);
3031
3032        // x = 0.1, hand-computed from the two constants:
3033        //   0.016·0.1 + 0.016·(1 − e^(−0.1·e/0.33))
3034        // = 0.0016   + 0.016·(1 − e^−0.8237217661997105)
3035        // = 0.01057927172144907
3036        assert!(
3037            (stretch_intensity(-10.0, 100.0) - 0.010_579_271_721_449_07).abs() < 1e-9,
3038            "the curve drifted from its pinned constants: {}",
3039            stretch_intensity(-10.0, 100.0)
3040        );
3041        assert_eq!(
3042            stretch_intensity(10.0, 100.0),
3043            stretch_intensity(-10.0, 100.0),
3044            "magnitude-only: the sign picks the anchor, never the amount"
3045        );
3046
3047        // Strictly increasing across the whole normalized range.
3048        let mut previous = 0.0;
3049        for step in 1..=100 {
3050            let intensity = stretch_intensity(step as f64, 100.0);
3051            assert!(
3052                intensity > previous,
3053                "the curve must increase monotonically (step {step}): {intensity} <= {previous}"
3054            );
3055            previous = intensity;
3056        }
3057
3058        // Bounded by the sum of the two terms' ceilings, and clamped past a
3059        // full-viewport pull rather than growing without limit.
3060        assert!(stretch_intensity(100.0, 100.0) <= 2.0 * STRETCH_INTENSITY);
3061        assert_eq!(
3062            stretch_intensity(500.0, 100.0),
3063            stretch_intensity(100.0, 100.0),
3064            "the normalized pull clamps at 1.0"
3065        );
3066        assert_eq!(
3067            stretch_intensity(-10.0, 0.0),
3068            0.0,
3069            "a degenerate viewport stretches nothing"
3070        );
3071    }
3072
3073    #[test]
3074    fn stretch_keeps_child_origin_fixed() {
3075        // The identical drag under each effect. The offset is the physics'
3076        // answer and must not vary; only what paint does with it does.
3077        let mut translate = laid_out_rubber_band(200.0, 100.0, 1000.0);
3078        drag_20px_past_top(&mut translate);
3079        let mut stretch = laid_out_rubber_band(200.0, 100.0, 1000.0);
3080        stretch.effect = OverscrollEffect::Stretch;
3081        drag_20px_past_top(&mut stretch);
3082        let mut none = laid_out_rubber_band(200.0, 100.0, 1000.0);
3083        none.effect = OverscrollEffect::None;
3084        drag_20px_past_top(&mut none);
3085
3086        assert_eq!(stretch.offset(), -10.0, "the resisted overscroll, as ever");
3087        assert_eq!(translate.offset(), stretch.offset());
3088        assert_eq!(none.offset(), stretch.offset());
3089        assert_eq!(translate.edge_pull, stretch.edge_pull);
3090
3091        // Translate paints the displacement into the child origin; Stretch and
3092        // None leave it exactly where an in-range offset would put it.
3093        assert_eq!(translate.child.origin().y, 10.0);
3094        assert_eq!(stretch.child.origin().y, 0.0);
3095        assert_eq!(none.child.origin().y, 0.0);
3096        assert_eq!(
3097            translate.child.origin().y - stretch.child.origin().y,
3098            -stretch.displacement(),
3099            "the two fixtures differ by exactly the overscroll displacement"
3100        );
3101
3102        // …and only Stretch paints a transform for it.
3103        assert_eq!(painted_stretch(&mut translate), None);
3104        assert_eq!(painted_stretch(&mut none), None);
3105        assert!(painted_stretch(&mut stretch).is_some());
3106    }
3107
3108    #[test]
3109    fn stretch_anchor_follows_pulled_edge() {
3110        let mut top = laid_out_rubber_band(200.0, 100.0, 1000.0);
3111        top.effect = OverscrollEffect::Stretch;
3112        drag_20px_past_top(&mut top);
3113        assert_eq!(top.edge_pull, -10.0, "pulled past the top");
3114        let (anchor, scale) = painted_stretch(&mut top).expect("a held pull stretches");
3115        assert!(
3116            anchor.abs() < 1e-9,
3117            "a top pull scales about the viewport's top edge: {anchor}"
3118        );
3119        assert!(
3120            (scale - (1.0 + stretch_intensity(-10.0, 100.0))).abs() < 1e-12,
3121            "scale is 1 + the curve's intensity: {scale}"
3122        );
3123        assert!(scale > 1.0, "the content grows, never shrinks");
3124
3125        let mut bottom = laid_out_rubber_band(200.0, 100.0, 1000.0);
3126        bottom.effect = OverscrollEffect::Stretch;
3127        drag_60px_past_bottom(&mut bottom);
3128        assert_eq!(bottom.edge_pull, 30.0, "pulled past the bottom");
3129        let (anchor, scale) = painted_stretch(&mut bottom).expect("a held pull stretches");
3130        assert!(
3131            (anchor - 100.0).abs() < 1e-9,
3132            "a bottom pull scales about the viewport's bottom edge: {anchor}"
3133        );
3134        assert!((scale - (1.0 + stretch_intensity(30.0, 100.0))).abs() < 1e-12);
3135    }
3136
3137    #[test]
3138    fn stretch_settles_back_to_identity() {
3139        let mut w = laid_out_rubber_band(200.0, 100.0, 1000.0);
3140        w.effect = OverscrollEffect::Stretch;
3141        drag_20px_past_top(&mut w);
3142        let (_, held) = painted_stretch(&mut w).expect("the held pull stretches");
3143
3144        dispatch(&mut w, &ev(PointerPhase::Up, 110.0), 48.0);
3145        assert!(w.settling, "an overscrolled release settles, effect or not");
3146        let (_, releasing) = painted_stretch(&mut w).expect("the settle still stretches");
3147        assert!(
3148            releasing <= held,
3149            "the stretch decays with the pull, never grows: {releasing} > {held}"
3150        );
3151
3152        let mut steps = 0;
3153        while w.settle_tick(16.0) {
3154            steps += 1;
3155            assert!(steps < 10_000, "settle failed to terminate");
3156        }
3157        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
3158        assert_eq!(
3159            painted_stretch(&mut w),
3160            None,
3161            "…so paint pushes no transform at all — back to identity"
3162        );
3163
3164        // And with nothing left to animate, the pump stops asking for frames.
3165        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
3166        let mut scene = crate::test_support::RecordingScene::default();
3167        w.paint(&mut ctx, &mut scene);
3168        assert!(
3169            !ctx.needs_frame(),
3170            "a settled stretch stops requesting frames"
3171        );
3172    }
3173
3174    /// A toy clamping physics: it rejects 100% of any past-edge proposal, so
3175    /// the position never leaves range and the entire pull is reported as
3176    /// boundary rejection instead — the Android-style clamping-plus-stretch
3177    /// pairing, exercised here without depending on any composed default.
3178    #[derive(Debug)]
3179    struct RejectPastEdge;
3180    impl ScrollPhysics for RejectPastEdge {
3181        fn apply_boundary_conditions(&self, metrics: &ScrollMetrics, value: f64) -> f64 {
3182            value - value.clamp(metrics.min_scroll_extent, metrics.max_scroll_extent)
3183        }
3184    }
3185
3186    #[test]
3187    fn stretch_under_boundary_rejection_uses_edge_pull() {
3188        let mut w = laid_out(200.0, 100.0, 1000.0);
3189        w.effect = OverscrollEffect::Stretch;
3190        w.physics = Rc::new(RejectPastEdge);
3191        drag_20px_past_top(&mut w);
3192
3193        assert_eq!(
3194            w.offset(),
3195            0.0,
3196            "a clamping physics never lets the position leave range"
3197        );
3198        assert_eq!(
3199            w.scroll_info().overscroll,
3200            0.0,
3201            "…so there is no displacement for Translate to have shown"
3202        );
3203        // The whole raw 20px is rejected excess (this physics maps the drag
3204        // itself with the trait's identity default — no rubber-band halving).
3205        assert_eq!(w.edge_pull, -20.0, "the pull is still reported in full");
3206
3207        let (anchor, scale) = painted_stretch(&mut w).expect("a rejected pull still stretches");
3208        assert!(
3209            anchor.abs() < 1e-9,
3210            "anchored at the pulled (top) edge: {anchor}"
3211        );
3212        assert!((scale - (1.0 + stretch_intensity(-20.0, 100.0))).abs() < 1e-12);
3213        assert!(scale > 1.0, "clamping + stretch is a visible effect");
3214        assert_eq!(
3215            w.child.origin().y,
3216            0.0,
3217            "and the content itself never moves"
3218        );
3219    }
3220
3221    #[test]
3222    fn clamping_fling_into_the_edge_settles_the_stretch() {
3223        use crate::physics::Tolerance;
3224        use crate::physics::simulation::ClampingScrollSimulation;
3225
3226        // The shipped Android pairing, run on the host: parked 100px short of
3227        // the bottom, released at 1000 px/s straight into it. The clamping
3228        // curve is unbounded (`physics::parity`'s *Why Clamping needs no
3229        // clamped simulation adapter*), so the driver pins the offset and
3230        // routes the whole overshoot into `edge_pull` — the release must not
3231        // leave that pull, and the stretch it paints, standing.
3232        let mut w = laid_out(200.0, 100.0, 1000.0);
3233        w.physics = Rc::new(Clamping::new());
3234        w.effect = OverscrollEffect::Stretch;
3235        dispatch(&mut w, &scroll(50.0, false, 800.0), 0.0);
3236        assert_eq!(w.offset(), 800.0, "parked 100px short of the 900px bottom");
3237
3238        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3239        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); // 32px > slop → takeover
3240        dispatch(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
3241        dispatch(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
3242        let released = w.ballistic.as_ref().expect("a clamping release flings");
3243        assert_close(released.sim.dx(0.0), 1000.0, 1e-9, "the release velocity");
3244
3245        // The same curve, spelled out: it runs far past the extent, which is
3246        // what makes the pull it feeds `edge_pull` a real one.
3247        let curve = ClampingScrollSimulation::new(
3248            800.0,
3249            1000.0,
3250            ClampingScrollSimulation::DEFAULT_FRICTION,
3251            Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
3252        );
3253        assert!(
3254            curve.final_x() > 1000.0,
3255            "the spline must overshoot the 900px extent by >100px: {}",
3256            curve.final_x()
3257        );
3258        let spline_frames = (curve.duration() * 1000.0 / 16.0).ceil();
3259
3260        // Pump the shared frame clock until nothing asks for another frame.
3261        let mut ms = 100.0;
3262        let mut frames = 0.0;
3263        let mut ballistic_frames = 0.0;
3264        let mut peak = 0.0f64;
3265        loop {
3266            let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, frame_time(ms));
3267            w.pump_fling(&mut ctx);
3268            peak = peak.max(w.edge_pull.abs());
3269            if w.ballistic.is_some() {
3270                ballistic_frames += 1.0;
3271            }
3272            ms += 16.0;
3273            frames += 1.0;
3274            assert!(
3275                frames < 2_000.0,
3276                "the release never came to rest: edge_pull {}",
3277                w.edge_pull
3278            );
3279            if !ctx.needs_frame() {
3280                break;
3281            }
3282        }
3283
3284        assert!(
3285            peak > 1.0,
3286            "the fling must actually reach the edge for this to mean anything: {peak}"
3287        );
3288        assert_close(
3289            w.offset(),
3290            900.0,
3291            1e-9,
3292            "the offset ends pinned at the edge",
3293        );
3294        assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
3295        assert_eq!(
3296            painted_stretch(&mut w),
3297            None,
3298            "…so paint pushes no transform at all — back to identity"
3299        );
3300
3301        // And the simulation itself stops the moment it is pinned outward,
3302        // rather than pumping dead frames for the rest of the spline.
3303        assert!(
3304            ballistic_frames < spline_frames / 2.0,
3305            "the pinned curve ran {ballistic_frames} frames of a {spline_frames}-frame spline"
3306        );
3307    }
3308
3309    // --- Nested scrolling: innermost-wins arbitration. See the module docs'
3310    //     *Nested scrolling*. ---
3311
3312    /// Which layer of a nested fixture an observation came from — an index into
3313    /// [`Nest`]'s per-layer logs, so one builder serves every depth.
3314    const OUTER: usize = 0;
3315    /// The middle layer of the three-deep fixture.
3316    const MIDDLE: usize = 1;
3317    /// The innermost scroll surface of a nested fixture.
3318    const INNER: usize = 2;
3319
3320    /// What each layer of a nested fixture observed, by layer index.
3321    #[derive(Default)]
3322    struct Nest {
3323        /// Every `ScrollInfo` a layer reported through `on_scroll`.
3324        scrolls: [Vec<ScrollInfo>; 3],
3325        /// How many times a layer's pull-to-refresh fired.
3326        refreshes: [u32; 3],
3327    }
3328
3329    /// The pointer phases the deepest, non-scrollable content saw — how a
3330    /// `Cancel` is attributed to whichever surface sent it.
3331    #[derive(Clone, Copy, Default)]
3332    struct ContentSeen {
3333        downs: u32,
3334        cancels: u32,
3335    }
3336
3337    /// 1000px of ordinary, non-scrollable content tallying what reaches it.
3338    ///
3339    /// Counted through an `Rc<Cell<_>>` rather than `EventCtx::state_mut`
3340    /// because a `Cancel` arm never touches state
3341    /// (`docs/CODE_STANDARDS.md`'s Interaction Semantics) — and `Cancel`s are
3342    /// exactly what this probe exists to count.
3343    struct NestContent(Rc<Cell<ContentSeen>>);
3344    /// Retained widget for [`NestContent`].
3345    struct NestContentW(Rc<Cell<ContentSeen>>);
3346
3347    /// A [`NestContent`] tallying into `seen`.
3348    fn nest_content(seen: &Rc<Cell<ContentSeen>>) -> NestContent {
3349        NestContent(Rc::clone(seen))
3350    }
3351
3352    /// A [`NestContent`] whose tally nobody reads — the placeholder child a
3353    /// layer is built with before [`nest`] wires the real nested surface into
3354    /// its place. Its 1000px height is what gives that layer its content
3355    /// extent, so the placeholder is load-bearing even after the swap.
3356    fn spacer_content() -> NestContent {
3357        NestContent(Rc::new(Cell::new(ContentSeen::default())))
3358    }
3359
3360    impl View<Nest> for NestContent {
3361        type Element = NestContentW;
3362        fn build(&self, _c: &mut BuildCtx<'_>) -> NestContentW {
3363            NestContentW(Rc::clone(&self.0))
3364        }
3365        fn rebuild(&self, _p: &Self, _e: &mut NestContentW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
3366            ChangeFlags::NONE
3367        }
3368    }
3369
3370    impl Widget for NestContentW {
3371        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3372            bc.constrain(Size::new(200.0, 1000.0))
3373        }
3374        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
3375        fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
3376            if let InputEvent::Pointer(p) = e {
3377                let mut seen = self.0.get();
3378                match p.phase {
3379                    PointerPhase::Down => seen.downs += 1,
3380                    PointerPhase::Cancel => seen.cancels += 1,
3381                    _ => {}
3382                }
3383                self.0.set(seen);
3384            }
3385            EventResult::Ignored
3386        }
3387    }
3388
3389    /// Build and lay out one layer of a nested fixture: a `viewport_h`-tall
3390    /// viewport over `child`, reporting scrolls and refreshes under `layer`.
3391    ///
3392    /// Laid out **tight** rather than by an enclosing surface, because a
3393    /// `ScrollView` hands its child *unbounded* height — a nested scroll
3394    /// surface laid out that way sizes its viewport to its own content and has
3395    /// nothing left to scroll. A real tree bounds it (a `SizedBox`, a list
3396    /// row's own extent); the fixture states the resulting geometry directly
3397    /// rather than threading a third widget through every assertion.
3398    fn nest_surface(child: impl View<Nest>, viewport_h: f64, layer: usize) -> ScrollWidget {
3399        let view: ScrollView<Nest> = scroll_view(child)
3400            .on_scroll(move |s: &mut Nest, info| s.scrolls[layer].push(info))
3401            .on_refresh_release(move |s: &mut Nest| s.refreshes[layer] += 1);
3402        let mut counter = 0u64;
3403        let mut w = View::<Nest>::build(&view, &mut BuildCtx::new(&mut counter));
3404        let mut lctx = LayoutCtx::new();
3405        w.layout(
3406            &mut lctx,
3407            &BoxConstraints::tight(Size::new(200.0, viewport_h)),
3408        );
3409        w
3410    }
3411
3412    /// [`nest_surface`] with the pre-seam [`RubberBand`] feel pinned
3413    /// explicitly — the variant the nested tests that assert a `0.5`-resisted
3414    /// number build their feel-carrying layer from (see
3415    /// [`laid_out_rubber_band`]); the arbitration itself is physics-agnostic,
3416    /// so every other layer stays on the platform default.
3417    fn nest_surface_rubber_band(
3418        child: impl View<Nest>,
3419        viewport_h: f64,
3420        layer: usize,
3421    ) -> ScrollWidget {
3422        let mut w = nest_surface(child, viewport_h, layer);
3423        w.physics = Rc::new(RubberBand::new());
3424        w
3425    }
3426
3427    /// Wire `inner` in as `outer`'s single child — the nesting a real tree
3428    /// builds through a bounded-height wrapper (see [`nest_surface`]).
3429    fn nest(outer: &mut ScrollWidget, inner: ScrollWidget) {
3430        outer.child = ChildPod::new(Box::new(inner));
3431    }
3432
3433    /// The nested surface [`nest`] wired under `outer` (single-boxed, unlike an
3434    /// `AnyView`-erased pod).
3435    fn nested_of(outer: &ScrollWidget) -> &ScrollWidget {
3436        (outer.child.widget() as &dyn Any)
3437            .downcast_ref::<ScrollWidget>()
3438            .expect("the fixture wired a ScrollWidget child")
3439    }
3440
3441    /// The nested `ListView` wired under `outer`.
3442    fn nested_list_of(outer: &ScrollWidget) -> &crate::list_view::ListViewWidget {
3443        (outer.child.widget() as &dyn Any)
3444            .downcast_ref::<crate::list_view::ListViewWidget>()
3445            .expect("the fixture wired a ListViewWidget child")
3446    }
3447
3448    /// Build and lay out a nested `ListView` — 10 rows of 100px in a
3449    /// `viewport_h`-tall viewport, tight for the same reason
3450    /// [`nest_surface`] is.
3451    fn nested_list(viewport_h: f64) -> crate::list_view::ListViewWidget {
3452        let view: crate::list_view::ListView<Nest> =
3453            crate::list_view::list_view(10, 100.0, |_| any(spacer_content()));
3454        let mut counter = 0u64;
3455        let mut w = View::<Nest>::build(&view, &mut BuildCtx::new(&mut counter));
3456        let mut lctx = LayoutCtx::new();
3457        w.layout(
3458            &mut lctx,
3459            &BoxConstraints::tight(Size::new(200.0, viewport_h)),
3460        );
3461        w
3462    }
3463
3464    fn run_nest(w: &mut ScrollWidget, state: &mut Nest, e: &InputEvent, t: f64) {
3465        let sa: &mut dyn Any = state;
3466        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
3467        w.event_at(&mut ctx, e, t);
3468    }
3469
3470    /// Park a surface at `offset` px with a wheel scroll (hard-clamped, no
3471    /// overscroll and no gesture state) — how a real surface reaches a
3472    /// mid-content position.
3473    fn park(w: &mut dyn Widget, viewport_h: f64, offset: f64) {
3474        let mut throwaway = Nest::default();
3475        let sa: &mut dyn Any = &mut throwaway;
3476        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
3477        w.event(&mut ctx, &scroll(50.0, false, offset));
3478    }
3479
3480    #[test]
3481    fn outer_defers_when_inner_can_consume() {
3482        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3483        let seen = Rc::new(Cell::new(ContentSeen::default()));
3484        let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
3485        // Mid-content, at neither edge: the inner's claim comes from actual
3486        // room, not from a displacement-allowing physics.
3487        park(&mut inner, 120.0, 400.0);
3488        assert_eq!(inner.offset(), 400.0);
3489        nest(&mut outer, inner);
3490
3491        let mut state = Nest::default();
3492        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
3493        assert!(
3494            outer.inner_at_down.registered,
3495            "the nested surface reported itself on the forwarded Down"
3496        );
3497        assert!(outer.inner_at_down.can_consume_up_drag);
3498        assert_eq!(seen.get().downs, 1, "the Down still reached the content");
3499
3500        // 50px of finger-up drag, past the slop: the outer stands down.
3501        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
3502        assert!(outer.deferring, "the outer deferred to the nested surface");
3503        assert!(!outer.scrolling);
3504        assert_eq!(outer.offset(), 0.0, "…and never moved");
3505        assert!(state.scrolls[OUTER].is_empty(), "…nor reported a scroll");
3506        // The one Cancel the content saw came from the INNER's own takeover —
3507        // the outer sent none, and it is the inner that is now scrolling.
3508        assert!(nested_of(&outer).scrolling, "the inner took the gesture");
3509        assert_eq!(seen.get().cancels, 1);
3510
3511        // The rest of the drag lands in the inner, still never in the outer.
3512        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
3513        assert_eq!(
3514            nested_of(&outer).offset(),
3515            440.0,
3516            "the inner consumed the 40px"
3517        );
3518        assert_eq!(outer.offset(), 0.0);
3519        assert!(state.scrolls[OUTER].is_empty());
3520        assert!(!state.scrolls[INNER].is_empty(), "the inner reported it");
3521        assert_eq!(seen.get().cancels, 1, "no second Cancel from anywhere");
3522    }
3523
3524    #[test]
3525    fn outer_takes_over_when_inner_pinned() {
3526        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3527        let seen = Rc::new(Cell::new(ContentSeen::default()));
3528        let mut inner = nest_surface(nest_content(&seen), 120.0, INNER);
3529        // At its top under a physics that rejects every past-edge proposal:
3530        // there is nothing a downward drag can do here.
3531        inner.physics = Rc::new(Clamping::new());
3532        nest(&mut outer, inner);
3533
3534        let mut state = Nest::default();
3535        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3536        assert!(
3537            outer.inner_at_down.registered,
3538            "the inner still reports itself…"
3539        );
3540        assert!(
3541            !outer.inner_at_down.can_consume_down_drag,
3542            "…pinned against a downward drag"
3543        );
3544        assert!(
3545            outer.inner_at_down.can_consume_up_drag,
3546            "…though not against an upward one"
3547        );
3548
3549        // 40px down, past the slop: the outer takes over exactly as ever.
3550        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3551        assert!(outer.scrolling);
3552        assert!(!outer.deferring);
3553        assert_eq!(
3554            seen.get().cancels,
3555            1,
3556            "the outer's takeover Cancel reached the content through the inner"
3557        );
3558        assert_eq!(
3559            outer.offset(),
3560            0.0,
3561            "the takeover move does not itself scroll"
3562        );
3563
3564        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3565        assert_eq!(
3566            outer.offset(),
3567            -10.0,
3568            "the resisted 20px past-top overscroll, as ever"
3569        );
3570        assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
3571        assert!(state.scrolls[INNER].is_empty());
3572    }
3573
3574    #[test]
3575    fn bouncing_inner_wins_even_at_edge() {
3576        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3577        let seen = Rc::new(Cell::new(ContentSeen::default()));
3578        // A `RubberBand` inner at the very top: like the bouncing default it
3579        // rejects nothing, so it can still answer a downward pull with a
3580        // rubber-band — and its resisted number is the one pinned below.
3581        let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
3582        nest(&mut outer, inner);
3583
3584        let mut state = Nest::default();
3585        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3586        assert!(
3587            outer.inner_at_down.can_consume_down_drag,
3588            "a displacement-allowing physics claims even pinned at the top"
3589        );
3590
3591        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3592        assert!(
3593            outer.deferring,
3594            "the outer defers even though the inner sits at offset 0"
3595        );
3596        assert!(nested_of(&outer).scrolling);
3597        assert_eq!(seen.get().cancels, 1, "the inner's own takeover Cancel");
3598
3599        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3600        assert_eq!(
3601            nested_of(&outer).offset(),
3602            -10.0,
3603            "the inner rubber-bands past its own top"
3604        );
3605        assert_eq!(outer.offset(), 0.0, "and the outer stays exactly put");
3606        assert!(state.scrolls[OUTER].is_empty());
3607    }
3608
3609    #[test]
3610    fn content_fits_inner_does_not_steal_the_drag() {
3611        // A bouncing-family inner whose content exactly fills its viewport —
3612        // `max_scroll_extent == min_scroll_extent`, nothing to scroll either
3613        // way — under the platform default (no `.physics(...)` override).
3614        // `should_accept_user_offset` is hardcoded `true` for the whole
3615        // bouncing family, so without a capacity conjunct in
3616        // `inner_claim_state` this would register and defer forever; the
3617        // outer must still win the drag (`inner_claim_state`'s doc comment,
3618        // the module docs' *Nested scrolling*). The outer runs `RubberBand`
3619        // (like `outer_takes_over_when_inner_pinned`) so its resisted number
3620        // is the deterministic one pinned below rather than the default's
3621        // progressive depth curve.
3622        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3623        let seen = Rc::new(Cell::new(ContentSeen::default()));
3624        // 1000px viewport over the fixture's fixed 1000px content: an exact
3625        // fit, so `max_offset() == 0.0`.
3626        let inner = nest_surface(nest_content(&seen), 1000.0, INNER);
3627        assert_eq!(
3628            inner.max_offset(),
3629            0.0,
3630            "the fixture's content exactly fits"
3631        );
3632        nest(&mut outer, inner);
3633
3634        let mut state = Nest::default();
3635        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3636        assert!(
3637            !outer.inner_at_down.registered,
3638            "no real capacity to scroll, so the claim never registers"
3639        );
3640
3641        // 40px down, past the slop: the outer takes over exactly as the
3642        // no-nested-scrollable case always has.
3643        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3644        assert!(outer.scrolling, "the outer takes the drag over");
3645        assert!(!outer.deferring);
3646        assert_eq!(
3647            seen.get().cancels,
3648            1,
3649            "the outer's takeover Cancel reached the content through the inner"
3650        );
3651
3652        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3653        assert_eq!(
3654            outer.offset(),
3655            -10.0,
3656            "the resisted 20px past-top overscroll, same as a pinned-inner takeover"
3657        );
3658        assert_eq!(nested_of(&outer).offset(), 0.0, "the inner never moved");
3659        assert_eq!(nested_of(&outer).edge_pull, 0.0, "…nor accrued any pull");
3660        assert!(state.scrolls[INNER].is_empty(), "the inner saw nothing");
3661    }
3662
3663    #[test]
3664    fn no_inner_behavior_identical() {
3665        // The pre-existing takeover, unchanged with arbitration in place: a
3666        // plain non-scrollable child registers nothing, so nothing defers.
3667        // Viewport 100 over 1000px of content — `laid_out(200, 100, 1000)`'s
3668        // geometry, so the numbers below are the shipped ones.
3669        let seen = Rc::new(Cell::new(ContentSeen::default()));
3670        let mut w = nest_surface(nest_content(&seen), 100.0, OUTER);
3671        assert_eq!(w.max_offset(), 900.0);
3672        let mut state = Nest::default();
3673
3674        run_nest(&mut w, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
3675        assert!(!w.inner_at_down.registered, "a plain child claims nothing");
3676        assert_eq!(seen.get().downs, 1);
3677
3678        run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 70.0), 16.0);
3679        assert!(w.scrolling, "the slop still takes the gesture over");
3680        assert!(!w.deferring);
3681        assert_eq!(seen.get().cancels, 1, "exactly one child Cancel, as before");
3682        assert_eq!(w.offset(), 0.0, "the takeover move does not itself scroll");
3683
3684        run_nest(&mut w, &mut state, &ev(PointerPhase::Move, 40.0), 32.0);
3685        assert_eq!(
3686            w.offset(),
3687            30.0,
3688            "the 30px `drag_past_slop_scrolls_the_offset` pins"
3689        );
3690        assert_eq!(seen.get().cancels, 1, "no further move reaches the child");
3691    }
3692
3693    #[test]
3694    fn three_deep_nesting_pairs_nearest() {
3695        let seen = Rc::new(Cell::new(ContentSeen::default()));
3696        let innermost = nest_surface(nest_content(&seen), 80.0, INNER);
3697        let mut middle = nest_surface(spacer_content(), 140.0, MIDDLE);
3698        nest(&mut middle, innermost);
3699        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3700        nest(&mut outer, middle);
3701
3702        let mut state = Nest::default();
3703        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 75.0), 0.0);
3704        // Each level learns about the level it actually contains and no
3705        // further: the innermost's own child is plain content, and nothing
3706        // propagated its (absent) claim up past the middle.
3707        assert!(outer.inner_at_down.registered, "outer sees the middle");
3708        assert!(
3709            nested_of(&outer).inner_at_down.registered,
3710            "middle sees the innermost"
3711        );
3712        assert!(
3713            !nested_of(nested_of(&outer)).inner_at_down.registered,
3714            "the innermost sees no scrollable below it"
3715        );
3716
3717        // A finger-up drag every layer could consume: the innermost gets it.
3718        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 35.0), 16.0);
3719        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 5.0), 32.0);
3720        assert!(outer.deferring && nested_of(&outer).deferring);
3721        assert!(!nested_of(nested_of(&outer)).deferring);
3722        assert!(nested_of(nested_of(&outer)).scrolling);
3723        assert_eq!(outer.offset(), 0.0, "the outermost never moved");
3724        assert_eq!(nested_of(&outer).offset(), 0.0, "nor the middle");
3725        assert_eq!(
3726            nested_of(nested_of(&outer)).offset(),
3727            30.0,
3728            "only the innermost took the drag"
3729        );
3730        assert!(state.scrolls[OUTER].is_empty() && state.scrolls[MIDDLE].is_empty());
3731        assert!(!state.scrolls[INNER].is_empty());
3732    }
3733
3734    #[test]
3735    fn up_and_cancel_still_reach_child_when_deferring() {
3736        // The device-gate case end to end: a refresh surface owning its own
3737        // scroll, under a page-level scroll. The outer must forward the whole
3738        // gesture — including the release that fires the refresh.
3739        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3740        let seen = Rc::new(Cell::new(ContentSeen::default()));
3741        let inner = nest_surface_rubber_band(nest_content(&seen), 120.0, INNER);
3742        nest(&mut outer, inner);
3743
3744        let mut state = Nest::default();
3745        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3746        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3747        assert!(
3748            outer.deferring,
3749            "the inner can rubber-band, so the outer defers"
3750        );
3751        // Pull the inner well past its own refresh trigger (150px raw, halved
3752        // by the rubber-band resistance to 75 > REFRESH_TRIGGER_PX).
3753        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 210.0), 32.0);
3754        assert_eq!(nested_of(&outer).offset(), -75.0);
3755        assert!(crossed_refresh_trigger(nested_of(&outer).edge_pull));
3756
3757        run_nest(&mut outer, &mut state, &ev(PointerPhase::Up, 210.0), 48.0);
3758        assert_eq!(
3759            state.refreshes[INNER], 1,
3760            "the forwarded Up fired the INNER's refresh exactly once"
3761        );
3762        assert_eq!(state.refreshes[OUTER], 0, "and never the outer's");
3763        assert_eq!(outer.offset(), 0.0, "the outer never scrolled at all");
3764        assert!(state.scrolls[OUTER].is_empty());
3765        // The Up clears the arbitration state, so the next gesture arbitrates
3766        // from scratch rather than inheriting this one's answer.
3767        assert!(!outer.deferring);
3768        assert!(!outer.inner_at_down.registered);
3769        assert!(!outer.scrolling && !outer.down_active);
3770
3771        // A second gesture, cancelled mid-drag: the Cancel is forwarded too.
3772        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 64.0);
3773        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 80.0);
3774        assert!(outer.deferring, "still the inner's gesture");
3775        run_nest(
3776            &mut outer,
3777            &mut state,
3778            &ev(PointerPhase::Cancel, 60.0),
3779            96.0,
3780        );
3781        // Three Cancels all told: the inner's takeover in each of the two
3782        // gestures, plus this forwarded one reaching the content through it.
3783        assert_eq!(seen.get().cancels, 3);
3784        assert_eq!(state.refreshes[INNER], 1, "a Cancel never fires a refresh");
3785        assert!(
3786            !outer.deferring,
3787            "the Cancel clears the arbitration state too"
3788        );
3789        assert!(!outer.inner_at_down.registered);
3790    }
3791
3792    #[test]
3793    fn a_nested_list_view_wins_the_drag_from_a_scroll_view() {
3794        let mut outer = nest_surface(spacer_content(), 200.0, OUTER);
3795        let mut list = nested_list(150.0);
3796        park(&mut list, 150.0, 300.0);
3797        assert_eq!(list.offset(), 300.0);
3798        outer.child = ChildPod::new(Box::new(list));
3799
3800        let mut state = Nest::default();
3801        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 100.0), 0.0);
3802        assert!(
3803            outer.inner_at_down.registered,
3804            "a nested ListView reports itself on the same seam"
3805        );
3806        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 50.0), 16.0);
3807        assert!(outer.deferring);
3808        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 10.0), 32.0);
3809        assert_eq!(outer.offset(), 0.0, "the outer never moved");
3810        assert!(state.scrolls[OUTER].is_empty());
3811        assert_eq!(
3812            nested_list_of(&outer).offset(),
3813            340.0,
3814            "the nested list took the 40px"
3815        );
3816    }
3817
3818    #[test]
3819    fn a_pinned_nested_list_view_hands_the_drag_back_to_the_scroll_view() {
3820        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3821        let mut list = nested_list(150.0);
3822        list.physics = Rc::new(Clamping::new());
3823        outer.child = ChildPod::new(Box::new(list));
3824
3825        let mut state = Nest::default();
3826        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3827        assert!(
3828            !outer.inner_at_down.can_consume_down_drag,
3829            "the nested list is pinned at its own top"
3830        );
3831        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3832        assert!(outer.scrolling, "so the outer takes over as it always has");
3833        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3834        assert_eq!(
3835            outer.offset(),
3836            -10.0,
3837            "the resisted 20px past-top overscroll"
3838        );
3839        assert_eq!(
3840            nested_list_of(&outer).offset(),
3841            0.0,
3842            "the nested list never moved"
3843        );
3844    }
3845
3846    #[test]
3847    fn a_content_fits_nested_list_view_does_not_steal_the_drag() {
3848        // The `ListView` twin of `content_fits_inner_does_not_steal_the_drag`:
3849        // both widgets route the claim through the same shared
3850        // `inner_claim_state`, so this pins that the capacity conjunct
3851        // applies here too rather than being a `ScrollWidget`-only fix.
3852        let mut outer = nest_surface_rubber_band(spacer_content(), 200.0, OUTER);
3853        // 10 rows of 100px in a 1000px viewport: an exact fit.
3854        let list = nested_list(1000.0);
3855        assert_eq!(list.max_offset(), 0.0, "the fixture's content exactly fits");
3856        outer.child = ChildPod::new(Box::new(list));
3857
3858        let mut state = Nest::default();
3859        run_nest(&mut outer, &mut state, &ev(PointerPhase::Down, 20.0), 0.0);
3860        assert!(
3861            !outer.inner_at_down.registered,
3862            "no real capacity to scroll, so the claim never registers"
3863        );
3864
3865        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 60.0), 16.0);
3866        assert!(outer.scrolling, "the outer takes the drag over");
3867        run_nest(&mut outer, &mut state, &ev(PointerPhase::Move, 80.0), 32.0);
3868        assert_eq!(
3869            outer.offset(),
3870            -10.0,
3871            "the resisted 20px past-top overscroll, same as a pinned-inner takeover"
3872        );
3873        assert_eq!(
3874            nested_list_of(&outer).offset(),
3875            0.0,
3876            "the nested list never moved"
3877        );
3878    }
3879
3880    // --- (07) The public builder surface: `.physics(...)`/`.overscroll_effect(...)` ---
3881
3882    #[test]
3883    fn physics_builder_installs_custom_physics() {
3884        let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
3885        let mut counter = 0u64;
3886        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
3887        let mut lctx = LayoutCtx::new();
3888        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
3889
3890        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3891        // Same slop-crossing shape as `drag_past_slop_scrolls_the_offset`, but
3892        // `NeverScrollable` refuses the drag outright.
3893        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
3894        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
3895        assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
3896        assert!(
3897            !w.scrolling,
3898            "NeverScrollable must never take the gesture over"
3899        );
3900
3901        // A default-built twin (no `.physics(...)` call) still scrolls normally
3902        // under `RubberBand` — proving the builder, not some global default
3903        // change, is what reached the widget above.
3904        let mut default_w = laid_out(200.0, 100.0, 1000.0);
3905        dispatch(&mut default_w, &ev(PointerPhase::Down, 100.0), 0.0);
3906        dispatch(&mut default_w, &ev(PointerPhase::Move, 70.0), 16.0);
3907        dispatch(&mut default_w, &ev(PointerPhase::Move, 40.0), 32.0);
3908        assert_eq!(
3909            default_w.offset(),
3910            30.0,
3911            "the default twin scrolls normally"
3912        );
3913    }
3914
3915    #[test]
3916    fn effect_builder_reaches_widget() {
3917        let view: ScrollView<()> =
3918            scroll_view(leaf(200.0, 1000.0)).overscroll_effect(OverscrollEffect::Stretch);
3919        let mut counter = 0u64;
3920        let w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
3921        assert_eq!(w.effect, OverscrollEffect::Stretch);
3922
3923        // A default-built twin takes the platform pairing instead — asserted
3924        // against the selector rather than a literal, so this reads the same
3925        // on the Android arm (Stretch) as on this one (Translate).
3926        let default_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
3927        let default_w = View::<()>::build(&default_view, &mut BuildCtx::new(&mut counter));
3928        assert_eq!(
3929            default_w.effect,
3930            crate::physics::default_overscroll_effect()
3931        );
3932        // …which on this host is translate overscroll; the Android arm is
3933        // pinned beside the selector itself, in `physics`' own tests.
3934        #[cfg(not(target_os = "android"))]
3935        assert_eq!(default_w.effect, OverscrollEffect::Translate);
3936    }
3937
3938    #[test]
3939    fn rebuild_preserves_builder_physics() {
3940        let physics_view: ScrollView<()> =
3941            scroll_view(leaf(200.0, 1000.0)).physics(NeverScrollable::new());
3942        let mut counter = 0u64;
3943        let mut w = View::<()>::build(&physics_view, &mut BuildCtx::new(&mut counter));
3944        let mut lctx = LayoutCtx::new();
3945        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
3946
3947        // Rebuilding against an identical `.physics(...)`-carrying view
3948        // reinstalls it (unconditionally, like the erased callbacks) — still
3949        // refuses the drag.
3950        View::<()>::rebuild(
3951            &physics_view,
3952            &physics_view,
3953            &mut w,
3954            &mut BuildCtx::new(&mut counter),
3955        );
3956        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3957        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
3958        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
3959        assert_eq!(
3960            w.offset(),
3961            0.0,
3962            "still installed after a re-asserting rebuild"
3963        );
3964        assert!(!w.scrolling);
3965        // Clear the armed gesture before the next rebuild's own probe.
3966        dispatch(&mut w, &ev(PointerPhase::Cancel, 40.0), 48.0);
3967
3968        // Rebuilding against a view with no `.physics(...)` call at all leaves
3969        // the widget's currently-installed physics untouched — it does not
3970        // revert to the `RubberBand` default.
3971        let plain_view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0));
3972        View::<()>::rebuild(
3973            &plain_view,
3974            &physics_view,
3975            &mut w,
3976            &mut BuildCtx::new(&mut counter),
3977        );
3978        dispatch(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3979        dispatch(&mut w, &ev(PointerPhase::Move, 70.0), 16.0);
3980        dispatch(&mut w, &ev(PointerPhase::Move, 40.0), 32.0);
3981        assert_eq!(
3982            w.offset(),
3983            0.0,
3984            "a rebuild whose view carries no .physics(...) leaves the widget's physics untouched"
3985        );
3986        assert!(!w.scrolling);
3987    }
3988
3989    /// A focused editable inside the viewport must see a clipboard verb: an
3990    /// `EditCommand` is focus-routed, so it takes the same bypass `Key`/`Ime`
3991    /// take rather than the gesture machinery (and never a hit test).
3992    #[test]
3993    fn an_edit_command_reaches_the_focused_child() {
3994        use crate::text_input;
3995        use frust_core::{EditCommand, RenderRoot};
3996
3997        struct Field {
3998            value: String,
3999        }
4000        fn logic(state: &mut Field) -> ScrollView<Field> {
4001            scroll_view(text_input(
4002                state.value.clone(),
4003                |s: &mut Field, v: String| {
4004                    s.value = v;
4005                },
4006            ))
4007        }
4008
4009        let mut state = Field {
4010            value: "hello".to_string(),
4011        };
4012        let mut root: RenderRoot<Field, ScrollView<Field>> = RenderRoot::new();
4013        root.rebuild(&mut logic, &mut state);
4014        root.layout(Size::new(200.0, 100.0));
4015
4016        // Tap the field through the viewport so it holds the recorded focus path.
4017        root.event(&mut state, &ev(PointerPhase::Down, 10.0));
4018        root.event(&mut state, &ev(PointerPhase::Up, 10.0));
4019        assert!(root.is_focus_active(), "the tap focused the child field");
4020
4021        root.event(&mut state, &InputEvent::EditCommand(EditCommand::SelectAll));
4022        root.event(&mut state, &InputEvent::EditCommand(EditCommand::Copy));
4023
4024        assert_eq!(
4025            root.take_clipboard_write().as_deref(),
4026            Some("hello"),
4027            "the copy was answered by the child, through this router"
4028        );
4029        assert_eq!(state.value, "hello", "a copy edits nothing");
4030    }
4031}
4032
4033/// A scroll view enclosing a multi-contact recognizer, driven through a real
4034/// `RenderRoot`: the second finger reaches only the widget that opted into it,
4035/// a takeover ends that opt-in, and a hit-tested `Scale` keeps its result.
4036#[cfg(test)]
4037mod contact_tests {
4038    use super::*;
4039    use crate::{PanZoomTransform, pan_zoom, pinch_detector};
4040    use frust_core::RenderRoot;
4041    use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
4042    use std::any::Any;
4043
4044    #[derive(Default)]
4045    struct App {
4046        transforms: Vec<PanZoomTransform>,
4047        scales: Vec<ScaleEvent>,
4048    }
4049
4050    type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
4051
4052    /// A 1000-px-tall content leaf. Logs every pointer event it receives (into
4053    /// a shared log, never app state, so its `Cancel` arm stays state-free).
4054    /// With `grabs` it captures every primary `Down` and handles it — opting
4055    /// into the gesture's other contacts too with `opt_in` — and otherwise
4056    /// ignores pointers. With `consumes` it reports `Handled` for a `Scale`
4057    /// and for a broadcast; otherwise it ignores both.
4058    #[derive(Clone)]
4059    struct Tall {
4060        seen: Seen,
4061        grabs: bool,
4062        opt_in: bool,
4063        consumes: bool,
4064    }
4065    struct TallWidget(Tall);
4066    impl View<App> for Tall {
4067        type Element = TallWidget;
4068        fn build(&self, _ctx: &mut BuildCtx<'_>) -> TallWidget {
4069            TallWidget(self.clone())
4070        }
4071        fn rebuild(&self, _p: &Self, _e: &mut TallWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
4072            ChangeFlags::NONE
4073        }
4074    }
4075    impl Widget for TallWidget {
4076        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
4077            bc.constrain(Size::new(400.0, 1000.0))
4078        }
4079        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
4080        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
4081            match event {
4082                InputEvent::Pointer(p) => {
4083                    self.0.seen.borrow_mut().push((ctx.pointer_id(), p.phase));
4084                    if !self.0.grabs {
4085                        return EventResult::Ignored;
4086                    }
4087                    if p.phase == PointerPhase::Down {
4088                        ctx.capture_pointer();
4089                        if self.0.opt_in {
4090                            ctx.capture_contacts();
4091                        }
4092                    }
4093                    EventResult::Handled
4094                }
4095                InputEvent::Scale(_) | InputEvent::Housekeeping if self.0.consumes => {
4096                    EventResult::Handled
4097                }
4098                _ => EventResult::Ignored,
4099            }
4100        }
4101    }
4102
4103    fn tall(grabs: bool, opt_in: bool, consumes: bool) -> (Tall, Seen) {
4104        let seen = Seen::default();
4105        let view = Tall {
4106            seen: seen.clone(),
4107            grabs,
4108            opt_in,
4109            consumes,
4110        };
4111        (view, seen)
4112    }
4113
4114    struct NullScene;
4115    impl PaintScene for NullScene {
4116        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
4117        fn draw_text(&mut self, _o: Point, _t: &str) {}
4118    }
4119
4120    fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
4121        InputEvent::PointerContact {
4122            pointer_id: PointerId::touch(slot),
4123            event: PointerEvent {
4124                phase,
4125                position: Point::new(x, y),
4126                button: PointerButton::Primary,
4127            },
4128        }
4129    }
4130
4131    /// A root over `logic`, laid out in a 400 × 300 window.
4132    fn root_over<V: View<App>>(logic: impl Fn() -> V + 'static) -> (RenderRoot<App, V>, App) {
4133        let mut root: RenderRoot<App, V> = RenderRoot::new();
4134        let mut state = App::default();
4135        root.rebuild(&mut move |_: &mut App| logic(), &mut state);
4136        root.layout(Size::new(400.0, 300.0));
4137        (root, state)
4138    }
4139
4140    fn viewport(root: &RenderRoot<App, ScrollView<App>>) -> &ScrollWidget {
4141        let id = root.root_id().expect("root built");
4142        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
4143            .downcast_ref::<ScrollWidget>()
4144            .expect("root is a ScrollWidget")
4145    }
4146
4147    #[test]
4148    fn a_second_finger_never_rearms_the_viewport_around_a_pan_zoom() {
4149        use PointerPhase::{Down, Move};
4150        let (content, seen) = tall(false, false, false);
4151        let (mut root, mut state) = root_over(move || {
4152            scroll_view(
4153                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4154            )
4155        });
4156        let t0 = PointerId::touch(0);
4157        // Two fingers stacked vertically, then the first one pans sideways —
4158        // no vertical travel at all for the claimant. A viewport that took the
4159        // second finger's `Down` as its own would measure this move against
4160        // *that* finger's position, cross its slop, and steal the gesture.
4161        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4162        assert_eq!(root.pointer_capture_claimant(), Some(t0));
4163        assert!(root.pointer_capture_contacts(), "pan_zoom opted in");
4164        root.event(&mut state, &touch(1, Down, 100.0, 250.0));
4165        root.event(&mut state, &touch(0, Move, 110.0, 100.0));
4166        root.event(&mut state, &touch(0, Move, 120.0, 100.0));
4167
4168        let scroll = viewport(&root);
4169        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4170        assert!(!scroll.scrolling, "and never took the gesture over");
4171        assert_eq!(
4172            scroll.down_start,
4173            Point::new(100.0, 100.0),
4174            "its drag is still anchored on the claimant"
4175        );
4176        assert_eq!(
4177            state.transforms.last().map(|t| t.offset),
4178            Some(kurbo::Vec2::new(20.0, 0.0)),
4179            "pan_zoom kept receiving the claimant's moves — it was never cancelled"
4180        );
4181        assert!(
4182            !seen
4183                .borrow()
4184                .iter()
4185                .any(|(_, phase)| *phase == PointerPhase::Cancel)
4186        );
4187        assert_eq!(root.pointer_capture_claimant(), Some(t0));
4188        assert!(
4189            root.pointer_capture_contacts(),
4190            "still routing the second finger to pan_zoom"
4191        );
4192    }
4193
4194    #[test]
4195    fn a_pinch_inside_a_viewport_reports_its_scale_and_leaves_the_viewport_alone() {
4196        use PointerPhase::{Down, Move, Up};
4197        let (content, _) = tall(true, false, false);
4198        let (mut root, mut state) = root_over(move || {
4199            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4200        });
4201        let mut sink = NullScene;
4202        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4203        root.paint(&mut sink, ms(0));
4204        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4205        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4206        root.paint(&mut sink, ms(16));
4207        // The second finger spreads straight down: vertical travel a viewport
4208        // that saw it would read as a scroll drag.
4209        root.event(&mut state, &touch(1, Move, 120.0, 160.0));
4210        root.paint(&mut sink, ms(32));
4211        root.event(&mut state, &touch(1, Move, 120.0, 250.0));
4212        root.event(&mut state, &touch(1, Up, 120.0, 250.0));
4213
4214        let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
4215        assert_eq!(
4216            phases,
4217            [ScalePhase::Begin, ScalePhase::Update, ScalePhase::End],
4218            "the pinch recognizer saw the whole spread"
4219        );
4220        let scroll = viewport(&root);
4221        assert_eq!(scroll.offset(), 0.0);
4222        assert!(!scroll.scrolling);
4223        assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
4224        root.event(&mut state, &touch(0, Up, 100.0, 100.0));
4225        assert!(!root.is_pointer_captured());
4226    }
4227
4228    #[test]
4229    fn a_vertical_drag_over_a_non_panning_child_still_scrolls_and_ends_its_opt_in() {
4230        use PointerPhase::{Cancel, Down, Move, Up};
4231        let (content, seen) = tall(true, true, false);
4232        let (mut root, mut state) = root_over(move || scroll_view(content.clone()));
4233        let t0 = PointerId::touch(0);
4234        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4235        assert!(root.pointer_capture_contacts(), "the child opted in");
4236
4237        // Past the slop: the viewport takes over, cancels the child and
4238        // releases it — and the root hears about it.
4239        root.event(&mut state, &touch(0, Move, 100.0, 60.0));
4240        assert!(viewport(&root).scrolling);
4241        assert_eq!(*seen.borrow(), [(t0, Down), (t0, Cancel)]);
4242        assert_eq!(
4243            root.pointer_capture_claimant(),
4244            Some(t0),
4245            "the drag is still the first finger's"
4246        );
4247        assert!(
4248            !root.pointer_capture_contacts(),
4249            "but the cancelled child no longer gets other fingers"
4250        );
4251
4252        // A second finger now reaches nothing.
4253        let outcome = root.event(&mut state, &touch(1, Down, 100.0, 200.0));
4254        assert!(!outcome.handled);
4255        assert_eq!(seen.borrow().len(), 2);
4256
4257        // The claimant keeps scrolling, and its release ends the gesture.
4258        root.event(&mut state, &touch(0, Move, 100.0, 30.0));
4259        assert_eq!(viewport(&root).offset(), 30.0);
4260        root.event(&mut state, &touch(0, Up, 100.0, 30.0));
4261        assert!(!root.is_pointer_captured());
4262        assert_eq!(
4263            seen.borrow().len(),
4264            2,
4265            "the child heard nothing after its Cancel"
4266        );
4267    }
4268
4269    /// Build a bare scroll widget over `child` in a 400 × 300 viewport.
4270    fn bare(child: Tall) -> ScrollWidget {
4271        let view: ScrollView<App> = scroll_view(child);
4272        let mut counter = 0u64;
4273        let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
4274        w.layout(
4275            &mut LayoutCtx::new(),
4276            &BoxConstraints::tight(Size::new(400.0, 300.0)),
4277        );
4278        w
4279    }
4280
4281    /// Dispatch into a bare scroll widget: its result and whether a capture
4282    /// release bubbled out of it.
4283    fn run(w: &mut ScrollWidget, event: &InputEvent, t_ms: f64) -> (EventResult, bool) {
4284        let mut state = App::default();
4285        let sa: &mut dyn Any = &mut state;
4286        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(400.0, 300.0));
4287        let result = w.event_at(&mut ctx, event, t_ms);
4288        (result, ctx.is_capture_released())
4289    }
4290
4291    fn mouse(phase: PointerPhase, y: f64) -> InputEvent {
4292        InputEvent::Pointer(PointerEvent {
4293            phase,
4294            position: Point::new(100.0, y),
4295            button: PointerButton::Primary,
4296        })
4297    }
4298
4299    #[test]
4300    fn a_takeover_raises_the_release_only_for_an_opted_in_child() {
4301        for opt_in in [true, false] {
4302            let (content, _) = tall(true, opt_in, false);
4303            let mut w = bare(content);
4304            assert!(!run(&mut w, &mouse(PointerPhase::Down, 100.0), 0.0).1);
4305            let (_, released) = run(&mut w, &mouse(PointerPhase::Move, 60.0), 16.0);
4306            assert!(w.scrolling, "took over");
4307            assert!(!w.child.is_active(), "and released the child");
4308            assert_eq!(released, opt_in, "signalled only when the child opted in");
4309        }
4310    }
4311
4312    #[test]
4313    fn a_scale_keeps_the_childs_result_and_a_broadcast_is_never_consumed() {
4314        let scale = InputEvent::Scale(ScaleEvent {
4315            phase: ScalePhase::Update,
4316            scale_delta: 1.1,
4317            focal: Point::new(100.0, 100.0),
4318            velocity: 0.0,
4319        });
4320        let (consumer, _) = tall(false, false, true);
4321        let mut w = bare(consumer);
4322        assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Handled);
4323        assert_eq!(
4324            run(&mut w, &InputEvent::Housekeeping, 0.0).0,
4325            EventResult::Ignored,
4326            "a broadcast is never consumed, whatever the child returned"
4327        );
4328        let (bystander, _) = tall(false, false, false);
4329        let mut w = bare(bystander);
4330        assert_eq!(run(&mut w, &scale, 0.0).0, EventResult::Ignored);
4331    }
4332
4333    #[test]
4334    fn a_wheel_zoom_handled_inside_the_viewport_is_not_applied_twice() {
4335        let (content, _) = tall(false, false, false);
4336        let (mut root, mut state) = root_over(move || {
4337            pinch_detector(scroll_view(
4338                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4339            ))
4340            .on_scale(|s: &mut App, e| s.scales.push(e))
4341        });
4342        let outcome = root.event(
4343            &mut state,
4344            &InputEvent::Scale(ScaleEvent {
4345                phase: ScalePhase::Update,
4346                scale_delta: 1.5,
4347                focal: Point::new(100.0, 100.0),
4348                velocity: 0.0,
4349            }),
4350        );
4351        assert!(outcome.handled);
4352        assert_eq!(state.transforms.len(), 1, "pan_zoom zoomed once");
4353        assert!(
4354            state.scales.is_empty(),
4355            "and the enclosing recognizer did not zoom again"
4356        );
4357    }
4358
4359    // --- The multi-contact veto: surviving the claimant's own travel -------
4360
4361    #[test]
4362    fn a_pinch_survives_the_claimants_own_travel_past_slop() {
4363        use PointerPhase::{Down, Move};
4364        let (content, _seen) = tall(false, false, false);
4365        let (mut root, mut state) = root_over(move || {
4366            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4367        });
4368        let mut sink = NullScene;
4369        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4370        root.paint(&mut sink, ms(0));
4371        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4372        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4373        root.paint(&mut sink, ms(16));
4374        // The CLAIMANT's own finger spreads the pair vertically, past the
4375        // viewport's `TOUCH_SLOP` — a viewport reading only the `Down`-time
4376        // claim snapshot (always unregistered here; `pinch_detector` is not a
4377        // nested scrollable) would steal this as an ordinary scroll drag
4378        // (the counterexample this card fixes).
4379        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4380        root.paint(&mut sink, ms(32));
4381        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4382
4383        let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
4384        assert_eq!(
4385            phases,
4386            [ScalePhase::Begin, ScalePhase::Update],
4387            "the pinch recognizer saw the whole spread"
4388        );
4389        let scroll = viewport(&root);
4390        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4391        assert!(!scroll.scrolling, "and never took the gesture over");
4392        assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
4393        assert!(
4394            root.pointer_capture_contacts(),
4395            "the viewport never cancelled/released the detector's opt-in"
4396        );
4397    }
4398
4399    #[test]
4400    fn a_pinch_over_a_child_owned_press_survives_the_claimants_own_travel() {
4401        use PointerPhase::{Down, Move};
4402        // `grabs = true`: the content captures the primary `Down` itself, so
4403        // `PanZoomWidget::begin_gesture` takes the child-owned branch, which
4404        // publishes nothing into the nested-scroll claim.
4405        let (content, _seen) = tall(true, false, false);
4406        let (mut root, mut state) = root_over(move || {
4407            scroll_view(
4408                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t)),
4409            )
4410        });
4411        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4412        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4413        // The claimant's finger travels well past TOUCH_SLOP vertically while
4414        // a second contact is tracked — exactly the counterexample this card
4415        // fixes for `PanZoomView`'s child-owned-press branch.
4416        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4417        root.event(&mut state, &touch(1, Move, 180.0, 40.0));
4418
4419        let scroll = viewport(&root);
4420        assert_eq!(scroll.offset(), 0.0, "the viewport never scrolled");
4421        assert!(!scroll.scrolling, "and never took the gesture over");
4422        assert!(
4423            !state.transforms.is_empty(),
4424            "pan_zoom's own pinch zoomed the view instead"
4425        );
4426    }
4427
4428    #[test]
4429    fn a_single_finger_drag_still_scrolls_through_a_pinch_detector_with_no_second_finger() {
4430        use PointerPhase::{Down, Move};
4431        let (content, _seen) = tall(false, false, false);
4432        let (mut root, mut state) = root_over(move || {
4433            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4434        });
4435        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4436        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
4437        assert!(
4438            viewport(&root).scrolling,
4439            "no second finger ever arrived to veto the takeover"
4440        );
4441        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4442        assert_eq!(viewport(&root).offset(), 30.0);
4443        assert!(state.scales.is_empty(), "never a pinch with one finger");
4444    }
4445
4446    #[test]
4447    fn releasing_the_second_finger_clears_the_veto_and_scrolling_resumes() {
4448        use PointerPhase::{Down, Move, Up};
4449        let (content, _seen) = tall(true, false, false);
4450        let (mut root, mut state) = root_over(move || {
4451            scroll_view(pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e)))
4452        });
4453        let mut sink = NullScene;
4454        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
4455        root.paint(&mut sink, ms(0));
4456        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
4457        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
4458        root.paint(&mut sink, ms(16));
4459        root.event(&mut state, &touch(0, Move, 100.0, 40.0)); // past slop while paired: no takeover
4460        assert!(
4461            !viewport(&root).scrolling,
4462            "the pinch still owns the gesture"
4463        );
4464
4465        root.event(&mut state, &touch(1, Up, 120.0, 40.0)); // the second finger lifts: pinch ends
4466
4467        // The claimant's very next `Move` is measured against its original
4468        // `down_start` as usual (the veto does not replay the suppressed slop
4469        // check) — already well past `TOUCH_SLOP`, so the viewport takes the
4470        // drag over immediately once the veto clears, per the existing
4471        // single-finger scroll contract.
4472        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
4473        assert!(
4474            viewport(&root).scrolling,
4475            "the viewport resumed scrolling once the pinch ended"
4476        );
4477    }
4478}