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