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