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