frust_widgets/list_view.rs
1//! The virtualized `ListView`:
2//! `ListView::builder(item_count, item_extent, |index| -> impl View<State>)` with a
3//! uniform, required `item_extent` — or, on a keyed list,
4//! `ListView::builder_keyed(..).estimated_item_extent(px)` for rows that size
5//! themselves (see *Variable extents*, below). Its place in the widget set and
6//! why virtualization exists at all belong to `docs/WIDGETS_ARCHITECTURE.md`
7//! (*Virtualized ListView*); this header holds the contracts behind that summary.
8//!
9//! # Windowed materialization at rebuild time
10//!
11//! Children materialize only at [`View::rebuild`] time — never at layout, the
12//! spoke doc's windowing invariant — and `rebuild` receives the retained
13//! `&mut Self::Element`, so it reads the widget's *own* scroll offset and cached
14//! viewport, recomputes the window
15//! `[floor(offset/extent) - BUFFER, ceil((offset+viewport.h)/extent) + BUFFER]`
16//! (clamped to `[0, item_count]`) and reconciles the live children to it,
17//! **keyed by item index**: an index staying in the window keeps its live
18//! [`ChildPod`] — and all of that child's retained state — *relocated* into the
19//! new order and rebuilt in place against its own previous view; indices leaving
20//! are torn down, indices entering are built fresh. The builder being a pure
21//! function of the index, a survivor's previous view is `prev.builder(i)` and its
22//! current one `self.builder(i)`, so no per-window view cache is retained.
23//!
24//! # Row identity: positional by default, keyed on request
25//!
26//! [`ListView::builder`] reconciles rows by raw item index,
27//! [`ListView::builder_keyed`] by a caller-supplied `key_of(index) -> ChildKey`
28//! (the same [`ChildKey`] [`keyed`](crate::keyed) uses for `Flex`); which data
29//! mutations each model is correct for, and how a positional list silently
30//! reattaches state on a mid-list insert/remove/reorder, is the spoke doc's *Two
31//! row-identity models*. What a retained [`ChildPod`] carries through either is
32//! that row's *entire* state — hosted `Component` state, `StateLayer` flags, a
33//! `ListItem`'s toggle/press state — which is what makes that misattachment
34//! silent rather than a panic. Keying is the fix; two hard parts:
35//!
36//! * **Slots vs. identity.** Window slots stay the contiguous ascending index run
37//! the uniform-extent fast path (`window_covers`, the `item_count *
38//! item_extent` scroll extent) relies on; identity lives *beside* them, in a
39//! retained `key -> the index that row occupied last frame` map rebuild reads
40//! off the element like `offset` and `viewport` (the view itself remembers
41//! nothing, being rebuilt from scratch every frame). Naming a survivor's last
42//! index is what redirects the reconstruction above through the map, as
43//! `prev.builder(prev_index)` against `self.builder(index)`; keys leaving the
44//! window or the data are torn down, keys entering built fresh.
45//! * **Duplicate keys.** Ambiguous, so a duplicate trips a `debug_assert!`,
46//! mirroring [`crate::authoring::rebuild_children`]'s keyed reconciler — but
47//! with **no positional fallback** in release: first slot wins, the map keeps
48//! the last index, and which row keeps its state is arbitrary.
49//!
50//! Keying costs one `key_of` per *materialized* slot per frame plus that map,
51//! never anything `item_count`-sized, and leaves the positional path untouched.
52//! Either path reports `ChangeFlags::LAYOUT` on any frame that built, tore down,
53//! relocated, or re-ranged pods — a freshly built or relocated pod has never been
54//! laid out where it now sits, and a frame skipping layout would paint it unsized
55//! or at a stale origin.
56//!
57//! # Prepend/removal scroll anchoring (keyed lists only)
58//!
59//! A keyed list also corrects the scroll offset so a mutation **above** the
60//! viewport — a "load older" prepend, a removal above the screen — doesn't
61//! visually jump the content the user is looking at. It runs inside
62//! [`View::rebuild`], **before** this frame's window is recomputed, so layout and
63//! paint agree on one corrected offset; never during paint.
64//!
65//! **Anchor selection.** The anchor is the topmost surviving keyed row of the
66//! previous window: walking [`ListViewWidget::keys`] ascending, the first key
67//! that still identifies a row in the new data wins. That is checked two ways, at
68//! most two extra `key_of` calls per candidate — unchanged position
69//! (`self.key_of(i_prev) == anchor_key`) or shifted by this frame's net
70//! `item_count` delta (`self.key_of(i_prev + delta) == anchor_key`), the latter
71//! correct precisely because nothing between the mutation and the anchor changed,
72//! so every surviving row at or above the old window shifts by the same net
73//! count. If neither matches for any key in the previous window, no correction
74//! runs at all: a full replace is reset semantics, not an anchoring case. The
75//! search is bounded by the window size, never `item_count`.
76//!
77//! **Correction.** A confirmed shift of `d` items applies
78//! `offset += d as f64 * item_extent`, clamped to `[0, max_offset]` and reported
79//! as `ChangeFlags::LAYOUT` — closed form, since `d` is the item-count delta
80//! itself merely confirmed against the anchor key, so nothing searches for
81//! *where* the anchor landed.
82//!
83//! **Anchor always, even from `offset == 0`**: pinning `offset` at zero on a
84//! prepend would snap that same old top row back under the user, so this module
85//! shifts instead, revealing the newly-prepended rows. A prepend answering a
86//! fired [`ListView::on_near_start`] also leaves that edge's armed flag as the
87//! fire left it (disarmed) rather than letting the unconditional item-count
88//! rearm force it back armed, which would re-trigger "load older" on the very
89//! next scroll event.
90//!
91//! **Positional lists are never anchored** — position-only identity cannot tell a
92//! prepend from a full mutation, so the correction would be a guess.
93//!
94//! # Variable extents (keyed lists only)
95//!
96//! [`ListView::estimated_item_extent`] switches a **keyed** list into
97//! variable-extent mode: a row not yet laid out is assumed `estimate` tall, one
98//! already laid out contributes its own measured height. Without that call the
99//! list stays on the closed-form uniform path, kept literal rather than folded
100//! into this math, as the regression guard.
101//!
102//! **Keyed-only, enforced by a `debug_assert`** that is **inert** in release (the
103//! list stays uniform), the duplicate-key tripwire's shape: a measured extent is
104//! cached under the row's *identity*, so under positional identity it would
105//! reattach to whatever content later occupies the index. (Type-state would make
106//! the misuse a compile error, at the cost of two generic `ListView` families.)
107//!
108//! **The extent model.** [`ListViewWidget`] retains `key -> (last index, measured
109//! height)` for every row it has ever laid out, plus their running sum, so
110//!
111//! ```text
112//! total = Σ measured + estimate × (item_count − measured count)
113//! ```
114//!
115//! is O(1) to read — and `max_offset`, the offset clamp, the unbounded-height
116//! layout size and the semantics scroll range all read it, converging on the true
117//! content height as rows are visited.
118//!
119//! **Offset → index in O(window + step), never O(N).** A full prefix sum from
120//! item 0 would be O(N) per frame, so the widget retains one *prefix anchor*: the
121//! index the materialized window starts at, plus that item's content-space `y`.
122//! Each frame's window walks from there — a handful of items for a scroll or a
123//! fling step — accumulating measured-or-estimated extents until it reaches the
124//! offset, then out to cover the viewport ± [`BUFFER`]; each row is then placed
125//! at its own walked content `y` (`slot_y`) minus the offset. Reaching item 0
126//! re-pins `y = 0` exactly, erasing accumulated drift, and a jump farther than
127//! [`MAX_PREFIX_STEP`] items counts as a data reset rather than a scroll: its
128//! bulk resolves in closed form against the estimate, only the remainder walks.
129//!
130//! The builder still never runs outside rebuild, so a measurement that leaves the
131//! window no longer covering the viewport costs one convergence frame (*Viewport
132//! staleness*, below), never a layout-time build.
133//!
134//! **Cache hygiene.** The measured cache is bounded by the keys a session has
135//! actually visited (one `f64` + index per visited row; an LRU cap is a named
136//! deferral). Three rules trim it, all window-bounded or shrink-only:
137//!
138//! * a pod no slot claimed is probed with anchoring's same two hypotheses; if
139//! neither still names its key the row left the *data*, not just the window,
140//! and its measurement is dropped — two `key_of` calls per departing row. This
141//! runs on every reconciled frame that isn't a genuine full replace (third
142//! rule), *including* one where the anchor-shift probe missed: that miss proves
143//! only that no uniform shift explained the whole previous window at once, never
144//! that *this* row's own two hypotheses fail, and skipping it there leaks
145//! permanently, a removed row having no later shrink or revisit to reclaim it,
146//! * a frame whose `item_count` shrank drops every entry whose recorded index is
147//! past the new end — the only entries the new keying provably cannot produce,
148//! scanned only on a shrink frame,
149//! * a wholesale replace clears the cache outright, but is decided against the
150//! reconciled window's own *exact* key matches (`ListView::reconcile_keyed`'s
151//! per-slot lookup), never the anchor-shift probe: that probe tests only two
152//! candidate index shifts and can miss a same-frame mutation touching both
153//! sides of the anchor while the on-screen rows are unchanged. Only a frame
154//! where none of the previous window's keys matched a slot is a genuine
155//! replace.
156//!
157//! Two acknowledged gaps: a row removed while *outside* the materialized window
158//! keeps its entry (finding it would mean re-keying all `item_count` items, the
159//! O(N) this design exists to avoid); and a same-frame mutation on both sides of
160//! the anchor can still miss the anchor-shift probe itself, leaving the *scroll
161//! position* uncorrected for that one frame (a visible jump), any pending
162//! measured-anchor correction being discarded rather than committed into geometry
163//! it can no longer explain. Both cost accuracy in an already-estimated total,
164//! never a misattached measurement or a leaked entry.
165//!
166//! # Measured anchor correction (variable extents only)
167//!
168//! A row measuring taller or shorter than it was *assumed* to be moves every row
169//! below it, including the one the viewport's top edge sits inside — the
170//! **anchor**. So layout accumulates, over the rows lying wholly above that edge
171//! *in the geometry this frame's window was planned against*, the signed
172//! `measured − assumed` delta into one [`ListViewWidget::pending_correction`]:
173//! what the offset owes to leave the anchor row exactly where it is. Rows at or
174//! below the anchor contribute nothing — a row growing pushes content below it
175//! down, the truth, not a jump.
176//!
177//! **Recorded at layout, committed at the next rebuild.** A *scroll* correction
178//! is neither layout's nor paint's to make — layout's only offset write stays the
179//! range clamp, paint's the fling pump — so paint asks for one continuation frame
180//! while a correction is pending and the next [`View::rebuild`] commits it
181//! *before* planning the window, folding `ChangeFlags::LAYOUT` into its report.
182//! The pending amount is nevertheless **honored visually the instant it is
183//! measured**: every reader that *places* content — the prefix walk, the window,
184//! each row's origin, the edge triggers, the semantics scroll position — reads
185//! [`ListViewWidget::placement_offset`] (`offset + pending`, clamped) instead of
186//! the raw offset, so the anchor row never moves and committing is pure
187//! bookkeeping. The raw `offset` — the fling pump's, the drag's, the wheel's and
188//! the clamp arithmetic's — moves only from input or a rebuild-time correction.
189//!
190//! **Fling interplay: accumulate, then apply at settle.** While a fling is live
191//! the pump advances `offset` at paint, and committing per-frame would fight it
192//! two ways: a correction *opposing* the fling exceeds a decayed fling step near
193//! the end of the animation and walks the offset backwards, and one *along* it
194//! can push the offset onto a bound, where [`ListViewWidget::tick`]'s at-bound
195//! check kills the fling early. So a correction taken during a fling only
196//! accumulates; the first rebuild after the fling stops (velocity below
197//! [`FLING_STOP`], a bound reached, or a `Down` taking the gesture over — all
198//! three clear `fling`) commits the whole sum, invisibly, since placement honored
199//! it every frame anyway. The reversal is measured, not assumed: deleting the
200//! withhold makes this module's upward-fling test walk the offset backwards
201//! mid-flight (`7980 → 7993`, ~13px against the gesture). **Accepted artifact:**
202//! until a long fling over never-measured rows settles, the *committed* offset
203//! lags the painted placement by exactly the accumulated sum, so a reader of
204//! [`ListViewWidget::offset`] alone sees a stale scroll position.
205//!
206//! **Clamping and edge triggers.** A correction goes through the same
207//! `[0, max_offset]` clamp as every other offset write, against a `max_offset`
208//! itself moving as measurements revise the content extent; one clamped at an
209//! edge is truncated, not kept owing, so the top/bottom of the list wins over
210//! anchor fidelity. `near_start`/`near_end` evaluate on events and on the fling
211//! pump, never in rebuild, so a commit can never itself fire one; and because
212//! they read the *placement*, which a commit leaves unchanged, a correction
213//! cannot rearm or re-fire an edge that has not genuinely moved.
214//!
215//! **What stays estimated.** Only materialized rows are ever measured, so a
216//! prepend landing entirely *above* the window is anchored by the estimate alone
217//! — the closed-form shift above — refining only if the user scrolls back over
218//! those rows; a prepend landing inside the window takes both steps in
219//! consecutive frames, the estimate shift then the measured refinement.
220//!
221//! # Viewport staleness
222//!
223//! [`frust_core::BuildCtx`] carries no viewport size, so the widget caches
224//! `viewport: Size` from the previous layout pass (the `ScrollWidget` precedent)
225//! and rebuild reads it off the element. One frame of staleness on a constraint
226//! change is accepted, and the very first `build` materializes a conservative
227//! window from a zero viewport; to converge, [`Widget::paint`] requests one more
228//! frame whenever the materialized window does not yet *cover* the now-known
229//! viewport, so a stationary list never idles under-materialized. Coverage, not
230//! equality: an over-wide window asks for no extra frame, the next rebuild trims.
231//!
232//! # Scroll machinery (reused, not reinvented)
233//!
234//! The widget owns its own vertical drag capture / wheel / fling, reusing
235//! `frust-core::input`'s constants + fling math and the spring-during-paint pump
236//! exactly like [`crate::ScrollView`] (see `scroll.rs`). During a scroll drag it
237//! captures the pointer and, on takeover, cancels any armed child (a `ListItem`'s
238//! press) via the structural-change contract — the accepted, Flutter-like
239//! tradeoff. An offset change requests a redraw; the fling advances the offset at
240//! paint and requests a continuation frame, so the next rebuild→layout→paint
241//! re-windows as the fling carries the list.
242//!
243//! [`ListViewWidget::offset`] — the *windowing* offset deciding which item
244//! indices materialize — is clamped to `[0, scroll extent - viewport.height]`
245//! (*The extent model*, above, for that extent on each path) and never leaves
246//! that range; see *Overscroll and pull-to-refresh* for the bounded out-of-range
247//! *visual* displacement layered on top of it.
248//!
249//! # Overscroll and pull-to-refresh
250//!
251//! [`ListView::on_refresh_release`] and a drag's rubber-band overscroll feel are
252//! [`crate::ScrollView`]'s, sharing `scroll.rs`'s `pub(crate)`
253//! resistance/trigger/settle items rather than a hand-copied second set so the
254//! two can never drift — the spoke doc's *Refresh/overscroll parity with
255//! ScrollView*. Only the settle animation is reimplemented, against a data shape
256//! `ScrollWidget` has no windowing concept to keep separate from.
257//!
258//! **The feel itself comes from a [`ScrollPhysics`]** ([`crate::physics`]),
259//! installed as [`crate::physics::default_physics`]'s platform-adaptive choice
260//! — the same seam, the same default, the same per-move drag convention, and
261//! the same parity rule as `ScrollView` (see its *Physics seam*): the drag
262//! mapping and boundary rejection are asked of the physics, while the legacy
263//! fling/settle path and the hard-clamped wheel path stay here. What is local
264//! to this widget is only how the answer is *stored* — split across a clamped
265//! windowing offset and a paint-only displacement, below.
266//!
267//! **Windowing offset vs. painted offset.** [`ListViewWidget::offset`] *is* the
268//! item-index math — where a `ScrollWidget::offset` can carry an out-of-range
269//! value directly, nothing there reading it as an index — so it must stay in
270//! `[0, max_offset]` at all times, and a drag past an edge splits the two:
271//! `offset` stays clamped (window planning, the prefix walk and the edge triggers
272//! all read [`ListViewWidget::placement_offset`], built on it),
273//! [`ListViewWidget::overscroll`] carries the signed, resisted past-edge
274//! displacement alone, and only [`ListViewWidget::painted_offset`]
275//! (`placement_offset() + overscroll`) — read solely by
276//! [`ListViewWidget::sync_child_origins`] and [`Widget::semantics`]'s scroll
277//! position — ever sees the out-of-range number. The content edge visually
278//! displaces; no row is ever materialized outside `[0, item_count)`.
279//!
280//! **How the pull is visualized is a separate axis**, shared verbatim with
281//! `ScrollView` (see its *Overscroll visuals*): under the default
282//! [`OverscrollEffect::Translate`] the displacement is what
283//! [`ListViewWidget::painted_offset`] layers in, above; under
284//! [`OverscrollEffect::Stretch`] the rows stay where the windowing offset puts
285//! them and [`Widget::paint`] scales them about the held edge from
286//! [`ListViewWidget::edge_pull`] instead — paint-only either way.
287//!
288//! **Resistance uses the *current*, converging `max_offset`.** Every drag `Move`
289//! re-splits the drag position against a freshly-read
290//! [`ListViewWidget::max_offset`], not a value cached at takeover, so a
291//! bottom-edge overscroll in variable-extent mode tracks the content extent as
292//! in-flight measurements revise it — content that grows under the finger
293//! absorbs the displacement it had already produced instead of the surface
294//! jumping by it.
295//!
296//! **`on_refresh_release` only ever arms at the top edge**, mirroring
297//! `ScrollView`: it fires on `Up` when `overscroll < -REFRESH_TRIGGER_PX`, a
298//! condition only the *negative* (past-top) direction can satisfy — a bottom
299//! overscroll releases into a settle like an under-threshold top one, but can
300//! never fire it. [`ListView::on_near_start`]/[`ListView::on_near_end`] read
301//! `placement_offset()`, which overscroll never touches, so one gesture can cross
302//! the near-start threshold on its way down (firing "load older") and *then* the
303//! refresh trigger before release — two independent signals, not a conflict.
304//!
305//! **Fling and wheel stay hard-clamped**, exactly like `ScrollView`: on the
306//! shipped feel only a drag ever *sets* a nonzero `overscroll` — wheel forces it
307//! back to `0.0` outright, and a fling can never enter it, since
308//! [`ListViewWidget::tick`]'s at-bound
309//! check stops a fling the instant `offset` reaches `0`/`max_offset`. (The
310//! generic ballistic driver is the one other writer, and only for a physics
311//! whose simulation is *allowed* past an edge — the bouncing default's spring
312//! is exactly that, `RubberBand` builds none.) A new
313//! `Down` does *not* reset it, only cancelling any in-progress settle
314//! (`ListViewWidget::settling = false`), so a regrab mid-bounce continues from
315//! wherever the surface sits rather than snapping first — mirroring
316//! `ScrollWidget::event_at`'s `Down` arm, which leaves its own `offset` untouched
317//! for the same reason.
318//!
319//! # Nested scrolling: innermost wins
320//!
321//! This widget is both halves of `scroll.rs`'s innermost-wins arbitration (see
322//! its *Nested scrolling*), on the same shared seam rather than a second copy:
323//! it reports itself into its host's ambient claim cell
324//! ([`crate::scroll::ambient_scroll_claim`]) as a forwarded `Down` reaches it,
325//! and it pushes its own cell ([`crate::scroll::with_scroll_claim`]) around the
326//! `Down` it routes to its own rows — in that order, so a row's own nested
327//! scrollable pairs with *this* list and not with whatever encloses it. At the
328//! takeover site a registered inner that can consume the drag's direction makes
329//! this list defer instead of cancelling its rows. A list with no nested
330//! scrollable in the window behaves exactly as it always has.
331//!
332//! # Programmatic scrolling
333//!
334//! [`ListView::controller`] attaches the same [`ScrollController`] handle a
335//! [`crate::ScrollView`] takes, with the same contract (`scroll.rs`'s
336//! *Programmatic scrolling*, and [`crate::scroll_controller`] for the handle's
337//! side): `jump_to`/`animate_to` apply on the next pass, every pass that moves
338//! the position publishes a [`ScrollInfo`], a jump/animate replaces whatever
339//! fling, settle or tween is in flight, and a user `Down`, wheel or `Cancel`
340//! ends a tween through the one tear-down path
341//! ([`ListViewWidget::stop_ballistic`]). A command applied while a finger is
342//! down ends that drag ([`ListViewWidget::end_live_drag`], mirroring
343//! `ScrollWidget::end_live_drag`), and every post-release start tears the
344//! previous motion down through the same path first, so fling, settle and
345//! simulation are never live together. Three things are local to this widget:
346//!
347//! * **Drained at rebuild too.** Besides the start of layout and paint, the
348//! queue is drained in [`View::rebuild`] once the anchor shift and measured
349//! correction have been committed and *before* the window is planned, so the
350//! frame a jump lands on already materializes the rows it lands among
351//! instead of painting one frame of the old window. Rebuild has no theme, so
352//! it uses the motion tokens the last layout/paint resolved.
353//! * **Content-space offsets.** The published offset — and the offset a jump
354//! writes — is [`ListViewWidget::placement_offset`], where the content
355//! actually sits; a jump or animate folds any uncommitted measured
356//! correction in first, so the position an app reads back is the position
357//! it asked for.
358//! * **`scroll_to_item`.** A keyed list resolves
359//! [`ScrollController::scroll_to_item`]'s key by scanning `key_of` over the
360//! items (O(item count), checked against the window's own key map first —
361//! there is no full reverse map, by design), places the row from the
362//! measured-or-estimated extents ahead of it (walking from the prefix
363//! anchor, the same walk the window plans with, so the two agree exactly),
364//! aligns it, and jumps or tweens there. **Settle and correct:** the
365//! estimate is only a guess for rows never laid out, so after the jump lands
366//! (or the tween finishes) each following layout — which has just measured
367//! the rows now on screen — re-resolves the row's aligned position and
368//! jumps to it when it is off by more than [`ITEM_SETTLE_TOLERANCE_PX`]. The
369//! loop ends as soon as the row is materialized and within tolerance, and
370//! is **bounded to [`ITEM_SETTLE_MAX_FRAMES`] layouts** whatever happens
371//! (each correction costs one frame to re-window and re-measure; the
372//! measured anchor correction already holds a row's top in place as rows
373//! above it measure, so `Start` usually settles on the first layout and the
374//! others within one more). Paint requests a layout frame while the loop is
375//! live, and a user `Down`, wheel or `Cancel`, or any later controller
376//! command, ends it. A settle step never runs while a drag is live: the
377//! request waits it out, consuming none of its bounded frames, and resumes
378//! once the drag ends.
379
380use std::cell::Cell;
381use std::collections::HashMap;
382use std::rc::Rc;
383
384use frust_core::accesskit::Role;
385use frust_core::{
386 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FLING_STOP,
387 FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
388 PointerPhase, ScrollDelta, SemanticsCtx, TOUCH_SLOP, VelocityTracker, View, WHEEL_LINE_PX,
389 Widget, fling_decay, fling_displacement,
390};
391use frust_theme::{MotionScheme, Theme};
392use kurbo::{Point, Size};
393
394use crate::ChildKey;
395use crate::authoring::{ErasedCallback, presses};
396use crate::physics::effect::OverscrollEffect;
397use crate::physics::simulation::TweenSimulation;
398use crate::physics::{
399 MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR, ScrollMetrics, ScrollPhysics, Simulation,
400 default_overscroll_effect, default_physics,
401};
402use crate::scroll::{
403 BallisticState, InnerScrollState, METRICS_FALLBACK_DPR, SETTLE_DECAY, SETTLE_STOP_PX,
404 ScrollInfo, ambient_scroll_claim, crossed_refresh_trigger, inner_claim_state,
405 stretch_about_edge, with_scroll_claim, with_scroll_veto,
406};
407use crate::scroll_controller::{
408 AnimateTo, ControllerCommand, ItemAlignment, ScrollBinding, ScrollCommand, ScrollController,
409 ScrollToItem,
410};
411
412/// Extra items materialized above and below the visible window, so a small
413/// scroll (or a fling's per-frame advance) reveals already-built rows instead of
414/// a blank edge before the next rebuild re-windows.
415const BUFFER: isize = 2;
416
417/// The debug-only tripwire message for two materialized slots claiming one
418/// identity, shared by the keyed build and rebuild paths. Mirrors
419/// [`crate::authoring::rebuild_children`]'s duplicate-key `debug_assert!`; unlike
420/// that one there is no positional fallback to take, so release builds carry on
421/// with first-claim-wins (see [`ListView::builder_keyed`]).
422const DUPLICATE_KEY_MSG: &str = "ListView::builder_keyed produced a duplicate key inside one \
423 window: row identity is ambiguous (release: the first slot claiming a key keeps the live \
424 row, later duplicates build fresh)";
425
426/// The debug-only tripwire message for [`ListView::estimated_item_extent`] on a
427/// positional list. Variable extents cache a measurement under the row's stable
428/// identity, which a positional list does not have; release builds ignore the
429/// estimate and stay on the uniform path rather than panicking live (see the
430/// [module docs](self)' *Variable extents* section).
431const ESTIMATE_NEEDS_KEYS_MSG: &str = "ListView::estimated_item_extent requires \
432 ListView::builder_keyed: a measured extent is cached under the row's stable key, which a \
433 positional list has none of (release: the estimate is ignored and the uniform extent path \
434 runs)";
435
436/// The longest prefix walk a single frame will step item-by-item before
437/// resolving the remaining distance in closed form against the estimate. A
438/// scroll, a fling step, or a window shift moves the offset by far less than
439/// this; only a data reset or a programmatic jump reaches it, and the walk's
440/// result re-pins the anchor, so the very next frame is short again.
441const MAX_PREFIX_STEP: usize = 512;
442
443/// How many layouts a [`ScrollController::scroll_to_item`] request may spend
444/// settling — re-resolving the row's aligned position against freshly
445/// measured extents and correcting to it — before it stops correcting and
446/// leaves the list where it stands. Counted from the layout the jump lands on
447/// (or the first one after the tween finishes); each correction costs one
448/// frame to re-window and re-measure, and three corrections cover a target
449/// whose own extent and the extents above it were all estimates. See the
450/// [module docs](self)' *Programmatic scrolling* section.
451const ITEM_SETTLE_MAX_FRAMES: u8 = 4;
452
453/// How far (logical px) a settling [`ScrollController::scroll_to_item`] row
454/// may sit from its aligned position and still count as landed — half a
455/// pixel, below anything a correction jump could visibly improve.
456const ITEM_SETTLE_TOLERANCE_PX: f64 = 0.5;
457
458/// A view-held near-start "load older" callback (erased to [`ErasedCallback`] on
459/// build).
460type OnNearStart<State> = Rc<dyn Fn(&mut State)>;
461
462/// A view-held near-end "load newer" callback (erased to [`ErasedCallback`] on
463/// build). Mirrors [`OnNearStart`].
464type OnNearEnd<State> = Rc<dyn Fn(&mut State)>;
465
466/// A view-held pull-to-refresh release callback (erased to [`ErasedCallback`]
467/// on build). Mirrors [`crate::ScrollView`]'s type of the same shape exactly —
468/// see the [module docs](self)' *Overscroll and pull-to-refresh* section.
469type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
470
471/// A view-held stable-key function (`item index -> row identity`), installed by
472/// [`ListView::builder_keyed`] and absent for the positional
473/// [`ListView::builder`]. Retained (an [`Rc`]) beside the builder, and — like the
474/// builder — a pure function of the index.
475type KeyOf = Rc<dyn Fn(usize) -> ChildKey>;
476
477/// One row's cached measurement in variable-extent mode, held under the row's
478/// stable key. See the [module docs](self)' *Variable extents* section.
479#[derive(Clone, Copy, Debug)]
480struct Measured {
481 /// The item index this row occupied the last time it was measured — the
482 /// only handle the cache has on "the current keying can no longer produce
483 /// this entry" when `item_count` shrinks.
484 index: usize,
485 /// The row's laid-out height (logical px).
486 extent: f64,
487}
488
489/// The motion tokens a controller command is applied with, resolved from the
490/// theme at every layout and paint and cached on the widget so the
491/// rebuild-time drain (whose `BuildCtx` threads no theme) applies a command
492/// with the same tokens the surrounding passes would.
493#[derive(Clone, Copy, Debug)]
494struct ControllerMotion {
495 /// The theme's `motion.reduce_motion`: collapses every tween to a jump.
496 reduce_motion: bool,
497 /// The tween an `animated` [`ScrollController::scroll_to_item`] runs:
498 /// `motion.durations.slow` eased by `motion.easing.spatial` — a spatial
499 /// move across possibly many rows, so the slow spatial pair rather than a
500 /// hand-rolled duration.
501 item_animation: AnimateTo,
502}
503
504impl ControllerMotion {
505 /// The tokens `theme` carries, or [`MotionScheme::neutral`]'s when no
506 /// theme is threaded (the unthemed fallback, rather than a local constant).
507 fn from_theme(theme: Option<&Theme>) -> Self {
508 let motion = theme.map_or(MotionScheme::neutral(), |theme| theme.motion);
509 Self {
510 reduce_motion: motion.reduce_motion,
511 item_animation: AnimateTo {
512 duration_ms: motion.durations.slow,
513 curve: motion.easing.spatial,
514 },
515 }
516 }
517}
518
519/// A [`ScrollController::scroll_to_item`] request still settling: the row's
520/// key (re-resolved every settle frame, so a mutation mid-settle follows the
521/// row), the concrete alignment it resolved to (`Nearest` already decided),
522/// and the layouts it has left. See the [module docs](self)' *Programmatic
523/// scrolling* section.
524#[derive(Clone, Copy, Debug)]
525struct ItemTarget {
526 key: ChildKey,
527 alignment: ItemAlignment,
528 frames_left: u8,
529}
530
531/// The window one frame should materialize: the `[start, end)` slot range plus
532/// the content-space `y` of `start` (the prefix walk's result in variable-extent
533/// mode; `start * item_extent` on the uniform path).
534#[derive(Clone, Copy, Debug)]
535struct WindowPlan {
536 start: usize,
537 end: usize,
538 y_start: f64,
539}
540
541impl WindowPlan {
542 /// The empty window (an empty list, or a degenerate extent).
543 const EMPTY: Self = Self {
544 start: 0,
545 end: 0,
546 y_start: 0.0,
547 };
548}
549
550/// A declarative, virtualized vertical list. See the [module docs](self).
551///
552/// `builder` is a pure function of the item index; it is retained (an [`Rc`]) so
553/// the previous frame's view for a surviving index can be reconstructed during
554/// reconciliation. All rows share the uniform, required `item_extent`.
555pub struct ListView<State: 'static> {
556 item_count: usize,
557 item_extent: f64,
558 builder: Rc<dyn Fn(usize) -> AnyView<State>>,
559 /// The stable-key function when this is a [`ListView::builder_keyed`] list;
560 /// `None` selects the positional (index-identity) reconciliation path. See
561 /// the [module docs](self)' *Row identity* section.
562 key_of: Option<KeyOf>,
563 /// The assumed extent of an unmeasured row, installed by
564 /// [`ListView::estimated_item_extent`]; `None` (the default) keeps the
565 /// closed-form uniform path. Only honored on a keyed list — see the
566 /// [module docs](self)' *Variable extents* section.
567 estimated_item_extent: Option<f64>,
568 /// Fired (edge-triggered) when the scrolled window comes within
569 /// `near_start_threshold` of content start — the "load older" edge. See
570 /// [`ListView::on_near_start`].
571 on_near_start: Option<OnNearStart<State>>,
572 /// Distance from content start (px) at which `on_near_start` fires.
573 near_start_threshold: f64,
574 /// Fired (edge-triggered) when the scrolled window comes within
575 /// `near_end_threshold` of content end — the "load newer" edge. See
576 /// [`ListView::on_near_end`].
577 on_near_end: Option<OnNearEnd<State>>,
578 /// Distance from content end (px) at which `on_near_end` fires.
579 near_end_threshold: f64,
580 /// Fired on pointer `Up` when the past-top overscroll exceeded
581 /// `REFRESH_TRIGGER_PX` (shared with [`crate::ScrollView`]). See
582 /// [`ListView::on_refresh_release`].
583 on_refresh_release: Option<OnRefresh<State>>,
584 /// A custom [`ScrollPhysics`] installed via [`ListView::physics`], or
585 /// `None` to leave whatever is already installed on the widget alone —
586 /// mirrors [`crate::ScrollView`]'s field of the same name/contract; see
587 /// [`ListView::physics`] for the full build/rebuild semantics.
588 physics: Option<Rc<dyn ScrollPhysics>>,
589 /// How past-edge pull is visualized, carried down to
590 /// [`ListViewWidget::effect`] on every build/rebuild. See
591 /// [`ListView::overscroll_effect`] (mirrors [`crate::ScrollView`]'s field
592 /// of the same name).
593 pub(crate) effect: OverscrollEffect,
594 /// The programmatic-scroll handle to attach, if any. See
595 /// [`ListView::controller`].
596 controller: Option<ScrollController>,
597}
598
599impl<State: 'static> ListView<State> {
600 /// Create a virtualized list of `item_count` rows, each `item_extent`
601 /// logical pixels tall, whose row at `index` is produced by `builder`.
602 ///
603 /// The builder may return any [`View`]; it is erased here. Rows that differ
604 /// in concrete view type return [`frust_core::any`] per branch. Panics if
605 /// `item_extent` is not positive (the uniform extent is the virtualization
606 /// fast path; a zero/negative extent has no well-defined window).
607 ///
608 /// # Contract
609 ///
610 /// Rows are reconciled by **raw item index**, not by a stable key. This is
611 /// safe for append-only, truncate-only, and full-replace data, but a
612 /// mid-list insert/remove/reorder silently reattaches a retained row's state
613 /// to different content at the same index. See the [module docs]'
614 /// *Row identity* section for the full rule, and
615 /// [`ListView::builder_keyed`] for the stable-key alternative that survives
616 /// a mid-list mutation.
617 ///
618 /// [module docs]: self
619 ///
620 /// ```
621 /// use frust_widgets::{ListView, text};
622 /// let list: ListView<()> = ListView::builder(100, 48.0, |i| text(format!("row {i}")));
623 /// # let _ = list;
624 /// ```
625 pub fn builder<V: View<State>>(
626 item_count: usize,
627 item_extent: f64,
628 builder: impl Fn(usize) -> V + 'static,
629 ) -> Self {
630 assert!(
631 item_extent > 0.0,
632 "ListView item_extent must be positive (uniform extent)"
633 );
634 Self {
635 item_count,
636 item_extent,
637 builder: Rc::new(move |index| AnyView::new(builder(index))),
638 key_of: None,
639 estimated_item_extent: None,
640 on_near_start: None,
641 near_start_threshold: 0.0,
642 on_near_end: None,
643 near_end_threshold: 0.0,
644 on_refresh_release: None,
645 physics: None,
646 effect: default_overscroll_effect(),
647 controller: None,
648 }
649 }
650
651 /// Create a virtualized list whose rows are reconciled by the **stable key**
652 /// `key_of(index)` instead of by raw item index — the mid-list-mutation-safe
653 /// counterpart of [`ListView::builder`], everything else identical.
654 ///
655 /// `key_of` returns the identity of the row at an item index (a
656 /// [`ChildKey`], built from any [`Hash`](std::hash::Hash) value — an item id,
657 /// a name — via `ChildKey::new`/`.into()`); it is called once per
658 /// *materialized* slot per frame, never `item_count` times, and must be a
659 /// pure function of the index over one frame's data, exactly like `builder`.
660 /// Panics if `item_extent` is not positive, like [`ListView::builder`].
661 ///
662 /// ```ignore
663 /// ListView::builder_keyed(
664 /// rows.len(),
665 /// 56.0,
666 /// move |i| ChildKey::new(rows[i].id),
667 /// move |i| row_view(&rows[i]),
668 /// )
669 /// ```
670 ///
671 /// # Contract
672 ///
673 /// A row whose key stays in the window keeps its live widget — and so its
674 /// entire retained state — even when an insert/remove/reorder moves it to a
675 /// different index; a key that leaves the window (or the data) is torn down,
676 /// and a key entering is built fresh. **Keys must be unique within a
677 /// window:** a duplicate trips a `debug_assert!` and, in release, hands the
678 /// live row to the first slot claiming the key while later duplicates build
679 /// fresh (no panic, but which row keeps its state is arbitrary). See the
680 /// [module docs]' *Row identity* section.
681 ///
682 /// [module docs]: self
683 pub fn builder_keyed<V: View<State>>(
684 item_count: usize,
685 item_extent: f64,
686 key_of: impl Fn(usize) -> ChildKey + 'static,
687 builder: impl Fn(usize) -> V + 'static,
688 ) -> Self {
689 assert!(
690 item_extent > 0.0,
691 "ListView item_extent must be positive (uniform extent)"
692 );
693 Self {
694 item_count,
695 item_extent,
696 builder: Rc::new(move |index| AnyView::new(builder(index))),
697 key_of: Some(Rc::new(key_of)),
698 estimated_item_extent: None,
699 on_near_start: None,
700 near_start_threshold: 0.0,
701 on_near_end: None,
702 near_end_threshold: 0.0,
703 on_refresh_release: None,
704 physics: None,
705 effect: default_overscroll_effect(),
706 controller: None,
707 }
708 }
709
710 /// Let rows size themselves, taking `estimate_px` as the assumed extent of
711 /// every row that has not been measured yet — **variable-extent mode**,
712 /// available on [`ListView::builder_keyed`] lists only.
713 ///
714 /// A materialized row is laid out under the list's width with unbounded
715 /// height and reports whatever height it wants; that height is cached under
716 /// the row's stable key and used from then on for window math, the scroll
717 /// extent, and the row's content position. Unmeasured rows (everything not
718 /// yet laid out) count as `estimate_px`, so the scroll range converges on
719 /// the true content height as the user visits rows. The constructor's
720 /// `item_extent` is unused in this mode — pass the same value as the
721 /// estimate for clarity.
722 ///
723 /// ```ignore
724 /// ListView::builder_keyed(rows.len(), 72.0, key_of, builder)
725 /// .estimated_item_extent(72.0)
726 /// ```
727 ///
728 /// # Contract
729 ///
730 /// **Keyed lists only.** A measured extent is cached under row identity, so
731 /// it must move with the row; a positional list has no identity to cache
732 /// under. Calling this on a [`ListView::builder`] list trips a
733 /// `debug_assert!` and is **inert** in release — the list keeps its
734 /// closed-form uniform extent rather than panicking live (the same shape as
735 /// the duplicate-key tripwire, see the [module docs]' *Variable extents*
736 /// section for why this is a `debug_assert` and not type-state). Panics if
737 /// `estimate_px` is not positive, like the constructors' `item_extent`.
738 ///
739 /// [module docs]: self
740 pub fn estimated_item_extent(mut self, estimate_px: f64) -> Self {
741 assert!(
742 estimate_px > 0.0,
743 "ListView estimated_item_extent must be positive"
744 );
745 debug_assert!(self.key_of.is_some(), "{}", ESTIMATE_NEEDS_KEYS_MSG);
746 self.estimated_item_extent = Some(estimate_px);
747 self
748 }
749
750 /// The estimate this view actually runs in variable-extent mode with:
751 /// `Some` only when a keyed list also named an estimate (the keyed-only
752 /// contract's release behavior — see [`ListView::estimated_item_extent`]).
753 fn variable_estimate(&self) -> Option<f64> {
754 match (self.key_of.as_ref(), self.estimated_item_extent) {
755 (Some(_), Some(estimate)) => Some(estimate),
756 _ => None,
757 }
758 }
759
760 /// Fire `callback` when the scrolled window comes within `threshold_px` of
761 /// content **start** — the load-older edge for a newest-at-bottom chat list.
762 ///
763 /// The callback is **edge-triggered**: it fires once per approach and rearms
764 /// only after the user scrolls away past `2 × threshold_px` (or the item
765 /// count changes). Drag, wheel, and fling motion all observe it — fling
766 /// motion is driven at paint time (no [`frust_core::EventCtx`]), so a
767 /// fling-triggered fire is recorded and delivered on the next event, one
768 /// event late; a `Cancel` clears a pending fire without invoking the
769 /// callback.
770 pub fn on_near_start<F: Fn(&mut State) + 'static>(
771 mut self,
772 callback: F,
773 threshold_px: f64,
774 ) -> Self {
775 self.on_near_start = Some(Rc::new(callback));
776 self.near_start_threshold = threshold_px;
777 self
778 }
779
780 /// Fire `callback` when the scrolled window comes within `threshold_px` of
781 /// content **end** — the load-newer edge for an infinite-scroll-downward
782 /// list.
783 ///
784 /// Mirrors [`ListView::on_near_start`] exactly, measured from content end
785 /// instead of start: **edge-triggered**, firing once per approach and
786 /// rearming only after the user scrolls back past `2 × threshold_px` away
787 /// from the end (or the item count changes). Drag, wheel, and fling motion
788 /// all observe it — a fling-triggered fire is recorded and delivered on the
789 /// next event, one event late; a `Cancel` clears a pending fire without
790 /// invoking the callback.
791 pub fn on_near_end<F: Fn(&mut State) + 'static>(
792 mut self,
793 callback: F,
794 threshold_px: f64,
795 ) -> Self {
796 self.on_near_end = Some(Rc::new(callback));
797 self.near_end_threshold = threshold_px;
798 self
799 }
800
801 /// The pull-to-refresh trigger: fires on pointer `Up` when the list was
802 /// pulled past the top by more than `REFRESH_TRIGGER_PX` (post-resistance)
803 /// — the same name, signature, and threshold behavior as
804 /// [`crate::ScrollView::on_refresh_release`], so a screen can swap between
805 /// the two containers without relearning the contract. Never fires on a
806 /// `Cancel`, and a bottom overscroll can never trigger it (only a past-top
807 /// pull can). See the [module docs](self)' *Overscroll and pull-to-refresh*
808 /// section.
809 pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
810 self.on_refresh_release = Some(Rc::new(callback));
811 self
812 }
813
814 /// Install a custom [`ScrollPhysics`] strategy — the same seam
815 /// [`crate::ScrollView::physics`] installs, sharing `scroll.rs`'s
816 /// resistance/trigger/settle constants and this crate's
817 /// [`crate::physics::parity`]/[`RubberBand`](crate::RubberBand)
818 /// implementations.
819 ///
820 /// ```
821 /// use frust_widgets::{ListView, NeverScrollable, text};
822 /// let view: ListView<()> = ListView::builder(3, 40.0, |i| {
823 /// frust_core::any::<(), _>(text(i.to_string()))
824 /// })
825 /// .physics(NeverScrollable::new());
826 /// # let _ = view;
827 /// ```
828 ///
829 /// # Build/rebuild semantics
830 ///
831 /// Identical to [`crate::ScrollView::physics`]: a view built (or
832 /// rebuilt) *with* `.physics(...)` reinstalls it on the widget every
833 /// time; a view built (or rebuilt) *without* it leaves the widget's
834 /// currently-installed physics untouched (a fresh `build` still starts at
835 /// [`crate::physics::default_physics`], the widget's own constructor
836 /// default).
837 ///
838 /// Defaults to the platform-adaptive physics
839 /// ([`crate::physics::default_physics`] — Android clamping, elsewhere
840 /// bouncing) if never called; `.physics(RubberBand::new())` is how an app
841 /// asks for the pre-seam rubber-band feel instead.
842 pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
843 self.physics = Some(Rc::new(physics));
844 self
845 }
846
847 /// Select how past-edge pull is visualized. See [`OverscrollEffect`]
848 /// ([`crate::physics::effect`]) for the full contract — the same
849 /// selector [`crate::ScrollView::overscroll_effect`] installs.
850 ///
851 /// ```
852 /// use frust_widgets::{ListView, OverscrollEffect, text};
853 /// let view: ListView<()> = ListView::builder(3, 40.0, |i| {
854 /// frust_core::any::<(), _>(text(i.to_string()))
855 /// })
856 /// .overscroll_effect(OverscrollEffect::Stretch);
857 /// # let _ = view;
858 /// ```
859 ///
860 /// Plain view-owned data, unlike [`ListView::physics`]: every
861 /// build/rebuild carries the current value down to
862 /// [`ListViewWidget::effect`] unconditionally.
863 ///
864 /// Defaults to the effect paired with the platform's default physics
865 /// ([`crate::physics::default_overscroll_effect`]): the M3E
866 /// [`OverscrollEffect::Stretch`] on Android, translate overscroll
867 /// ([`OverscrollEffect::Translate`]) everywhere else.
868 pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
869 self.effect = effect;
870 self
871 }
872
873 /// Attach a [`ScrollController`] — the same handle
874 /// [`crate::ScrollView::controller`] takes, driving this list with
875 /// [`ScrollController::jump_to`]/[`ScrollController::animate_to`] and, on a
876 /// [`ListView::builder_keyed`] list,
877 /// [`ScrollController::scroll_to_item`]; read back through
878 /// [`ScrollController::offset`]/[`ScrollController::max_offset`]/
879 /// [`ScrollController::on_change`] from outside any dispatch.
880 ///
881 /// ```
882 /// use frust_widgets::{ChildKey, ListView, ScrollController, text};
883 /// use frust_widgets::scroll_controller::ItemAlignment;
884 /// let controller = ScrollController::new();
885 /// let view: ListView<()> = ListView::builder_keyed(
886 /// 100,
887 /// 40.0,
888 /// |i| ChildKey::new(i),
889 /// |i| frust_core::any::<(), _>(text(i.to_string())),
890 /// )
891 /// .controller(controller.clone());
892 /// controller.scroll_to_item(ChildKey::new(42usize), ItemAlignment::Start, false);
893 /// # let _ = view;
894 /// ```
895 ///
896 /// # Build/rebuild semantics
897 ///
898 /// Identical to [`crate::ScrollView::controller`]: a build binds the
899 /// handle (taking it over from any other surface); a rebuild with the same
900 /// handle keeps the binding and never takes it back from a surface that
901 /// has since taken it over; a rebuild with a different handle binds that
902 /// one; a rebuild with none detaches; dropping the widget detaches the
903 /// handle it still holds. See the [module docs](self)' *Programmatic
904 /// scrolling* section for what is local to a list.
905 pub fn controller(mut self, controller: ScrollController) -> Self {
906 self.controller = Some(controller);
907 self
908 }
909}
910
911/// The reconciliation half of [`View::rebuild`], split per identity mode. See the
912/// [module docs](self)' *Row identity* section for which one runs when.
913impl<State: 'static> ListView<State> {
914 /// Reconcile the materialized window to `[start, end)` **by item index** —
915 /// the [`ListView::builder`] path, unchanged since the keyed one landed
916 /// beside it: an index that stays in the window keeps its live pod and is
917 /// rebuilt in place against its own previous view, indices leaving are torn
918 /// down, indices entering are built fresh.
919 ///
920 /// Because the window is a contiguous range, any change to it necessarily
921 /// builds or tears down at least one pod, so `structural` covers the
922 /// "window shifted" case as well as the materialization one.
923 fn reconcile_positional(
924 &self,
925 prev: &Self,
926 element: &mut ListViewWidget,
927 ctx: &mut BuildCtx<'_>,
928 plan: WindowPlan,
929 ) -> ChangeFlags {
930 let (start, end) = (plan.start, plan.end);
931 let mut flags = ChangeFlags::NONE;
932 // Move the live window out so surviving indices can be relocated by key.
933 let old_keys = std::mem::take(&mut element.keys);
934 let old_children = std::mem::take(&mut element.children);
935 let mut old: HashMap<usize, ChildPod> = old_keys.into_iter().zip(old_children).collect();
936
937 let mut new_children = Vec::with_capacity(end.saturating_sub(start));
938 let mut new_keys = Vec::with_capacity(end.saturating_sub(start));
939 let mut structural = false;
940
941 for index in start..end {
942 if let Some(mut pod) = old.remove(&index) {
943 // Survivor: rebuild in place against its own previous view
944 // (reconstructed from the pure builder) — state preserved.
945 let prev_view = (prev.builder)(index);
946 let next_view = (self.builder)(index);
947 flags |= crate::authoring::rebuild_child(&prev_view, &next_view, &mut pod, ctx);
948 new_children.push(pod);
949 } else {
950 new_children.push(crate::authoring::build_child(&(self.builder)(index), ctx));
951 structural = true;
952 }
953 new_keys.push(index);
954 }
955
956 // Indices that left the window are torn down (cancel-if-active inside
957 // teardown_child unwinds an armed child).
958 for (index, mut pod) in old.drain() {
959 crate::authoring::teardown_child(&(prev.builder)(index), &mut pod, ctx);
960 structural = true;
961 }
962
963 element.children = new_children;
964 element.keys = new_keys;
965 element.sync_child_origins();
966
967 if structural {
968 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
969 }
970 flags
971 }
972
973 /// Reconcile the materialized window to `[start, end)` **by stable key** —
974 /// the [`ListView::builder_keyed`] path.
975 ///
976 /// Slots stay the contiguous index run `[start, end)` (the uniform-extent
977 /// fast path is untouched); identity is matched through the retained
978 /// [`ListViewWidget::key_index`] map instead. For each slot: the key
979 /// `key_of(index)` names the item index that row occupied last frame, whose
980 /// live pod is relocated into this slot and rebuilt against
981 /// `prev.builder(prev_index)` → `self.builder(index)`. Keys with no entry
982 /// build fresh; pods no slot claimed are torn down with the same
983 /// cancel-if-active [`crate::authoring::teardown_child`] path the positional
984 /// reconciler uses — nothing more.
985 ///
986 /// A duplicate key is ambiguous and trips a `debug_assert!` mirroring
987 /// [`crate::authoring::rebuild_children`]'s; release builds carry on (first
988 /// claim keeps the live row, later duplicates build fresh) rather than panic.
989 ///
990 /// In variable-extent mode this pass additionally retains the window's slot
991 /// keys (so `layout` can cache each measurement under the right identity
992 /// without re-keying), re-pins the prefix anchor from `plan`, and applies
993 /// the departing-row half of the measured cache's hygiene rules — `delta` is
994 /// this frame's net `item_count` change, the second hypothesis of the same
995 /// probe [`ListView::anchor_shift_items`] uses. `anchor_probe_missed` is
996 /// that same probe's own outcome (`true` when it matched no previous-window
997 /// key at all); this pass is where the wholesale-replace half of the
998 /// hygiene rules is actually decided, against its own exact per-slot key
999 /// matches below rather than the probe's hypotheses — see the [module
1000 /// docs](self)' *Variable extents* and *Cache hygiene* sections.
1001 #[allow(clippy::too_many_arguments)]
1002 fn reconcile_keyed(
1003 &self,
1004 prev: &Self,
1005 element: &mut ListViewWidget,
1006 ctx: &mut BuildCtx<'_>,
1007 plan: WindowPlan,
1008 key_of: &KeyOf,
1009 delta: isize,
1010 anchor_probe_missed: bool,
1011 ) -> ChangeFlags {
1012 let (start, end) = (plan.start, plan.end);
1013 let capacity = end.saturating_sub(start);
1014 // One pass over the new window: each slot's key (so `key_of` runs exactly
1015 // once per slot), the map this frame will retain, and the duplicate check
1016 // — done up front, like the authoring reconciler's, so a debug build trips
1017 // before any pod has been moved.
1018 let mut slot_keys: Vec<ChildKey> = Vec::with_capacity(capacity);
1019 let mut next_index_of: HashMap<ChildKey, usize> = HashMap::with_capacity(capacity);
1020 let mut duplicate = false;
1021 for index in start..end {
1022 let key = key_of(index);
1023 duplicate |= next_index_of.insert(key, index).is_some();
1024 slot_keys.push(key);
1025 }
1026 debug_assert!(!duplicate, "{}", DUPLICATE_KEY_MSG);
1027
1028 // Move the live window out so surviving rows can be relocated by key: the
1029 // previous frame's `key -> item index` map, and the pods by the item index
1030 // each rendered.
1031 let prev_index_of = std::mem::take(&mut element.key_index);
1032 let old_keys = std::mem::take(&mut element.keys);
1033 let old_children = std::mem::take(&mut element.children);
1034 // A moved window range owes a layout pass on its own, without depending on
1035 // the match outcome below to prove it (the window is a contiguous run, so
1036 // first index + length pin it exactly).
1037 let window_shifted = old_keys.first().copied() != (capacity > 0).then_some(start)
1038 || old_keys.len() != capacity;
1039 let mut old: HashMap<usize, ChildPod> = old_keys.into_iter().zip(old_children).collect();
1040
1041 let mut new_children = Vec::with_capacity(capacity);
1042 let mut new_keys = Vec::with_capacity(capacity);
1043 let mut flags = ChangeFlags::NONE;
1044 let mut structural = window_shifted;
1045 // Whether any slot in this frame's reconciled window matched a key the
1046 // previous window also held — an *exact* hashmap lookup, unlike the
1047 // rebuild-time anchor-shift probe's two index hypotheses. Read below to
1048 // decide the wholesale-replace half of the measured-cache hygiene
1049 // rules.
1050 let mut any_survivor = false;
1051
1052 for (index, key) in (start..end).zip(slot_keys.iter().copied()) {
1053 let survivor = prev_index_of
1054 .get(&key)
1055 .copied()
1056 .and_then(|prev_index| old.remove(&prev_index).map(|pod| (prev_index, pod)));
1057 match survivor {
1058 Some((prev_index, mut pod)) => {
1059 any_survivor = true;
1060 // The row survived under its key: relocate its live pod into
1061 // this slot and rebuild it in place against the view it
1062 // actually holds — `prev.builder(prev_index)`, the index it
1063 // rendered last frame. That redirection is the whole point of
1064 // the retained map; state (hosted component, press, toggle)
1065 // rides along with the pod.
1066 let prev_view = (prev.builder)(prev_index);
1067 let next_view = (self.builder)(index);
1068 flags |= crate::authoring::rebuild_child(&prev_view, &next_view, &mut pod, ctx);
1069 if prev_index != index {
1070 // Same row, different slot: its origin moves, so the frame
1071 // owes a layout pass even though nothing was built or torn
1072 // down.
1073 structural = true;
1074 }
1075 new_children.push(pod);
1076 }
1077 None => {
1078 // A key with no live row: entering the window, or new data.
1079 new_children.push(crate::authoring::build_child(&(self.builder)(index), ctx));
1080 structural = true;
1081 }
1082 }
1083 new_keys.push(index);
1084 }
1085
1086 // Measured-cache hygiene (variable extents only).
1087 if element.is_variable() {
1088 if anchor_probe_missed && !any_survivor {
1089 // The rebuild-time anchor-shift probe matched no previous-window
1090 // key by hypothesis, *and* literally no previous-window key
1091 // matched a slot in this frame's reconciled window by exact
1092 // lookup either — a genuine full replace (or a jump far enough
1093 // that nothing on screen is recognizable), which is reset
1094 // semantics for the measured cache too. Deliberately not
1095 // triggered by `anchor_probe_missed` alone: that probe only
1096 // tests two candidate index shifts and can miss on a same-frame
1097 // mutation touching both sides of the anchor (a prepend above
1098 // the viewport plus an append below it in one frame) while
1099 // `any_survivor` above still proves most on-screen rows are
1100 // exactly the ones they were — see the [module docs](self)'
1101 // *Cache hygiene* section.
1102 element.clear_measured();
1103 } else {
1104 // A pod no slot claimed left the window *or* the data, and only
1105 // the second case may drop its measurement. Probe each orphan's
1106 // key at its unchanged index and at that index shifted by this
1107 // frame's net item-count delta — the same two hypotheses
1108 // anchoring uses, at most two `key_of` calls per departing row,
1109 // never a scan of `item_count`. Same acknowledged imprecision as
1110 // the anchor probe (see the module docs' *Cache hygiene*
1111 // section's gaps) — narrower blast radius than the wholesale
1112 // clear above, since a false miss here only evicts one
1113 // already-departed row's entry.
1114 //
1115 // Runs on every frame that isn't a genuine full replace,
1116 // including one where the anchor-shift probe above missed
1117 // (`anchor_probe_missed && any_survivor`, the branch that lands
1118 // here rather than in the wholesale clear above): a probe miss
1119 // does not mean this loop's own two hypotheses are unreliable
1120 // for a *specific* orphaned row — it only means no *single*
1121 // uniform hypothesis explained every row in the previous
1122 // window at once (the same-frame both-sides-of-the-anchor
1123 // case). Gating this loop on the probe's outcome, as an
1124 // earlier version of this fix did, traded a bounded
1125 // over-eviction (re-measure one row that was merely outside
1126 // the still-mis-anchored window) for an unbounded leak: a row
1127 // truly removed from the data on such a frame has no later
1128 // shrink or revisit to reclaim it — `record_measurement` never
1129 // refreshes a dead key, and `evict_measured_stale_indices`
1130 // only scans on a frame whose `item_count` shrank, which a
1131 // frame that *adds* a sentinel/anchor row alongside a removal
1132 // (any_survivor's own precondition) need never be. A false
1133 // eviction here costs one row's re-measurement — the module's
1134 // own documented conservative direction — never a permanent
1135 // leak.
1136 let count = element.item_count;
1137 for (key, prev_index) in prev_index_of.iter() {
1138 if old.contains_key(prev_index)
1139 && !Self::key_survives(*key, *prev_index, delta, count, key_of)
1140 {
1141 element.forget_measured(key);
1142 }
1143 }
1144 }
1145 }
1146
1147 // Every pod no slot claimed is a key that left the window or the data.
1148 for (index, mut pod) in old.drain() {
1149 crate::authoring::teardown_child(&(prev.builder)(index), &mut pod, ctx);
1150 structural = true;
1151 }
1152
1153 element.children = new_children;
1154 element.keys = new_keys;
1155 element.key_index = next_index_of;
1156 if let Some(estimate) = element.variable_estimate() {
1157 // Retain this window's identities beside its slots, then re-pin the
1158 // prefix anchor and the per-slot content positions to the plan the
1159 // window was computed from (layout refines them from measurements).
1160 element.slot_keys = slot_keys;
1161 element.set_window_geometry(plan, estimate);
1162 }
1163 element.sync_child_origins();
1164
1165 if structural {
1166 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1167 }
1168 flags
1169 }
1170
1171 /// Determine the keyed prepend/removal scroll-anchor correction for this
1172 /// rebuild, in **items** (the caller multiplies by `item_extent`), or
1173 /// `None` if no correction should apply. See the [module docs](self)'
1174 /// anchoring section for the algorithm and the `offset == 0` decision.
1175 ///
1176 /// `element.item_count` must already hold this frame's (new) count —
1177 /// callers apply the item-count bookkeeping before calling this — while
1178 /// `old_item_count` is the count as of the *previous* frame.
1179 fn anchor_shift_items(
1180 prev: &Self,
1181 element: &ListViewWidget,
1182 old_item_count: usize,
1183 key_of: &KeyOf,
1184 ) -> Option<isize> {
1185 // A prior frame built with the positional path (or no key_of at all)
1186 // leaves no previous-frame key function to reconstruct an anchor
1187 // key from — nothing to anchor against.
1188 let prev_key_of = prev.key_of.as_ref()?;
1189 let new_item_count = element.item_count;
1190 let delta = new_item_count as isize - old_item_count as isize;
1191 for &i_prev in &element.keys {
1192 let anchor_key = prev_key_of(i_prev);
1193 // Unchanged position: the below-viewport/no-op case (the common
1194 // case — checked first, resolves in one extra `key_of` call).
1195 if i_prev < new_item_count && key_of(i_prev) == anchor_key {
1196 return Some(0);
1197 }
1198 // Shifted by the frame's net item-count delta: the
1199 // prepend/removal-above case, confirmed rather than assumed.
1200 if delta != 0 {
1201 let candidate = i_prev as isize + delta;
1202 if candidate >= 0
1203 && (candidate as usize) < new_item_count
1204 && key_of(candidate as usize) == anchor_key
1205 {
1206 return Some(delta);
1207 }
1208 }
1209 }
1210 // No key in the previous window survived either hypothesis: a full
1211 // replace (or an edit this closed-form check can't characterize) —
1212 // reset semantics, not an anchoring case.
1213 None
1214 }
1215
1216 /// Whether `key` still identifies a row in this frame's data, probed at the
1217 /// index it last occupied and at that index shifted by the frame's net
1218 /// item-count `delta` — the same two hypotheses
1219 /// [`ListView::anchor_shift_items`] tests, at most two `key_of` calls. Used
1220 /// by the measured cache's departing-row eviction; a `false` here means the
1221 /// row left the data, not merely the window.
1222 fn key_survives(
1223 key: ChildKey,
1224 prev_index: usize,
1225 delta: isize,
1226 item_count: usize,
1227 key_of: &KeyOf,
1228 ) -> bool {
1229 if prev_index < item_count && key_of(prev_index) == key {
1230 return true;
1231 }
1232 if delta != 0 {
1233 let candidate = prev_index as isize + delta;
1234 if candidate >= 0
1235 && (candidate as usize) < item_count
1236 && key_of(candidate as usize) == key
1237 {
1238 return true;
1239 }
1240 }
1241 false
1242 }
1243}
1244
1245/// Create a virtualized [`ListView`] — the free-function spelling of
1246/// [`ListView::builder`]. `builder` may return any [`View`]; it is erased
1247/// here.
1248///
1249/// ```
1250/// use frust_widgets::{ListView, list_view, text};
1251/// let list: ListView<()> = list_view(100, 48.0, |i| text(format!("row {i}")));
1252/// # let _ = list;
1253/// ```
1254pub fn list_view<State: 'static, V: View<State>>(
1255 item_count: usize,
1256 item_extent: f64,
1257 builder: impl Fn(usize) -> V + 'static,
1258) -> ListView<State> {
1259 ListView::builder(item_count, item_extent, builder)
1260}
1261
1262/// The retained widget for a [`ListView`]: the materialized window of children
1263/// (`children[j]` renders item `keys[j]`), the scroll offset + cached viewport,
1264/// and the same fling bookkeeping as [`crate::ScrollWidget`].
1265pub struct ListViewWidget {
1266 /// The currently materialized rows, parallel to [`ListViewWidget::keys`].
1267 children: Vec<ChildPod>,
1268 /// The item index each entry of [`ListViewWidget::children`] renders. Always
1269 /// a contiguous ascending run (uniform extent → the window is a range).
1270 keys: Vec<usize>,
1271 /// Row identity for a [`ListView::builder_keyed`] list: `stable key -> the
1272 /// item index that row occupied in the materialized window`, as of the last
1273 /// build/rebuild. Empty for a positional [`ListView::builder`] list (and
1274 /// cleared by any positional rebuild, so a list switched between the two
1275 /// constructors never matches against a window the other path materialized).
1276 ///
1277 /// This is the retained half of keyed reconciliation: window slots stay
1278 /// contiguous indices for the uniform-extent fast path, and identity is
1279 /// carried here instead — read back by `rebuild` off the element exactly like
1280 /// [`ListViewWidget::offset`]/[`ListViewWidget::viewport`], since the view is
1281 /// reconstructed from scratch every frame.
1282 key_index: HashMap<ChildKey, usize>,
1283 /// The keyed list's own `index -> identity` function, retained (a cheap
1284 /// [`Rc`] clone, reinstalled every rebuild like the erased callbacks) so the
1285 /// widget's own passes can key an index without the view in scope: the
1286 /// variable-extent prefix walk looks a row's measurement up by key, and
1287 /// `paint`'s convergence check re-derives the same window. `None` for a
1288 /// positional list. This is the *key* function only — the builder is never
1289 /// retained here, and never runs outside rebuild (see the [module
1290 /// docs](self)' *Variable extents* section).
1291 key_of: Option<KeyOf>,
1292 /// The assumed extent of an unmeasured row in variable-extent mode, or
1293 /// `None` for the closed-form uniform path. Set from
1294 /// [`ListView::estimated_item_extent`], already resolved against the
1295 /// keyed-only contract.
1296 estimated_extent: Option<f64>,
1297 /// Variable-extent mode: every row this widget has ever laid out, by stable
1298 /// key. Bounded by the keys a session has visited — see the [module
1299 /// docs](self)' cache-hygiene rules (and the LRU deferral noted there).
1300 measured: HashMap<ChildKey, Measured>,
1301 /// The running Σ of [`ListViewWidget::measured`]'s extents, so the content
1302 /// extent is O(1) rather than a scan of the cache.
1303 measured_sum: f64,
1304 /// Variable-extent mode: the identity of each materialized slot, parallel to
1305 /// [`ListViewWidget::keys`], so `layout` can cache a measurement under the
1306 /// right key without calling `key_of` again. Empty on the uniform path.
1307 slot_keys: Vec<ChildKey>,
1308 /// Variable-extent mode: each materialized slot's content-space `y`,
1309 /// parallel to [`ListViewWidget::keys`] — what rows are placed at (minus the
1310 /// offset) instead of the uniform path's `index * item_extent`. Empty on the
1311 /// uniform path.
1312 slot_y: Vec<f64>,
1313 /// Variable-extent mode: the prefix anchor's item index — the item
1314 /// [`ListViewWidget::anchor_y`] gives the content-space top of, and the
1315 /// point every per-frame prefix walk starts from (the previous window's
1316 /// start). See the [module docs](self)' *Variable extents* section.
1317 anchor_index: usize,
1318 /// The content-space `y` of [`ListViewWidget::anchor_index`]'s top.
1319 anchor_y: f64,
1320 /// Variable-extent mode: the scroll correction `layout` has measured but no
1321 /// rebuild has committed into [`ListViewWidget::offset`] yet — Σ
1322 /// `measured − assumed` over the rows that lay wholly above the viewport top
1323 /// in the geometry the frame's window was planned against. Always `0.0` on
1324 /// the uniform path (which measures nothing). Read back through
1325 /// [`ListViewWidget::placement_offset`] by everything that places content, so
1326 /// it is honored the frame it is recorded; committed by
1327 /// [`ListViewWidget::apply_pending_correction`]. See the [module docs](self)'
1328 /// *Measured anchor correction* section.
1329 pending_correction: f64,
1330 item_count: usize,
1331 item_extent: f64,
1332 /// The **windowing** scroll offset — always in `[0, max_offset]`, the
1333 /// value every item-index computation (window planning, the prefix walk,
1334 /// edge triggers) reads through [`ListViewWidget::placement_offset`]. Never
1335 /// carries an out-of-range value; see [`ListViewWidget::overscroll`] and
1336 /// the [module docs](self)' *Overscroll and pull-to-refresh* section for
1337 /// where a drag past an edge is represented instead.
1338 offset: f64,
1339 /// The physics-mapped drag position accumulated during an active scroll
1340 /// drag, **before** boundary rejection, in the same **raw-offset space**
1341 /// [`ListViewWidget::offset`] itself lives in — never
1342 /// [`ListViewWidget::placement_offset`]'s pending-corrected space, since
1343 /// [`ListViewWidget::apply_drag_offset`] splits it straight into `offset`
1344 /// plus `overscroll` by absolute assignment. Seeded at takeover from
1345 /// `offset + overscroll` (the raw offset plus the live displacement — the
1346 /// intentional regrab-mid-bounce term, so a regrab mid-bounce continues
1347 /// smoothly from what is on screen) and advanced by each move's mapped
1348 /// delta thereafter; deliberately **not**
1349 /// [`ListViewWidget::painted_offset`], which would also fold in a nonzero
1350 /// [`ListViewWidget::pending_correction`] and double-count it once
1351 /// `placement_offset` re-adds it on the first post-takeover move.
1352 /// Mirrors `ScrollWidget::drag_position`, including the part that runs off
1353 /// past an edge under a clamping physics.
1354 drag_position: f64,
1355 /// The signed, resisted past-edge visual displacement a drag shows beyond
1356 /// the windowing [`ListViewWidget::offset`]: negative past the top,
1357 /// positive past the bottom, `0.0` while in range. Only a drag ever sets
1358 /// this nonzero; wheel and fling always force it back to `0.0` (both stay
1359 /// hard-clamped, matching `ScrollView`). See
1360 /// [`ListViewWidget::painted_offset`] and the [module docs](self)'
1361 /// *Overscroll and pull-to-refresh* section.
1362 overscroll: f64,
1363 /// Whether a release-settle animation is easing an overscrolled
1364 /// [`ListViewWidget::overscroll`] back to `0.0` (driven at paint via
1365 /// [`ListViewWidget::settle_tick`], mirrors `ScrollWidget::settling`).
1366 settling: bool,
1367 /// The installed scroll physics — [`crate::physics::default_physics`]'s
1368 /// platform-adaptive choice unless [`ListView::physics`] replaces it, the
1369 /// same default `ScrollView` takes. `Rc`, not
1370 /// `Box`: every [`ScrollPhysics`] method takes `&self`, so a shared,
1371 /// immutable handle is both cheap to (re)install and sufficient (mirrors
1372 /// `ScrollWidget::physics` — see its doc for the full rationale).
1373 /// Survives a rebuild whose view carries no `.physics(...)` call
1374 /// untouched; see [`ListView::physics`] for the full contract and the
1375 /// [module docs](self)' *Overscroll and pull-to-refresh* section.
1376 pub(crate) physics: Rc<dyn ScrollPhysics>,
1377 /// How past-edge pull is visualized —
1378 /// [`crate::physics::default_overscroll_effect`]'s platform pairing unless
1379 /// [`ListView::overscroll_effect`] names one. Read at paint alone (see
1380 /// [`ListViewWidget::painted_offset`] and `scroll.rs`'s *Overscroll
1381 /// visuals*), never by layout, windowing, or the physics.
1382 pub(crate) effect: OverscrollEffect,
1383 /// The signed pull past an edge, negative past the top: the displacement
1384 /// the physics allowed ([`ListViewWidget::overscroll`]) plus whatever
1385 /// [`ScrollPhysics::apply_boundary_conditions`] rejected while the position
1386 /// was pinned at the edge. Under a physics that rejects nothing
1387 /// (`Bouncing`, [`RubberBand`](crate::RubberBand)) it *is* `overscroll` —
1388 /// which is why basing the refresh trigger on it changes no trigger
1389 /// distance for either. Same field, same contract, same sign as
1390 /// `ScrollWidget::edge_pull`; see it for the full rule.
1391 pub(crate) edge_pull: f64,
1392 /// A generic ballistic simulation handed back by
1393 /// [`ScrollPhysics::create_ballistic_simulation`] on release, or `None` —
1394 /// always `None` under [`RubberBand`](crate::RubberBand), which keeps the
1395 /// legacy [`ListViewWidget::fling`] path instead.
1396 ballistic: Option<BallisticState>,
1397 /// Velocity (px/s of offset) of the motion a new `Down` interrupted, fed to
1398 /// [`ScrollPhysics::carried_momentum`] at the next fling start. Always
1399 /// `0.0` when the press landed on a resting surface.
1400 carried_velocity: f64,
1401 /// Resolved viewport size (this widget's own size), cached from the previous
1402 /// layout so rebuild can window against it (BuildCtx carries no viewport).
1403 viewport: Size,
1404 /// Whether a scroll drag has taken the gesture over (past the slop).
1405 scrolling: bool,
1406 /// Whether a `Down` armed an active gesture (mirrors `ScrollWidget`).
1407 down_active: bool,
1408 /// What the nearest nested scroll surface inside a materialized row claimed
1409 /// it could do with this gesture, read back out of the claim cell this
1410 /// widget pushed around the `Down`'s routing — the input to the
1411 /// innermost-wins decision at the takeover site. Same field, same contract,
1412 /// same `Down`-time staleness window as `ScrollWidget::inner_at_down`; see
1413 /// it for the full rule.
1414 pub(crate) inner_at_down: InnerScrollState,
1415 /// Whether this gesture was handed to that nested surface — sticky for the
1416 /// rest of the gesture, exactly like `ScrollWidget::deferring`.
1417 pub(crate) deferring: bool,
1418 /// The live multi-contact veto cell for the *current* gesture (mirrors
1419 /// `ScrollWidget::live_veto`). Checked on every `Move` at the takeover site, not read once:
1420 /// the recognizer flips it live as a second contact joins and leaves.
1421 /// Replaced with a fresh, unset cell on every `Down` (and cleared again
1422 /// on `Up`/`Cancel`) so a stale recognizer handle from a previous gesture
1423 /// can never veto this one.
1424 live_veto: Rc<Cell<bool>>,
1425 down_start: Point,
1426 last_drag: Point,
1427 tracker: VelocityTracker,
1428 /// Active fling velocity (px/s of offset), or `None` when not flinging.
1429 fling: Option<f64>,
1430 /// Last painted frame time, reused as the event-pass timestamp for velocity
1431 /// tracking (the event pass carries no clock).
1432 last_frame_time: FrameTime,
1433 /// Last animation frame time for the paint-time fling pump; `None` seeds the
1434 /// clock (zero-delta) on the first paint after a release.
1435 last_anim: Option<FrameTime>,
1436 /// The near-start "load older" callback (`None` if the view set none).
1437 on_near_start: Option<ErasedCallback>,
1438 /// Distance from content start (px) at which `on_near_start` fires.
1439 near_start_threshold: f64,
1440 /// Whether `on_near_start` is armed to fire on the next near-start approach.
1441 /// Cleared when it fires; rearmed after scrolling away past `2 × threshold`
1442 /// or an item-count change.
1443 near_start_armed: bool,
1444 /// A near-start fire detected by the paint-time fling pump (no `EventCtx`);
1445 /// delivered on the next event, cleared by a `Cancel` without firing.
1446 pending_near_start: bool,
1447 /// The near-end "load newer" callback (`None` if the view set none).
1448 on_near_end: Option<ErasedCallback>,
1449 /// Distance from content end (px) at which `on_near_end` fires.
1450 near_end_threshold: f64,
1451 /// Whether `on_near_end` is armed to fire on the next near-end approach.
1452 /// Cleared when it fires; rearmed after scrolling away past `2 ×
1453 /// threshold` or an item-count change.
1454 near_end_armed: bool,
1455 /// A near-end fire detected by the paint-time fling pump (no `EventCtx`);
1456 /// delivered on the next event, cleared by a `Cancel` without firing.
1457 pending_near_end: bool,
1458 /// The pull-to-refresh release callback (`None` if the view set none).
1459 on_refresh_release: Option<ErasedCallback>,
1460 /// This surface's claim on an attached [`ScrollController`], or `None`.
1461 /// Dropping it (with the widget, or on a rebuild that names no handle)
1462 /// detaches the handle when this surface still holds it.
1463 controller: Option<ScrollBinding>,
1464 /// Whether a layout has run, so the viewport and extent a controller
1465 /// command resolves against (and the snapshot it publishes) are measured
1466 /// ones rather than the pre-layout zero.
1467 laid_out: bool,
1468 /// Whether [`ListViewWidget::ballistic`] is running a controller-driven
1469 /// tween rather than a release fling/spring — this surface's mirror of
1470 /// [`ScrollController::is_animating`], cleared at the one tear-down site
1471 /// ([`ListViewWidget::stop_ballistic`]). Mirrors
1472 /// `ScrollWidget::controller_animating`.
1473 controller_animating: bool,
1474 /// The motion tokens the last layout/paint resolved, for the rebuild-time
1475 /// drain (see [`ControllerMotion`]).
1476 motion: ControllerMotion,
1477 /// A [`ScrollController::scroll_to_item`] request still settling, or
1478 /// `None`. See the [module docs](self)' *Programmatic scrolling* section.
1479 item_target: Option<ItemTarget>,
1480 /// Whether a controller jump recorded a near-start/near-end fire since the
1481 /// last paint — the paint that sees it requests the `Housekeeping` flush
1482 /// that delivers it (the fire has no `EventCtx` to go through when the
1483 /// jump lands). Mirrors `ScrollWidget::jump_notify_owed`.
1484 jump_notify_owed: bool,
1485}
1486
1487impl ListViewWidget {
1488 fn new(item_count: usize, item_extent: f64) -> Self {
1489 Self {
1490 children: Vec::new(),
1491 keys: Vec::new(),
1492 key_index: HashMap::new(),
1493 key_of: None,
1494 estimated_extent: None,
1495 measured: HashMap::new(),
1496 measured_sum: 0.0,
1497 slot_keys: Vec::new(),
1498 slot_y: Vec::new(),
1499 anchor_index: 0,
1500 anchor_y: 0.0,
1501 pending_correction: 0.0,
1502 item_count,
1503 item_extent,
1504 offset: 0.0,
1505 drag_position: 0.0,
1506 overscroll: 0.0,
1507 settling: false,
1508 physics: default_physics(),
1509 effect: default_overscroll_effect(),
1510 edge_pull: 0.0,
1511 ballistic: None,
1512 carried_velocity: 0.0,
1513 viewport: Size::ZERO,
1514 scrolling: false,
1515 down_active: false,
1516 inner_at_down: InnerScrollState::default(),
1517 deferring: false,
1518 live_veto: Rc::new(Cell::new(false)),
1519 down_start: Point::ZERO,
1520 last_drag: Point::ZERO,
1521 tracker: VelocityTracker::new(),
1522 fling: None,
1523 last_frame_time: FrameTime::ZERO,
1524 last_anim: None,
1525 on_near_start: None,
1526 near_start_threshold: 0.0,
1527 near_start_armed: true,
1528 pending_near_start: false,
1529 on_near_end: None,
1530 near_end_threshold: 0.0,
1531 near_end_armed: true,
1532 pending_near_end: false,
1533 on_refresh_release: None,
1534 controller: None,
1535 laid_out: false,
1536 controller_animating: false,
1537 motion: ControllerMotion::from_theme(None),
1538 item_target: None,
1539 jump_notify_owed: false,
1540 }
1541 }
1542
1543 /// Edge-detect the near-start "load older" condition, mutating the armed
1544 /// state: rearm once scrolled away past `2 × threshold`, and return `true`
1545 /// exactly once when armed and the offset comes within `threshold` of content
1546 /// start. Callers fire the callback on a `true` return.
1547 ///
1548 /// Measured against [`ListViewWidget::placement_offset`] — where the content
1549 /// actually sits — rather than the raw offset, so an uncommitted
1550 /// variable-extent correction can neither rearm nor fire this edge (the
1551 /// placement is exactly what a commit leaves unchanged). Identical to the
1552 /// offset on the uniform path, which never has a pending correction.
1553 fn evaluate_near_start(&mut self) -> bool {
1554 if self.on_near_start.is_none() || self.item_count == 0 {
1555 return false;
1556 }
1557 let threshold = self.near_start_threshold;
1558 let position = self.placement_offset();
1559 if position > 2.0 * threshold {
1560 self.near_start_armed = true;
1561 }
1562 if self.near_start_armed && position <= threshold {
1563 self.near_start_armed = false;
1564 return true;
1565 }
1566 false
1567 }
1568
1569 /// Fire `on_near_start` during the event pass if the near-start edge just
1570 /// triggered.
1571 fn fire_near_start(&mut self, ctx: &mut EventCtx) {
1572 if self.evaluate_near_start()
1573 && let Some(cb) = self.on_near_start.as_mut()
1574 {
1575 cb(ctx);
1576 }
1577 }
1578
1579 /// Deliver a near-start fire recorded by the paint-time fling pump on the
1580 /// next event (one event of latency — the paint pass has no `EventCtx`).
1581 fn deliver_pending_near_start(&mut self, ctx: &mut EventCtx) {
1582 if self.pending_near_start {
1583 self.pending_near_start = false;
1584 if let Some(cb) = self.on_near_start.as_mut() {
1585 cb(ctx);
1586 }
1587 }
1588 }
1589
1590 /// Edge-detect the near-end "load newer" condition, mirroring
1591 /// [`Self::evaluate_near_start`] but measured from content **end**: the
1592 /// remaining scrollable distance below the viewport (`max_offset − offset`)
1593 /// rather than the offset itself. Reads the placement for the same reason
1594 /// [`Self::evaluate_near_start`] does.
1595 fn evaluate_near_end(&mut self) -> bool {
1596 if self.on_near_end.is_none() || self.item_count == 0 {
1597 return false;
1598 }
1599 let threshold = self.near_end_threshold;
1600 let distance = self.max_offset() - self.placement_offset();
1601 if distance > 2.0 * threshold {
1602 self.near_end_armed = true;
1603 }
1604 if self.near_end_armed && distance <= threshold {
1605 self.near_end_armed = false;
1606 return true;
1607 }
1608 false
1609 }
1610
1611 /// Fire `on_near_end` during the event pass if the near-end edge just
1612 /// triggered.
1613 fn fire_near_end(&mut self, ctx: &mut EventCtx) {
1614 if self.evaluate_near_end()
1615 && let Some(cb) = self.on_near_end.as_mut()
1616 {
1617 cb(ctx);
1618 }
1619 }
1620
1621 /// Deliver a near-end fire recorded by the paint-time fling pump on the
1622 /// next event (mirrors [`Self::deliver_pending_near_start`]).
1623 fn deliver_pending_near_end(&mut self, ctx: &mut EventCtx) {
1624 if self.pending_near_end {
1625 self.pending_near_end = false;
1626 if let Some(cb) = self.on_near_end.as_mut() {
1627 cb(ctx);
1628 }
1629 }
1630 }
1631
1632 /// The current scroll offset.
1633 pub fn offset(&self) -> f64 {
1634 self.offset
1635 }
1636
1637 /// The item indices currently materialized (the visible window ± buffer).
1638 pub fn window(&self) -> &[usize] {
1639 &self.keys
1640 }
1641
1642 /// The maximum scroll offset (`extent − viewport`, never negative).
1643 pub fn max_offset(&self) -> f64 {
1644 (self.content_extent() - self.viewport.height).max(0.0)
1645 }
1646
1647 /// The content-space `y` the viewport's top edge is at this frame **for
1648 /// windowing purposes**: the committed [`ListViewWidget::offset`] plus the
1649 /// correction `layout` has measured and no rebuild has committed yet,
1650 /// through the same `[0, max_offset]` clamp every offset write takes —
1651 /// always in range, never carrying [`ListViewWidget::overscroll`].
1652 ///
1653 /// Everything that decides item indices reads this — the prefix walk, the
1654 /// window, each row's content-space `y`, the near-start/near-end edge
1655 /// triggers — so a measured correction is honored the instant it is
1656 /// recorded and committing it is visually a no-op, and a row is never
1657 /// asked to materialize for an out-of-range index. Everything that *moves*
1658 /// the scroll (drag, wheel, fling, clamp) works on the raw offset instead.
1659 /// Identical to the offset on the uniform path, which never measures and so
1660 /// never has a pending correction. See the [module docs](self)' *Measured
1661 /// anchor correction* and *Overscroll and pull-to-refresh* sections; for
1662 /// where content is actually **painted** (which does layer overscroll on
1663 /// top), see [`ListViewWidget::painted_offset`].
1664 fn placement_offset(&self) -> f64 {
1665 if self.pending_correction == 0.0 {
1666 return self.offset;
1667 }
1668 (self.offset + self.pending_correction).clamp(0.0, self.max_offset())
1669 }
1670
1671 /// The content-space `y` the viewport's top edge is actually **painted**
1672 /// at this frame: [`ListViewWidget::placement_offset`] (always in range)
1673 /// plus the current [`ListViewWidget::overscroll`] displacement (`0.0`
1674 /// outside a drag past an edge). Read by
1675 /// [`ListViewWidget::sync_child_origins`] and [`Widget::semantics`]'s
1676 /// scroll position — the only two places the out-of-range number is ever
1677 /// allowed to show, so the content edge visually displaces while no row
1678 /// is ever asked to materialize outside `[0, item_count)`. See the
1679 /// [module docs](self)' *Overscroll and pull-to-refresh* section.
1680 ///
1681 /// The displacement is layered in only under
1682 /// [`OverscrollEffect::Translate`]: [`OverscrollEffect::Stretch`] paints
1683 /// the pull as a scale about the held edge instead (see
1684 /// [`Widget::paint`]) and [`OverscrollEffect::None`] paints it not at all,
1685 /// so under both the content — and the position semantics reports for it —
1686 /// stays exactly where the in-range windowing offset puts it. `overscroll`
1687 /// itself still evolves identically under every effect; only this read
1688 /// differs (`scroll.rs`'s *Overscroll visuals*).
1689 fn painted_offset(&self) -> f64 {
1690 match self.effect {
1691 OverscrollEffect::Translate => self.placement_offset() + self.overscroll,
1692 OverscrollEffect::Stretch | OverscrollEffect::None => self.placement_offset(),
1693 }
1694 }
1695
1696 /// Commit the pending measured correction into [`ListViewWidget::offset`] —
1697 /// the one place it is ever committed, from [`View::rebuild`] before the
1698 /// window is planned. Returns whether the offset actually moved (a
1699 /// correction the clamp swallowed whole reports `false`: nothing changed, so
1700 /// nothing is owed a layout pass).
1701 ///
1702 /// **Withheld while a fling is live**: the pump is advancing the offset at
1703 /// paint, and a correction landing on top of that can walk it backwards or
1704 /// onto a bound that ends the fling early, so the sum keeps accumulating and
1705 /// the first rebuild after the fling stops commits all of it (see the
1706 /// [module docs](self)' *Measured anchor correction* section).
1707 fn apply_pending_correction(&mut self) -> bool {
1708 if self.pending_correction == 0.0 || self.is_flinging() {
1709 return false;
1710 }
1711 let target = self.offset + self.pending_correction;
1712 self.pending_correction = 0.0;
1713 let before = self.offset;
1714 self.set_offset(target);
1715 self.offset != before
1716 }
1717
1718 /// The whole content's extent: `item_count * item_extent` exactly on the
1719 /// uniform path, or `Σ measured + estimate × unmeasured` in variable-extent
1720 /// mode (O(1) — the sum is maintained as rows are measured). See the [module
1721 /// docs](self)' *Variable extents* section.
1722 fn content_extent(&self) -> f64 {
1723 match self.variable_estimate() {
1724 Some(estimate) => {
1725 let unmeasured = self.item_count.saturating_sub(self.measured.len());
1726 self.measured_sum + estimate * unmeasured as f64
1727 }
1728 None => self.item_count as f64 * self.item_extent,
1729 }
1730 }
1731
1732 /// Whether post-release motion is in flight — the legacy fling, or a
1733 /// physics-supplied [`Simulation`] the generic driver is running (never
1734 /// both, and never either one under [`RubberBand`](crate::RubberBand)'s
1735 /// legacy-only path).
1736 pub fn is_flinging(&self) -> bool {
1737 self.fling.is_some() || self.ballistic.is_some()
1738 }
1739
1740 /// This surface's extent/position snapshot for the physics, reading
1741 /// `pixels` from a caller-supplied position rather than a field: the drag
1742 /// path asks about the *raw* (un-resisted) drag position clamped into
1743 /// range, so resistance is derived from the accumulator instead of
1744 /// compounding across moves. `max_scroll_extent` is
1745 /// [`ListViewWidget::max_offset`], read fresh, so a still-converging
1746 /// variable-extent content extent is always what the physics sees.
1747 fn metrics_at(&self, pixels: f64) -> ScrollMetrics {
1748 ScrollMetrics {
1749 pixels,
1750 min_scroll_extent: 0.0,
1751 max_scroll_extent: self.max_offset(),
1752 viewport_dimension: self.viewport.height,
1753 device_pixel_ratio: METRICS_FALLBACK_DPR,
1754 }
1755 }
1756
1757 /// This surface's extent/position snapshot at the scroll position a physics
1758 /// reasons about: the **raw** windowing offset plus any live displacement,
1759 /// which is the same space [`ListViewWidget::max_offset`] and
1760 /// [`ListViewWidget::drag_raw`] live in. Deliberately not
1761 /// [`ListViewWidget::painted_offset`], which also folds in an uncommitted
1762 /// [`ListViewWidget::pending_correction`] — a physics answer fed back into
1763 /// raw space would double-count it, exactly as it would at drag takeover.
1764 fn metrics(&self) -> ScrollMetrics {
1765 self.metrics_at(self.offset + self.overscroll)
1766 }
1767
1768 /// The velocity (px/s of offset) of whatever post-release motion is live
1769 /// right now — the legacy fling's own, or a running simulation's at the
1770 /// last painted frame — and `0.0` when the list is at rest.
1771 fn live_velocity(&self) -> f64 {
1772 if let Some(v) = self.fling {
1773 return v;
1774 }
1775 match self.ballistic.as_ref() {
1776 Some(state) => state.sim.dx(state.elapsed_secs(self.last_frame_time)),
1777 None => 0.0,
1778 }
1779 }
1780
1781 /// A new fling's starting velocity: the `release` velocity plus whatever
1782 /// [`ScrollPhysics::carried_momentum`] carries over from the motion this
1783 /// gesture's `Down` interrupted (`0.0` under
1784 /// [`RubberBand`](crate::RubberBand), leaving `release` untouched).
1785 /// Mirrors `ScrollWidget::fling_start_velocity`, gate included: momentum
1786 /// is carried only onto a release that plainly continues the interrupted
1787 /// motion — same sign, and faster than
1788 /// [`MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR`] of the physics' own
1789 /// **mapped** share of the interrupted velocity —
1790 /// `physics.carried_momentum(carried)`, the exact value the release is
1791 /// about to add, not the raw interrupted speed (see that method's doc
1792 /// for why this matters) — since the carried term is comparable in
1793 /// magnitude to an ordinary release and would otherwise cancel out or
1794 /// reverse a flick back the other way. Flutter's third guard, dropping
1795 /// the carried velocity when the finger held still before letting go, is
1796 /// an accepted gap here too (see that method for the full contract).
1797 fn fling_start_velocity(&self, release: f64) -> f64 {
1798 let mapped = self.physics.carried_momentum(self.carried_velocity);
1799 let continues_it = release.signum() == mapped.signum()
1800 && release.abs() > MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped.abs();
1801 if continues_it {
1802 release + mapped
1803 } else {
1804 release
1805 }
1806 }
1807
1808 /// Ask the installed physics for post-release motion, bounded by its own
1809 /// [`ScrollPhysics::min_fling_velocity`]/[`ScrollPhysics::max_fling_velocity`]
1810 /// — **the generic driver's bounds only**; the legacy fling below keeps its
1811 /// pinned `FLING_STOP` threshold and no upper clamp. Mirrors
1812 /// `ScrollWidget::release_simulation`.
1813 fn release_simulation(&self) -> Option<Box<dyn Simulation>> {
1814 // The offset moves opposite the finger, like every other release path.
1815 let released = self.fling_start_velocity(-self.tracker.velocity());
1816 let max = self.physics.max_fling_velocity();
1817 let velocity = if released.abs() < self.physics.min_fling_velocity() {
1818 0.0
1819 } else {
1820 released.clamp(-max, max)
1821 };
1822 self.physics
1823 .create_ballistic_simulation(&self.metrics(), velocity)
1824 }
1825
1826 fn event_time_ms(&self) -> f64 {
1827 self.last_frame_time.as_secs_f64() * 1000.0
1828 }
1829
1830 fn set_offset(&mut self, value: f64) {
1831 self.offset = value.clamp(0.0, self.max_offset());
1832 }
1833
1834 fn clamp_offset(&mut self) {
1835 self.set_offset(self.offset);
1836 }
1837
1838 /// Advance the drag by `delta` px of raw finger travel in offset space
1839 /// (positive = the content scrolls down) and re-split the result across the
1840 /// windowing [`ListViewWidget::offset`], the
1841 /// [`ListViewWidget::overscroll`] displacement and
1842 /// [`ListViewWidget::edge_pull`], mirroring
1843 /// [`crate::ScrollWidget::apply_drag_offset`] (see `scroll.rs`'s *Drag
1844 /// convention* for why the physics is handed a per-move delta at the live
1845 /// position rather than a whole excursion from a clamped base).
1846 ///
1847 /// The one thing local to this widget: because its windowing offset may
1848 /// never leave range (the [module docs](self)' *Overscroll and
1849 /// pull-to-refresh* section), the position the physics allows is clamped
1850 /// into `offset` and whatever is left over lands in `overscroll` for paint
1851 /// alone. `max_offset` is read fresh every call, so a variable-extent
1852 /// list's still-converging content extent is always what that split runs
1853 /// against.
1854 fn apply_drag_offset(&mut self, delta: f64) {
1855 let metrics = self.metrics();
1856 let mapped = self.physics.apply_physics_to_user_offset(&metrics, delta);
1857 self.drag_position += mapped;
1858 let rejected = self
1859 .physics
1860 .apply_boundary_conditions(&metrics, self.drag_position);
1861 let allowed = self.drag_position - rejected;
1862 self.offset = allowed.clamp(0.0, self.max_offset());
1863 self.overscroll = allowed - self.offset;
1864 self.edge_pull = self.overscroll + rejected;
1865 }
1866
1867 /// Advance a release-settle by `dt_ms`, easing
1868 /// [`ListViewWidget::overscroll`] back to `0.0` and returning whether it
1869 /// is still animating. Pure and deterministic (mirrors
1870 /// [`crate::ScrollWidget::settle_tick`]); the paint pump and the tests
1871 /// both drive it. Only ever eases `overscroll` — the windowing
1872 /// [`ListViewWidget::offset`] already sits at the exact edge (`0.0` or
1873 /// `max_offset`) the moment a drag overscrolls, so it needs no motion of
1874 /// its own here.
1875 pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
1876 if !self.settling {
1877 return false;
1878 }
1879 // `edge_pull`'s boundary-rejected half (always `0.0` under
1880 // `RubberBand`, where the pull *is* the displacement) has no
1881 // displacement to ride back, so it decays on the same curve of its own
1882 // — otherwise a clamping physics' stretch would snap off at release.
1883 let rejected = self.edge_pull - self.overscroll;
1884 if self.overscroll.abs() <= SETTLE_STOP_PX && rejected.abs() <= SETTLE_STOP_PX {
1885 self.overscroll = 0.0;
1886 self.edge_pull = 0.0;
1887 self.settling = false;
1888 self.sync_child_origins();
1889 return false;
1890 }
1891 let retained = SETTLE_DECAY.powf(dt_ms);
1892 self.overscroll *= retained;
1893 self.edge_pull = self.overscroll + rejected * retained;
1894 self.sync_child_origins();
1895 true
1896 }
1897
1898 /// The `[start, end)` item range that should be materialized for the current
1899 /// offset + cached viewport, clamped to `[0, item_count]`, plus the
1900 /// content-space `y` of `start`. With a zero viewport (first build) this is
1901 /// the conservative initial window.
1902 ///
1903 /// Variable-extent mode takes the prefix-walk path instead; the uniform
1904 /// closed form below is unchanged.
1905 fn desired_window(&self) -> WindowPlan {
1906 if let Some(estimate) = self.variable_estimate() {
1907 return if self.item_count == 0 {
1908 WindowPlan::EMPTY
1909 } else {
1910 self.desired_window_variable(estimate)
1911 };
1912 }
1913 if self.item_count == 0 || self.item_extent <= 0.0 {
1914 return WindowPlan::EMPTY;
1915 }
1916 let vh = self.viewport.height;
1917 let first = (self.offset / self.item_extent).floor() as isize - BUFFER;
1918 let last = ((self.offset + vh) / self.item_extent).ceil() as isize + BUFFER;
1919 let start = first.max(0) as usize;
1920 let end = (last.max(0) as usize).min(self.item_count);
1921 let start = start.min(end);
1922 WindowPlan {
1923 start,
1924 end,
1925 y_start: start as f64 * self.item_extent,
1926 }
1927 }
1928
1929 /// Whether the materialized window fully covers `[start, end)`.
1930 fn window_covers(&self, start: usize, end: usize) -> bool {
1931 if start >= end {
1932 return true;
1933 }
1934 match (self.keys.first(), self.keys.last()) {
1935 (Some(&f), Some(&l)) => f <= start && l + 1 >= end,
1936 _ => false,
1937 }
1938 }
1939
1940 /// Place each materialized row at its content position minus the
1941 /// **painted** offset (row `i` occupies `y ∈ [i*extent, (i+1)*extent)` in
1942 /// content space) — [`ListViewWidget::painted_offset`], not the windowing
1943 /// one, so an overscrolled drag's out-of-range displacement shows on
1944 /// screen even though [`ListViewWidget::offset`] itself never leaves
1945 /// `[0, max_offset]`.
1946 ///
1947 /// Variable-extent mode places each row at its own retained content `y`
1948 /// instead — the same rule, over the prefix walk's positions rather than the
1949 /// closed form.
1950 fn sync_child_origins(&mut self) {
1951 let painted = self.painted_offset();
1952 if self.is_variable() {
1953 for (y, pod) in self.slot_y.iter().zip(self.children.iter_mut()) {
1954 pod.set_origin(Point::new(0.0, *y - painted));
1955 }
1956 return;
1957 }
1958 let extent = self.item_extent;
1959 for (index, pod) in self.keys.iter().zip(self.children.iter_mut()) {
1960 pod.set_origin(Point::new(0.0, *index as f64 * extent - painted));
1961 }
1962 }
1963
1964 // --- Variable extents (keyed lists only). See the module docs' *Variable
1965 // extents* section; every method here is inert on the uniform path. ---
1966
1967 /// The estimate this widget is running variable-extent mode with, or `None`
1968 /// for the closed-form uniform path. Both halves are required: identity to
1969 /// cache a measurement under, and an estimate for the rows without one.
1970 fn variable_estimate(&self) -> Option<f64> {
1971 match (self.key_of.as_ref(), self.estimated_extent) {
1972 (Some(_), Some(estimate)) => Some(estimate),
1973 _ => None,
1974 }
1975 }
1976
1977 /// Whether variable-extent mode is active (see
1978 /// [`ListViewWidget::variable_estimate`]).
1979 fn is_variable(&self) -> bool {
1980 self.variable_estimate().is_some()
1981 }
1982
1983 /// The extent one *unmeasured* row contributes: the estimate in
1984 /// variable-extent mode, the uniform `item_extent` otherwise. The scroll
1985 /// anchoring correction steps by this (a prepended row is by definition
1986 /// unmeasured).
1987 fn unmeasured_extent(&self) -> f64 {
1988 self.variable_estimate().unwrap_or(self.item_extent)
1989 }
1990
1991 /// The extent item `index` contributes: its cached measurement if it has
1992 /// one, else the estimate. One `key_of` call, no builder call.
1993 fn extent_at(&self, index: usize, estimate: f64) -> f64 {
1994 let Some(key_of) = self.key_of.as_ref() else {
1995 return estimate;
1996 };
1997 if index >= self.item_count {
1998 return estimate;
1999 }
2000 self.measured
2001 .get(&key_of(index))
2002 .map(|m| m.extent)
2003 .unwrap_or(estimate)
2004 }
2005
2006 /// Walk the retained prefix anchor to the current offset, returning the
2007 /// first item the offset falls inside and that item's content-space top.
2008 ///
2009 /// O(step): a scroll/fling frame moves a few items; a jump farther than
2010 /// [`MAX_PREFIX_STEP`] items resolves its bulk in closed form against the
2011 /// estimate first. Reaching item 0 re-pins `y = 0` exactly, erasing any
2012 /// floating-point drift the walk accumulated.
2013 fn walk_to_offset(&self, estimate: f64) -> (usize, f64) {
2014 let count = self.item_count;
2015 debug_assert!(count > 0, "walk_to_offset needs a non-empty list");
2016 let target = self.placement_offset();
2017 let mut index = self.anchor_index.min(count - 1);
2018 let mut y = self.anchor_y;
2019
2020 let gap = target - y;
2021 if gap.abs() > estimate * MAX_PREFIX_STEP as f64 {
2022 // A data reset or a programmatic jump, never a scroll: cover the
2023 // bulk against the estimate so the walk below stays bounded.
2024 let jump = (gap / estimate).trunc();
2025 let landed = (index as f64 + jump).clamp(0.0, (count - 1) as f64);
2026 y += (landed - index as f64) * estimate;
2027 index = landed as usize;
2028 }
2029
2030 let mut steps = 0usize;
2031 while y > target && index > 0 && steps < MAX_PREFIX_STEP {
2032 index -= 1;
2033 y -= self.extent_at(index, estimate);
2034 steps += 1;
2035 }
2036 while index + 1 < count && steps < MAX_PREFIX_STEP {
2037 let extent = self.extent_at(index, estimate);
2038 if y + extent <= target {
2039 y += extent;
2040 index += 1;
2041 steps += 1;
2042 } else {
2043 break;
2044 }
2045 }
2046 if index == 0 {
2047 y = 0.0;
2048 }
2049 (index, y)
2050 }
2051
2052 /// The variable-extent counterpart of [`ListViewWidget::desired_window`]:
2053 /// the item the offset falls inside, widened by [`BUFFER`] above and by
2054 /// whatever it takes to cover the viewport (plus [`BUFFER`]) below.
2055 /// O(window + step).
2056 fn desired_window_variable(&self, estimate: f64) -> WindowPlan {
2057 let count = self.item_count;
2058 let (first_visible, y_visible) = self.walk_to_offset(estimate);
2059
2060 // Leading buffer: back up from the first visible item, accumulating the
2061 // same extents in reverse.
2062 let mut start = first_visible;
2063 let mut y_start = y_visible;
2064 for _ in 0..BUFFER {
2065 if start == 0 {
2066 break;
2067 }
2068 start -= 1;
2069 y_start -= self.extent_at(start, estimate);
2070 }
2071 if start == 0 {
2072 y_start = 0.0;
2073 }
2074
2075 // Forward to the viewport's bottom edge, then the trailing buffer.
2076 let bottom = self.placement_offset() + self.viewport.height;
2077 let mut end = first_visible + 1;
2078 let mut y_end = y_visible + self.extent_at(first_visible, estimate);
2079 while end < count && y_end < bottom {
2080 y_end += self.extent_at(end, estimate);
2081 end += 1;
2082 }
2083 let end = end.saturating_add(BUFFER as usize).min(count);
2084 WindowPlan {
2085 start,
2086 end: end.max(start),
2087 y_start,
2088 }
2089 }
2090
2091 /// Re-pin the prefix anchor to the window `plan` this frame materialized and
2092 /// refill the per-slot content positions from the cached extents. Layout
2093 /// refines those positions from the rows' actual measurements.
2094 fn set_window_geometry(&mut self, plan: WindowPlan, estimate: f64) {
2095 self.anchor_index = plan.start;
2096 self.anchor_y = if plan.start == 0 { 0.0 } else { plan.y_start };
2097 let keys = std::mem::take(&mut self.keys);
2098 let mut ys = std::mem::take(&mut self.slot_y);
2099 ys.clear();
2100 let mut y = self.anchor_y;
2101 for &index in &keys {
2102 ys.push(y);
2103 y += self.extent_at(index, estimate);
2104 }
2105 self.slot_y = ys;
2106 self.keys = keys;
2107 }
2108
2109 /// Cache one row's laid-out extent under its stable key, keeping the running
2110 /// sum exact.
2111 fn record_measurement(&mut self, key: ChildKey, index: usize, extent: f64) {
2112 match self.measured.insert(key, Measured { index, extent }) {
2113 Some(previous) => self.measured_sum += extent - previous.extent,
2114 None => self.measured_sum += extent,
2115 }
2116 }
2117
2118 /// Drop one row's measurement (its key left the data).
2119 fn forget_measured(&mut self, key: &ChildKey) {
2120 if let Some(previous) = self.measured.remove(key) {
2121 self.measured_sum -= previous.extent;
2122 }
2123 }
2124
2125 /// Drop every measurement — a full replace, where no previous key survives.
2126 /// Any uncommitted correction goes with them: it describes rows this list no
2127 /// longer holds, so committing it into new data would be a guess.
2128 fn clear_measured(&mut self) {
2129 self.measured.clear();
2130 self.measured_sum = 0.0;
2131 self.pending_correction = 0.0;
2132 }
2133
2134 /// Drop measurements the current keying provably cannot produce any more:
2135 /// entries recorded at an index past the (just shrunk) end. A scan of the
2136 /// cache, run only on a frame whose `item_count` shrank. A stale recorded
2137 /// index can evict a still-live row early; it is re-measured on its next
2138 /// visit, which is the conservative direction.
2139 fn evict_measured_stale_indices(&mut self) {
2140 if self.measured.is_empty() {
2141 return;
2142 }
2143 let count = self.item_count;
2144 let mut dropped = 0.0;
2145 self.measured.retain(|_, entry| {
2146 let live = entry.index < count;
2147 if !live {
2148 dropped += entry.extent;
2149 }
2150 live
2151 });
2152 self.measured_sum -= dropped;
2153 }
2154
2155 /// Drop every variable-extent artifact: the measured cache, the per-slot
2156 /// identities and positions, and the prefix anchor. Run when a list leaves
2157 /// variable-extent mode, so nothing stale can be read back if it re-enters.
2158 fn reset_variable_state(&mut self) {
2159 self.clear_measured();
2160 self.slot_keys.clear();
2161 self.slot_y.clear();
2162 self.anchor_index = 0;
2163 self.anchor_y = 0.0;
2164 }
2165
2166 /// Drop every keyed-identity artifact: the `key -> index` map plus all
2167 /// variable-extent bookkeeping (which is cached under those identities). Run
2168 /// when a positional frame rebuilds a list a keyed frame materialized (see
2169 /// [`View::rebuild`]).
2170 fn reset_keyed_state(&mut self) {
2171 self.key_index.clear();
2172 self.reset_variable_state();
2173 }
2174
2175 /// Apply a confirmed scroll-anchor shift of `items` rows: the offset absorbs
2176 /// it, and (in variable-extent mode) so does the prefix anchor, which names
2177 /// an item index that just moved by the same amount.
2178 fn apply_anchor_shift(&mut self, items: isize) {
2179 let step = self.unmeasured_extent();
2180 self.set_offset(self.offset + items as f64 * step);
2181 if self.is_variable() {
2182 let index = (self.anchor_index as isize + items).max(0) as usize;
2183 self.anchor_y = if index == 0 {
2184 0.0
2185 } else {
2186 (self.anchor_y + items as f64 * step).max(0.0)
2187 };
2188 self.anchor_index = index;
2189 }
2190 }
2191
2192 /// Lay the materialized rows out under a bounded width and **unbounded**
2193 /// height (the [`crate::ScrollView`] precedent), caching each row's chosen
2194 /// height under its key and re-deriving the window's content positions from
2195 /// those heights. Measures only pods that already exist — no builder call,
2196 /// no materialization (the wake hazard the module docs call out).
2197 ///
2198 /// It also records — never applies — the frame's anchor correction: two
2199 /// running positions are carried, one over the heights the rows actually
2200 /// chose and one over the extents this frame's window was *planned*
2201 /// against, and every row whose planned span lies wholly above the viewport
2202 /// top contributes its `measured − assumed` delta to
2203 /// [`ListViewWidget::pending_correction`]. Those are exactly the rows whose
2204 /// mis-estimate moves the anchor row, and the offset owes their sum back.
2205 /// The offset itself is never written here beyond the pre-existing range
2206 /// clamp (the viewport is only known at layout) — the correction is
2207 /// committed by the next [`View::rebuild`]. See the [module docs](self)'
2208 /// *Measured anchor correction* section.
2209 fn layout_variable(&mut self, ctx: &mut LayoutCtx, vw: f64, estimate: f64) {
2210 let child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
2211 // The viewport's top edge in content space, in the geometry this frame's
2212 // window was planned against — the line that decides which rows sit
2213 // above the anchor.
2214 let top = self.placement_offset();
2215 let mut children = std::mem::take(&mut self.children);
2216 let mut ys = std::mem::take(&mut self.slot_y);
2217 ys.clear();
2218 let mut y = self.anchor_y;
2219 let mut y_assumed = self.anchor_y;
2220 let mut correction = 0.0;
2221 for (slot, pod) in children.iter_mut().enumerate() {
2222 let key = self.slot_keys.get(slot).copied();
2223 let index = self.keys.get(slot).copied();
2224 // Read the assumed extent *before* this row's own measurement is
2225 // recorded below, or the delta would always be zero.
2226 let assumed = index.map_or(estimate, |index| self.extent_at(index, estimate));
2227 let size = pod.layout_child(ctx, &child_bc);
2228 ys.push(y);
2229 if y_assumed + assumed <= top {
2230 correction += size.height - assumed;
2231 }
2232 y += size.height;
2233 y_assumed += assumed;
2234 if let (Some(key), Some(index)) = (key, index) {
2235 self.record_measurement(key, index, size.height);
2236 }
2237 }
2238 self.slot_y = ys;
2239 self.children = children;
2240 // Accumulate, never assign: a correction withheld across a live fling is
2241 // still owed. Re-measuring an unchanged row contributes a zero delta, so
2242 // this can never double-count one.
2243 self.pending_correction += correction;
2244 // The measurements just revised the content extent (the estimate still
2245 // governs everything outside the window), so re-clamp before the caller
2246 // syncs origins.
2247 self.clamp_offset();
2248 }
2249
2250 /// Advance an in-flight fling by `dt_ms`, returning whether it is still
2251 /// animating. Pure and deterministic — the paint-time pump and the tests
2252 /// both drive it (mirrors [`crate::ScrollWidget::tick`]).
2253 pub fn tick(&mut self, dt_ms: f64) -> bool {
2254 let Some(v) = self.fling else {
2255 return false;
2256 };
2257 self.set_offset(self.offset + fling_displacement(v, dt_ms));
2258 self.sync_child_origins();
2259 let next_v = fling_decay(v, dt_ms);
2260 let at_bound = self.offset <= 0.0 || self.offset >= self.max_offset();
2261 if next_v.abs() < FLING_STOP || at_bound {
2262 self.fling = None;
2263 false
2264 } else {
2265 self.fling = Some(next_v);
2266 true
2267 }
2268 }
2269
2270 /// Advance a physics-supplied [`Simulation`] to frame time `now`, the
2271 /// windowing/displacement split preserved: the position it reports, minus
2272 /// whatever [`ScrollPhysics::apply_boundary_conditions`] rejects of it,
2273 /// clamped into [`ListViewWidget::offset`] with the remainder left in
2274 /// [`ListViewWidget::overscroll`] where paint (never the item-index math)
2275 /// reads it. Subtracting the rejection is what keeps the driver honest for
2276 /// **any** physics — a clamping one can never displace even if its
2277 /// simulation overshoots, while a bouncing one (rejecting nothing) is free
2278 /// to run past the edge and back. Mirrors
2279 /// [`crate::ScrollWidget::drive_ballistic`], including its early stop for a
2280 /// curve that is [pinned
2281 /// outward](ListViewWidget::ballistic_is_pinned_outward) and the handoff of
2282 /// any leftover pull to the settle
2283 /// ([`ListViewWidget::settle_ballistic_residual`]).
2284 fn drive_ballistic(&mut self, now: FrameTime) {
2285 let Some((proposed, velocity, done)) = self.ballistic.as_ref().map(|state| {
2286 let t = state.elapsed_secs(now);
2287 (state.sim.x(t), state.sim.dx(t), state.sim.is_done(t))
2288 }) else {
2289 return;
2290 };
2291 let rejected = self
2292 .physics
2293 .apply_boundary_conditions(&self.metrics(), proposed);
2294 let allowed = proposed - rejected;
2295 self.offset = allowed.clamp(0.0, self.max_offset());
2296 self.overscroll = allowed - self.offset;
2297 self.edge_pull = self.overscroll + rejected;
2298 self.sync_child_origins();
2299 if done || self.ballistic_is_pinned_outward(proposed, rejected, velocity) {
2300 self.stop_ballistic();
2301 self.settle_ballistic_residual();
2302 }
2303 }
2304
2305 /// Whether the running simulation can no longer move anything on screen —
2306 /// its proposal is outside the range, the physics rejected **all** of that
2307 /// excess, and the curve is still travelling further out. See
2308 /// [`crate::ScrollWidget::ballistic_is_pinned_outward`] for the full
2309 /// contract and why the test is deliberately conservative.
2310 ///
2311 /// One caveat local to this widget: `max_offset` is a *converging* estimate
2312 /// in variable-extent mode, so a fling stopped here against an
2313 /// under-estimated end stays stopped rather than resuming when later
2314 /// measurements push the end out. Accepted — the offset is already pinned
2315 /// at that estimated end for as long as the estimate holds, so the
2316 /// difference is a fling that ends where the surface had already stopped
2317 /// moving.
2318 fn ballistic_is_pinned_outward(&self, proposed: f64, rejected: f64, velocity: f64) -> bool {
2319 let excess = proposed - proposed.clamp(0.0, self.max_offset());
2320 excess != 0.0 && rejected == excess && velocity * excess > 0.0
2321 }
2322
2323 /// Hand a residual [`ListViewWidget::edge_pull`] left behind by a finished
2324 /// ballistic to the release-settle, so it decays on [`SETTLE_DECAY`]
2325 /// exactly as a drag release's pull does instead of standing on screen
2326 /// until the next `Down`/wheel/`Cancel`. Mirrors
2327 /// [`crate::ScrollWidget::settle_ballistic_residual`], guard and all.
2328 fn settle_ballistic_residual(&mut self) {
2329 if self.edge_pull.abs() > SETTLE_STOP_PX {
2330 self.settling = true;
2331 }
2332 }
2333
2334 /// Advance the ballistic simulation, the legacy fling, *or* the
2335 /// release-settle by the delta since the last paint, and signal
2336 /// [`PaintCtx::request_frame`] while any is still
2337 /// running (mirrors [`crate::ScrollWidget::pump_fling`]). The continuation
2338 /// frame is what re-runs the shell's rebuild → the window re-materializes
2339 /// as the fling carries on; a settle never changes the window (it only
2340 /// eases [`ListViewWidget::overscroll`], which windowing never reads), so
2341 /// it needs the request purely to keep painting the animation.
2342 ///
2343 /// The three are mutually exclusive by construction — a release picks one,
2344 /// after tearing every one of them down through
2345 /// [`ListViewWidget::stop_ballistic`], and a controller command ends the
2346 /// drag a release would come from — and under [`RubberBand`](crate::RubberBand) the simulation arm is never
2347 /// taken at all.
2348 fn pump_fling(&mut self, ctx: &mut PaintCtx) {
2349 if self.fling.is_none() && !self.settling && self.ballistic.is_none() {
2350 self.last_anim = None;
2351 return;
2352 }
2353 let now = ctx.frame_time();
2354 let dt = match self.last_anim {
2355 Some(t) => now.saturating_sub(t).as_secs_f64() * 1000.0,
2356 None => 0.0,
2357 };
2358 self.last_anim = Some(now);
2359 // A simulation measures time from its own start, so the first pump
2360 // after the release seeds it — the same zero-delta seeding frame
2361 // `last_anim` takes, so neither clock ever jumps on frame one.
2362 if let Some(state) = self.ballistic.as_mut() {
2363 state.start.get_or_insert(now);
2364 }
2365 if dt > 0.0 {
2366 if self.ballistic.is_some() {
2367 self.drive_ballistic(now);
2368 } else if self.fling.is_some() {
2369 self.tick(dt);
2370 } else {
2371 self.settle_tick(dt);
2372 }
2373 // The fling moved the offset with no `EventCtx` in scope; if it
2374 // crossed the near-start/near-end edge, record the fire for the
2375 // next event. (A settle never moves `placement_offset()`, so this
2376 // is a no-op there, not a special case worth branching out.)
2377 if self.evaluate_near_start() {
2378 self.pending_near_start = true;
2379 }
2380 if self.evaluate_near_end() {
2381 self.pending_near_end = true;
2382 }
2383 }
2384 if self.fling.is_some() || self.settling || self.ballistic.is_some() {
2385 ctx.request_frame();
2386 }
2387 }
2388
2389 // --- Programmatic scrolling (the attached `ScrollController`). See the
2390 // module docs' *Programmatic scrolling* section. ---
2391
2392 /// End any live [`ListViewWidget::ballistic`] simulation — a release
2393 /// fling/spring, or a controller tween — telling the attached controller
2394 /// the animation stopped if it was one. The single tear-down path every
2395 /// site that ends ballistic motion takes (`Down`, wheel, `Cancel`, a
2396 /// controller jump/animate, and [`ListViewWidget::drive_ballistic`]'s own
2397 /// completion), **and every start goes through it too**: the `Up` release
2398 /// clears the fling and settle and calls this before it starts a
2399 /// simulation, settle or fling of its own, and a controller tween is only
2400 /// installed by [`ListViewWidget::apply_animate_to`] after the same
2401 /// tear-down — so fling, settle and simulation are never live at once and
2402 /// [`ScrollController::is_animating`] cannot outlive the tween it
2403 /// reports *while this widget stays current on the handle*. A tween
2404 /// whose surface is dropped, taken over, or rebound to a different
2405 /// handle mid-flight never calls this — that case is reconciled on the
2406 /// handle's own side instead ([`ScrollController::bind`] and
2407 /// `impl Drop for ScrollBinding` in `scroll_controller.rs`), since the
2408 /// tween keeps running on `self.ballistic` regardless of which binding,
2409 /// if any, is still current to report it. Mirrors
2410 /// `ScrollWidget::stop_ballistic`.
2411 fn stop_ballistic(&mut self) {
2412 self.ballistic = None;
2413 if std::mem::take(&mut self.controller_animating)
2414 && let Some(binding) = self.controller.as_ref()
2415 {
2416 binding.set_animating(false);
2417 }
2418 }
2419
2420 /// End a live drag because a controller command just took the position
2421 /// over — a programmatic command replaces the user's drag rather than
2422 /// running alongside it. Resets exactly the per-gesture state the `Down`
2423 /// arm resets (the drag/armed flags, the Down-time claim snapshot, the
2424 /// sticky defer decision and the live multi-contact veto cell), so the
2425 /// rest of the gesture's `Move`s take the unarmed row/hover path and its
2426 /// `Up` the non-scrolling row-forward branch. Only an *established* drag
2427 /// ends here: a gesture armed but still inside [`TOUCH_SLOP`] is left as
2428 /// it is (a pre-slop finger writes no offset, and the row under it holds
2429 /// an uncancelled `Down` whose slop takeover and `Cancel` must still
2430 /// happen), so a no-op when nothing is scrolling. Pointer capture is left
2431 /// alone (no [`EventCtx`] in layout, paint or rebuild; the shell clears
2432 /// it on the physical `Up`/`Cancel`), and the caller re-seeds
2433 /// [`ListViewWidget::drag_position`] from the position it applies.
2434 /// Mirrors `ScrollWidget::end_live_drag`.
2435 fn end_live_drag(&mut self) {
2436 if !self.scrolling {
2437 return;
2438 }
2439 self.scrolling = false;
2440 self.down_active = false;
2441 self.inner_at_down = InnerScrollState::default();
2442 self.deferring = false;
2443 self.live_veto = Rc::new(Cell::new(false));
2444 }
2445
2446 /// Reconcile the attached [`ScrollController`] with the one a view names
2447 /// (see [`ListView::controller`]'s build/rebuild semantics), returning
2448 /// whether the binding changed. Every fresh [`ScrollController::bind`]
2449 /// resets the handle's `is_animating` to `false`; a tween that is
2450 /// genuinely still running (`self.controller_animating`) belongs to this
2451 /// widget, not the handle, so it is republished onto the new binding
2452 /// right after bind() rather than stopped — the tween keeps running
2453 /// either way, only the handle reporting it changes. Mirrors
2454 /// `ScrollWidget::attach_controller`.
2455 fn attach_controller(&mut self, requested: Option<&ScrollController>) -> bool {
2456 match (requested, self.controller.as_ref()) {
2457 (None, None) => false,
2458 (None, Some(_)) => {
2459 self.controller = None;
2460 true
2461 }
2462 (Some(handle), Some(binding)) if binding.controller().same(handle) => {
2463 if binding.is_current() || handle.is_attached() {
2464 false
2465 } else {
2466 let binding = handle.bind();
2467 binding.set_animating(self.controller_animating);
2468 self.controller = Some(binding);
2469 true
2470 }
2471 }
2472 (Some(handle), _) => {
2473 let binding = handle.bind();
2474 binding.set_animating(self.controller_animating);
2475 self.controller = Some(binding);
2476 true
2477 }
2478 }
2479 }
2480
2481 /// Apply every command queued on the attached [`ScrollController`], in
2482 /// recording order — once this surface has laid out (a command queued
2483 /// earlier waits for the first measured viewport) and only while it still
2484 /// holds the handle. Returns whether any command was drained.
2485 fn drain_controller(&mut self) -> bool {
2486 if !self.laid_out {
2487 return false;
2488 }
2489 let commands = match self.controller.as_ref() {
2490 Some(binding) => binding.take_all_commands(),
2491 None => return false,
2492 };
2493 let drained = !commands.is_empty();
2494 for command in commands {
2495 match command {
2496 ControllerCommand::Offset(command) => {
2497 // A later offset command replaces a settling item request
2498 // outright, like it replaces a tween.
2499 self.item_target = None;
2500 match command {
2501 ScrollCommand::JumpTo(target) => self.apply_jump(target),
2502 ScrollCommand::AnimateTo(target, options) => {
2503 self.apply_animate_to(target, options, self.motion.reduce_motion);
2504 }
2505 }
2506 }
2507 ControllerCommand::ScrollToItem(request) => self.apply_scroll_to_item(request),
2508 }
2509 }
2510 drained
2511 }
2512
2513 /// Move straight to the content-space offset `target`, clamped to
2514 /// `[0, max_offset]`, ending any fling, release-settle, ballistic
2515 /// simulation or overscroll in flight. Any uncommitted measured correction
2516 /// is dropped rather than committed — `target` already names where the
2517 /// content should sit, so the placement becomes exactly it. A live drag
2518 /// ends here ([`ListViewWidget::end_live_drag`]). `NaN` is ignored.
2519 /// Mirrors `ScrollWidget::apply_jump`; a near-start/near-end edge the jump
2520 /// crosses is recorded for the next event, like a fling frame's.
2521 fn apply_jump(&mut self, target: f64) {
2522 if target.is_nan() {
2523 return;
2524 }
2525 self.end_live_drag();
2526 self.fling = None;
2527 self.stop_ballistic();
2528 self.settling = false;
2529 self.overscroll = 0.0;
2530 self.edge_pull = 0.0;
2531 self.last_anim = None;
2532 self.pending_correction = 0.0;
2533 self.set_offset(target);
2534 self.drag_position = self.offset;
2535 self.sync_child_origins();
2536 if self.evaluate_near_start() {
2537 self.pending_near_start = true;
2538 self.jump_notify_owed = true;
2539 }
2540 if self.evaluate_near_end() {
2541 self.pending_near_end = true;
2542 self.jump_notify_owed = true;
2543 }
2544 }
2545
2546 /// Start (or replace) a controller-driven tween to the content-space
2547 /// offset `target`, clamped to `[0, max_offset]`, ending any fling,
2548 /// release-settle or ballistic simulation first. Runs through a
2549 /// [`TweenSimulation`] in [`ListViewWidget::ballistic`] — the field and
2550 /// pump ([`ListViewWidget::drive_ballistic`]) a release fling runs
2551 /// through. An uncommitted measured correction is committed first (the
2552 /// placement does not move), so the tween starts from where the content
2553 /// actually sits. `reduce_motion` (or a non-positive/non-finite
2554 /// `duration_ms`) collapses this to [`ListViewWidget::apply_jump`]. A
2555 /// live drag ends here ([`ListViewWidget::end_live_drag`]). `NaN` is
2556 /// ignored. Mirrors `ScrollWidget::apply_animate_to`.
2557 fn apply_animate_to(&mut self, target: f64, options: AnimateTo, reduce_motion: bool) {
2558 if target.is_nan() {
2559 return;
2560 }
2561 self.end_live_drag();
2562 self.fling = None;
2563 self.stop_ballistic();
2564 self.settling = false;
2565 self.overscroll = 0.0;
2566 self.edge_pull = 0.0;
2567 self.last_anim = None;
2568 if reduce_motion || !options.duration_ms.is_finite() || options.duration_ms <= 0.0 {
2569 self.apply_jump(target);
2570 return;
2571 }
2572 self.offset = self.placement_offset();
2573 self.pending_correction = 0.0;
2574 self.drag_position = self.offset;
2575 let clamped = target.clamp(0.0, self.max_offset());
2576 let sim = TweenSimulation::new(
2577 self.offset,
2578 clamped,
2579 options.duration_ms / 1000.0,
2580 options.curve,
2581 );
2582 self.ballistic = Some(BallisticState {
2583 sim: Box::new(sim),
2584 start: None,
2585 });
2586 self.controller_animating = true;
2587 if let Some(binding) = self.controller.as_ref() {
2588 binding.set_animating(true);
2589 }
2590 }
2591
2592 /// Apply a [`ScrollController::scroll_to_item`] request: resolve the key,
2593 /// decide `Nearest`, jump or tween to the aligned offset, and leave an
2594 /// [`ItemTarget`] for the settle loop ([`ListViewWidget::settle_item_target`]).
2595 /// An unknown key (or any key on a positional list) is a no-op with a
2596 /// debug-build log; `Nearest` on a fully visible row is a no-op too.
2597 fn apply_scroll_to_item(&mut self, request: ScrollToItem) {
2598 let Some(index) = self.resolve_key(request.key) else {
2599 unknown_item_key(&request, self.key_of.is_some());
2600 return;
2601 };
2602 let alignment = match request.alignment {
2603 ItemAlignment::Nearest => match self.nearest_alignment(index) {
2604 Some(alignment) => alignment,
2605 None => return,
2606 },
2607 alignment => alignment,
2608 };
2609 let target = self.aligned_offset(index, alignment);
2610 if request.animated {
2611 let motion = self.motion;
2612 self.apply_animate_to(target, motion.item_animation, motion.reduce_motion);
2613 } else {
2614 self.apply_jump(target);
2615 }
2616 self.item_target = Some(ItemTarget {
2617 key: request.key,
2618 alignment,
2619 frames_left: ITEM_SETTLE_MAX_FRAMES,
2620 });
2621 }
2622
2623 /// The item index currently carrying `key`, or `None` (an unknown key, or
2624 /// a positional list, which has no keys). The window's own `key -> index`
2625 /// map is tried first — verified against the current keying, since a
2626 /// rebuild-time drain runs before this frame's reconciliation refreshes
2627 /// it — and only a miss scans `key_of` over every item: O(item count),
2628 /// once per request or settle frame, never per frame otherwise.
2629 fn resolve_key(&self, key: ChildKey) -> Option<usize> {
2630 let key_of = self.key_of.as_ref()?;
2631 if let Some(&index) = self.key_index.get(&key)
2632 && index < self.item_count
2633 && key_of(index) == key
2634 {
2635 return Some(index);
2636 }
2637 (0..self.item_count).find(|&index| key_of(index) == key)
2638 }
2639
2640 /// Item `index`'s content-space top and extent: closed form on the
2641 /// uniform path; in variable-extent mode, walked from the prefix anchor
2642 /// over measured-or-estimated extents — the same walk, from the same
2643 /// anchor, that plans the window, so a jump to this top lands the window
2644 /// exactly on the row. O(distance from the anchor).
2645 fn item_span(&self, index: usize) -> (f64, f64) {
2646 let Some(estimate) = self.variable_estimate() else {
2647 return (index as f64 * self.item_extent, self.item_extent);
2648 };
2649 let anchor = self.anchor_index.min(self.item_count.saturating_sub(1));
2650 let mut y = if anchor == 0 { 0.0 } else { self.anchor_y };
2651 if index >= anchor {
2652 for i in anchor..index {
2653 y += self.extent_at(i, estimate);
2654 }
2655 } else {
2656 for i in index..anchor {
2657 y -= self.extent_at(i, estimate);
2658 }
2659 }
2660 let top = if index == 0 { 0.0 } else { y.max(0.0) };
2661 (top, self.extent_at(index, estimate))
2662 }
2663
2664 /// The offset that puts item `index` at `alignment` (never `Nearest`, which
2665 /// [`ListViewWidget::nearest_alignment`] resolves first), clamped to
2666 /// `[0, max_offset]`.
2667 fn aligned_offset(&self, index: usize, alignment: ItemAlignment) -> f64 {
2668 let (top, extent) = self.item_span(index);
2669 let viewport = self.viewport.height;
2670 let raw = match alignment {
2671 ItemAlignment::Start | ItemAlignment::Nearest => top,
2672 ItemAlignment::Center => top + (extent - viewport) / 2.0,
2673 ItemAlignment::End => top + extent - viewport,
2674 };
2675 raw.clamp(0.0, self.max_offset())
2676 }
2677
2678 /// What [`ItemAlignment::Nearest`] means for item `index` right now:
2679 /// `None` when the row is already fully inside the viewport,
2680 /// [`ItemAlignment::Start`] when it sits above it or is taller than it,
2681 /// [`ItemAlignment::End`] when it sits below.
2682 fn nearest_alignment(&self, index: usize) -> Option<ItemAlignment> {
2683 let (top, extent) = self.item_span(index);
2684 let viewport = self.viewport.height;
2685 let position = self.placement_offset();
2686 let tolerance = ITEM_SETTLE_TOLERANCE_PX;
2687 if top >= position - tolerance && top + extent <= position + viewport + tolerance {
2688 None
2689 } else if top < position || extent > viewport {
2690 Some(ItemAlignment::Start)
2691 } else {
2692 Some(ItemAlignment::End)
2693 }
2694 }
2695
2696 /// Whether item `index` is in the materialized window (and so, after a
2697 /// layout, measured).
2698 fn window_holds(&self, index: usize) -> bool {
2699 match (self.keys.first(), self.keys.last()) {
2700 (Some(&first), Some(&last)) => first <= index && index <= last,
2701 _ => false,
2702 }
2703 }
2704
2705 /// One settle step for a pending [`ItemTarget`], run at the end of every
2706 /// layout (the rows on screen were just measured): re-resolve the row's
2707 /// aligned offset and jump to it if the placement is off by more than
2708 /// [`ITEM_SETTLE_TOLERANCE_PX`]. The request ends once the row is
2709 /// materialized and in tolerance, when its key leaves the data, or after
2710 /// [`ITEM_SETTLE_MAX_FRAMES`] layouts. Waits, consuming nothing, while a
2711 /// tween is still carrying the list there. The `scrolling` guard below is
2712 /// defensive only: a pending request never coexists with an established
2713 /// drag in real event flow — applying the request ends the drag
2714 /// ([`ListViewWidget::end_live_drag`]) and a later primary `Down`, wheel
2715 /// or `Cancel` clears the request — so the guard documents the invariant
2716 /// rather than a resume-after-drag path.
2717 fn settle_item_target(&mut self) {
2718 let Some(mut target) = self.item_target else {
2719 return;
2720 };
2721 if self.ballistic.is_some() {
2722 return;
2723 }
2724 if self.scrolling {
2725 return;
2726 }
2727 let Some(index) = self.resolve_key(target.key) else {
2728 self.item_target = None;
2729 return;
2730 };
2731 target.frames_left = target.frames_left.saturating_sub(1);
2732 let desired = self.aligned_offset(index, target.alignment);
2733 let off = (desired - self.placement_offset()).abs() > ITEM_SETTLE_TOLERANCE_PX;
2734 if off {
2735 self.apply_jump(desired);
2736 }
2737 let landed = !off && self.window_holds(index);
2738 self.item_target = if landed || target.frames_left == 0 {
2739 None
2740 } else {
2741 Some(target)
2742 };
2743 }
2744
2745 /// This surface's position as a [`ScrollInfo`]: the content-space
2746 /// placement (what an app reads back after a jump), the converging
2747 /// `max_offset`, and the signed overscroll.
2748 fn scroll_info(&self) -> ScrollInfo {
2749 ScrollInfo {
2750 offset: self.placement_offset(),
2751 max_offset: self.max_offset(),
2752 overscroll: self.overscroll,
2753 }
2754 }
2755
2756 /// Publish the current position to the attached [`ScrollController`] (a
2757 /// no-op before the first layout, or once another surface took it over).
2758 fn publish_to_controller(&self) {
2759 if !self.laid_out {
2760 return;
2761 }
2762 if let Some(binding) = self.controller.as_ref() {
2763 binding.publish(self.scroll_info(), self.viewport.height);
2764 }
2765 }
2766
2767 /// Deliver a synthetic `Cancel` to whichever child holds the capture path,
2768 /// disarming an armed `ListItem` press when the scroll drag takes over.
2769 fn cancel_children(&mut self, ctx: &mut EventCtx, pos: Point) {
2770 let cancel = InputEvent::Pointer(PointerEvent {
2771 phase: PointerPhase::Cancel,
2772 position: pos,
2773 button: PointerButton::Primary,
2774 });
2775 // A takeover, not the gesture's end: the captured row gets its
2776 // `Cancel` exactly as `route_event` would deliver it, and is then
2777 // released through the context, which also ends a contact opt-in held
2778 // inside the row so the root stops routing other fingers to it.
2779 if let Some(pod) = self.children.iter_mut().find(|pod| pod.is_active()) {
2780 pod.event_child(ctx, &cancel);
2781 ctx.release_captured_child(pod);
2782 } else {
2783 crate::authoring::route_event(&mut self.children, ctx, &cancel);
2784 }
2785 }
2786
2787 /// The event entry, parameterised on an explicit timestamp so velocity math
2788 /// is deterministic in tests; [`Widget::event`] supplies the real clock.
2789 /// Routes the event, then publishes whatever position it left behind to an
2790 /// attached [`ScrollController`] (mirrors `ScrollWidget::event_at`).
2791 fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
2792 let result = self.route_event_at(ctx, event, t_ms);
2793 self.publish_to_controller();
2794 result
2795 }
2796
2797 /// The event body behind [`ListViewWidget::event_at`]. Adapted from
2798 /// [`crate::ScrollWidget`], routing to the *window* of children via
2799 /// [`crate::authoring::route_event`] rather than a single child.
2800 fn route_event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
2801 // A fling-driven near-start fire recorded at paint time is delivered on
2802 // the next event — except a Cancel, which clears it without firing.
2803 if !matches!(
2804 event,
2805 InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
2806 ) {
2807 self.deliver_pending_near_start(ctx);
2808 self.deliver_pending_near_end(ctx);
2809 }
2810 match event {
2811 // A broadcast reaches every realized child unconditionally and is
2812 // never consumed — `route_event` owns that contract, so this arm just
2813 // hands it over ahead of the gesture machinery. A floated surface's
2814 // own input is the second broadcast and rides the same arm: an
2815 // overlay owner in a realized row has to hear it.
2816 InputEvent::Housekeeping | InputEvent::Overlay(_) => {
2817 crate::authoring::route_event(&mut self.children, ctx, event)
2818 }
2819 // Focus-routed events — keyboard, IME, and the clipboard verbs an
2820 // `EditCommand` carries — reach the focused row through
2821 // `route_event`'s own focus branch, never a hit test.
2822 InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
2823 crate::authoring::route_event(&mut self.children, ctx, event)
2824 }
2825 InputEvent::Scroll { delta, .. } => {
2826 let dy = match delta {
2827 ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
2828 ScrollDelta::Pixels(_, y) => *y,
2829 };
2830 // Wheel scrolling stays hard-clamped — no overscroll rubber-band
2831 // on wheel input, matching `ScrollView`, and no physics
2832 // consulted: the clamp is a property of the input device, not
2833 // of the installed feel, so this arm is identical under every
2834 // physics.
2835 self.fling = None;
2836 self.settling = false;
2837 self.stop_ballistic();
2838 // User input wins over a settling `scroll_to_item` too.
2839 self.item_target = None;
2840 self.overscroll = 0.0;
2841 self.edge_pull = 0.0;
2842 self.set_offset(self.offset + dy);
2843 self.sync_child_origins();
2844 self.fire_near_start(ctx);
2845 self.fire_near_end(ctx);
2846 ctx.request_redraw();
2847 EventResult::Handled
2848 }
2849 InputEvent::Pointer(p) => match p.phase {
2850 PointerPhase::Down => {
2851 // Only a primary press arms a drag. A secondary press is a
2852 // context gesture: it still reaches the realized rows (a
2853 // context-menu consumer in a row must see it), but opens no
2854 // capture and can never start a scroll — the `ScrollView`
2855 // rule, applied to the windowing list.
2856 if !presses(p) {
2857 return crate::authoring::route_event(&mut self.children, ctx, event);
2858 }
2859 self.scrolling = false;
2860 self.down_active = true;
2861 self.inner_at_down = InnerScrollState::default();
2862 self.deferring = false;
2863 // A fresh, unset cell for this gesture — never the
2864 // previous one, which a since-torn-down recognizer may
2865 // still hold a clone of.
2866 self.live_veto = Rc::new(Cell::new(false));
2867 // Remember what this press interrupted before killing it —
2868 // the next fling asks the physics how much of it to carry
2869 // forward (`0.0` under `RubberBand`, i.e. start cold).
2870 self.carried_velocity = self.live_velocity();
2871 self.fling = None;
2872 self.settling = false;
2873 self.stop_ballistic();
2874 // User input wins over a settling `scroll_to_item` too.
2875 self.item_target = None;
2876 // A `Down` deliberately leaves a mid-bounce displacement on
2877 // screen (the regrab continues from it), so the pull is
2878 // re-seeded from that displacement rather than zeroed —
2879 // "reset" here means "carries nothing stale from the
2880 // previous gesture".
2881 self.edge_pull = self.overscroll;
2882 self.last_anim = None;
2883 self.down_start = p.position;
2884 self.last_drag = p.position;
2885 self.tracker.clear();
2886 self.tracker.record(t_ms, p.position.y);
2887 ctx.capture_pointer();
2888 // Innermost-wins arbitration, in dispatch order: report
2889 // THIS list into whatever cell is ambient (the nearest
2890 // enclosing scrollable's, if any) while that is still the
2891 // top of the stack, then push this list's own cell for the
2892 // routing below so a row's nested scrollable writes here
2893 // rather than past this level. `scroll.rs` owns the seam;
2894 // this is the second consumer of it, not a second copy.
2895 if let Some(host) = ambient_scroll_claim() {
2896 host.set(inner_claim_state(self.physics.as_ref(), &self.metrics()));
2897 }
2898 let claim = Rc::new(Cell::new(InnerScrollState::default()));
2899 let veto = Rc::clone(&self.live_veto);
2900 let children = &mut self.children;
2901 with_scroll_claim(&claim, || {
2902 with_scroll_veto(&veto, || {
2903 crate::authoring::route_event(children, ctx, event)
2904 })
2905 });
2906 self.inner_at_down = claim.get();
2907 EventResult::Handled
2908 }
2909 PointerPhase::Move => {
2910 if !self.down_active {
2911 return crate::authoring::route_event(&mut self.children, ctx, event);
2912 }
2913 self.tracker.record(t_ms, p.position.y);
2914 if self.scrolling {
2915 let dy = p.position.y - self.last_drag.y;
2916 self.last_drag = p.position;
2917 // Hand the physics this move's raw delta (the offset
2918 // moves opposite the finger); whatever it makes of a
2919 // past-edge pull lands in `overscroll`, never in a
2920 // windowing offset outside `[0, max_offset]` (see the
2921 // module docs' *Overscroll and pull-to-refresh*).
2922 self.apply_drag_offset(-dy);
2923 self.sync_child_origins();
2924 self.fire_near_start(ctx);
2925 self.fire_near_end(ctx);
2926 ctx.request_redraw();
2927 } else if !self.deferring
2928 && !self.live_veto.get()
2929 && (p.position.y - self.down_start.y).abs() > TOUCH_SLOP
2930 && self.physics.should_accept_user_offset(&self.metrics())
2931 {
2932 if self.inner_at_down.defers(p.position.y - self.down_start.y) {
2933 // Innermost wins: a nested scrollable inside a row
2934 // registered on this gesture's `Down` and can
2935 // consume this direction, so take nothing over —
2936 // no `Cancel` to the rows, no capture handover —
2937 // and keep routing. Sticky for the rest of the
2938 // gesture (`deferring` gates this whole branch);
2939 // the inner's own slop machinery takes it from
2940 // here. See the module docs' *Nested scrolling*.
2941 self.deferring = true;
2942 crate::authoring::route_event(&mut self.children, ctx, event);
2943 } else {
2944 // Take the gesture over: cancel the armed child,
2945 // stop forwarding — the documented window-shift
2946 // capture-loss tradeoff's sibling. A physics that
2947 // refuses drags outright keeps the move flowing to
2948 // the row instead; `RubberBand` accepts
2949 // unconditionally (even content that fits
2950 // rubber-bands), so that gate is inert on the
2951 // default feel.
2952 self.scrolling = true;
2953 self.settling = false;
2954 self.last_drag = p.position;
2955 // Seed the drag accumulator from the raw offset
2956 // plus any live overscroll — never
2957 // `painted_offset`, which also folds in
2958 // `pending_correction`: `apply_drag_offset` splits
2959 // `drag_position` straight into `offset` by
2960 // absolute assignment, and `placement_offset`
2961 // unconditionally re-adds `pending_correction` on
2962 // top, so seeding from painted space would
2963 // double-count a nonzero pending correction on the
2964 // first post-takeover move. Including `overscroll`
2965 // (not just `offset`) is the intentional
2966 // regrab-mid-bounce term, so a regrab mid-bounce
2967 // still continues smoothly from what is on screen.
2968 self.drag_position = self.offset + self.overscroll;
2969 self.cancel_children(ctx, p.position);
2970 ctx.request_redraw();
2971 }
2972 } else {
2973 crate::authoring::route_event(&mut self.children, ctx, event);
2974 }
2975 EventResult::Handled
2976 }
2977 PointerPhase::Up => {
2978 if self.scrolling {
2979 // Pull-to-refresh: released past the top trigger fires the
2980 // app hook (an Up, so mutating state is allowed). Shares
2981 // the exact threshold check `ScrollView` uses — see the
2982 // module docs' *Overscroll and pull-to-refresh* section
2983 // — measured on `edge_pull`, which under `RubberBand` is
2984 // bit-for-bit the overscroll it has always read.
2985 if crossed_refresh_trigger(self.edge_pull)
2986 && let Some(cb) = self.on_refresh_release.as_mut()
2987 {
2988 cb(ctx);
2989 }
2990 // Whatever the release starts, it starts alone: tear
2991 // every motion down first, through the single
2992 // tear-down path (mirrors `ScrollWidget`'s release).
2993 self.fling = None;
2994 self.settling = false;
2995 self.stop_ballistic();
2996 self.last_anim = None;
2997 // Ask the physics for post-release motion first: one
2998 // that hands back a simulation owns the release
2999 // outright, and one that does not (`RubberBand`) falls
3000 // through to the legacy settle/fling below untouched.
3001 if let Some(sim) = self.release_simulation() {
3002 self.ballistic = Some(BallisticState { sim, start: None });
3003 } else if self.edge_pull != 0.0 {
3004 // Released while overscrolled: settle back to the
3005 // edge, never fling out of range.
3006 self.settling = true;
3007 } else {
3008 // The legacy path keeps its own FLING_STOP threshold
3009 // (the trait's min/max fling bounds govern the
3010 // generic driver only) and takes carried momentum,
3011 // which is `0.0` under `RubberBand`.
3012 let finger_v = self.tracker.velocity();
3013 if finger_v.abs() > FLING_STOP {
3014 self.fling = Some(self.fling_start_velocity(-finger_v));
3015 }
3016 }
3017 } else {
3018 crate::authoring::route_event(&mut self.children, ctx, event);
3019 }
3020 self.scrolling = false;
3021 self.down_active = false;
3022 self.inner_at_down = InnerScrollState::default();
3023 self.deferring = false;
3024 self.live_veto = Rc::new(Cell::new(false));
3025 ctx.request_redraw();
3026 EventResult::Handled
3027 }
3028 PointerPhase::Cancel => {
3029 crate::authoring::route_event(&mut self.children, ctx, event);
3030 self.scrolling = false;
3031 self.down_active = false;
3032 self.inner_at_down = InnerScrollState::default();
3033 self.deferring = false;
3034 self.live_veto = Rc::new(Cell::new(false));
3035 // Cancel never fires a callback and snaps any overscroll away
3036 // with no settle animation: drop any pending
3037 // near-start/near-end fire without invoking it, and never
3038 // fires on_refresh_release (mirrors `ScrollWidget`'s Cancel).
3039 self.pending_near_start = false;
3040 self.pending_near_end = false;
3041 self.settling = false;
3042 self.stop_ballistic();
3043 self.item_target = None;
3044 self.overscroll = 0.0;
3045 self.edge_pull = 0.0;
3046 self.sync_child_origins();
3047 ctx.request_redraw();
3048 EventResult::Handled
3049 }
3050 },
3051 // `InputEvent` grows (a scale gesture and file drops are planned);
3052 // this widget handles only the variants named above, and hands any
3053 // other to its children exactly like the broadcast arm.
3054 _ => crate::authoring::route_event(&mut self.children, ctx, event),
3055 }
3056 }
3057}
3058
3059/// Report a [`ScrollController::scroll_to_item`] request no row answered — an
3060/// app bug worth hearing about in a debug build, but a no-op either way (a
3061/// recorded command has no caller left to return a `Result` to).
3062fn unknown_item_key(_request: &ScrollToItem, _keyed: bool) {
3063 #[cfg(debug_assertions)]
3064 if _keyed {
3065 eprintln!(
3066 "frust-widgets: ScrollController::scroll_to_item({:?}) ignored: no row of the \
3067 attached ListView carries that key",
3068 _request.key
3069 );
3070 } else {
3071 eprintln!(
3072 "frust-widgets: ScrollController::scroll_to_item({:?}) ignored: the attached \
3073 ListView is positional (ListView::builder) and has no row keys; build it with \
3074 ListView::builder_keyed",
3075 _request.key
3076 );
3077 }
3078}
3079
3080impl<State: 'static> View<State> for ListView<State> {
3081 type Element = ListViewWidget;
3082
3083 fn build(&self, ctx: &mut BuildCtx<'_>) -> ListViewWidget {
3084 let mut widget = ListViewWidget::new(self.item_count, self.item_extent);
3085 widget.on_near_start = self
3086 .on_near_start
3087 .as_ref()
3088 .map(crate::authoring::erase_callback);
3089 widget.near_start_threshold = self.near_start_threshold;
3090 widget.on_near_end = self
3091 .on_near_end
3092 .as_ref()
3093 .map(crate::authoring::erase_callback);
3094 widget.near_end_threshold = self.near_end_threshold;
3095 widget.on_refresh_release = self
3096 .on_refresh_release
3097 .as_ref()
3098 .map(crate::authoring::erase_callback);
3099 if let Some(physics) = self.physics.clone() {
3100 widget.physics = physics;
3101 }
3102 widget.effect = self.effect;
3103 widget.attach_controller(self.controller.as_ref());
3104 // The key function and the unmeasured-row estimate are widget state (the
3105 // window math and layout's measurement both run without the view in
3106 // scope), and must be installed before the first window is planned.
3107 widget.key_of = self.key_of.clone();
3108 widget.estimated_extent = self.variable_estimate();
3109 // Conservative initial window from a zero viewport (converges within one
3110 // extra frame via paint's continuation request — see the module docs).
3111 let plan = widget.desired_window();
3112 let variable = widget.is_variable();
3113 let mut duplicate = false;
3114 for index in plan.start..plan.end {
3115 widget
3116 .children
3117 .push(crate::authoring::build_child(&(self.builder)(index), ctx));
3118 widget.keys.push(index);
3119 if let Some(key_of) = self.key_of.as_ref() {
3120 // Seed the retained identity map so the first rebuild can already
3121 // relocate these rows by key.
3122 let key = key_of(index);
3123 duplicate |= widget.key_index.insert(key, index).is_some();
3124 if variable {
3125 widget.slot_keys.push(key);
3126 }
3127 }
3128 }
3129 debug_assert!(!duplicate, "{}", DUPLICATE_KEY_MSG);
3130 if let Some(estimate) = widget.variable_estimate() {
3131 widget.set_window_geometry(plan, estimate);
3132 }
3133 widget.sync_child_origins();
3134 widget
3135 }
3136
3137 fn rebuild(
3138 &self,
3139 prev: &Self,
3140 element: &mut ListViewWidget,
3141 ctx: &mut BuildCtx<'_>,
3142 ) -> ChangeFlags {
3143 // Closures are not comparable — always reinstall the erased adapter.
3144 element.on_near_start = self
3145 .on_near_start
3146 .as_ref()
3147 .map(crate::authoring::erase_callback);
3148 element.near_start_threshold = self.near_start_threshold;
3149 element.on_near_end = self
3150 .on_near_end
3151 .as_ref()
3152 .map(crate::authoring::erase_callback);
3153 element.near_end_threshold = self.near_end_threshold;
3154 element.on_refresh_release = self
3155 .on_refresh_release
3156 .as_ref()
3157 .map(crate::authoring::erase_callback);
3158 // A `.physics(...)`-carrying view reinstalls it every rebuild, like the
3159 // erased callbacks above; a view with no opinion (`None`) leaves the
3160 // widget's currently-installed physics alone — see `ListView::physics`'s
3161 // doc for the full contract.
3162 if let Some(physics) = self.physics.clone() {
3163 element.physics = physics;
3164 }
3165 // The visual effect is plain data the view owns and is always carried
3166 // down unconditionally.
3167 element.effect = self.effect;
3168 // Closures are not comparable either — reinstall the key function the
3169 // widget's own passes key indices with (never the builder; see the
3170 // module docs' *Variable extents* section).
3171 element.key_of = self.key_of.clone();
3172
3173 let mut flags = ChangeFlags::NONE;
3174 let old_item_count = element.item_count;
3175 let mut item_count_changed = false;
3176 if element.item_count != self.item_count {
3177 element.item_count = self.item_count;
3178 item_count_changed = true;
3179 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3180 }
3181 if element.item_extent != self.item_extent {
3182 element.item_extent = self.item_extent;
3183 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3184 }
3185 let estimate = self.variable_estimate();
3186 if element.estimated_extent != estimate {
3187 // Entering, leaving, or re-scaling variable-extent mode is an
3188 // extent-affecting change like `item_extent`: every row's position
3189 // is derived from it. Leaving it also drops the bookkeeping, so a
3190 // later re-entry starts from measurements this list actually took.
3191 element.estimated_extent = estimate;
3192 if estimate.is_none() {
3193 element.reset_variable_state();
3194 }
3195 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3196 }
3197
3198 // Prepend/removal scroll anchoring — keyed lists only (positional
3199 // identity cannot distinguish a prepend from a full mutation, see the
3200 // module docs' *Row identity* section): correct the offset for the
3201 // topmost surviving keyed row of the previous window *before* this
3202 // frame's window is recomputed below, so layout and paint agree on
3203 // one corrected offset — never during paint. See the module docs'
3204 // anchoring section for the algorithm and the `offset == 0` decision.
3205 let mut prepend_correction = false;
3206 // Set when the anchor-shift hypothesis probe below misses on every key
3207 // in the previous window. NOT itself proof of a full replace — the
3208 // probe only tests two candidate index shifts and can miss on a
3209 // same-frame mutation touching both sides of the anchor (a prepend
3210 // above the viewport plus an append below it in one frame) even
3211 // though most on-screen rows are still exactly the ones they were.
3212 // Whether the measured *cache* should reset on that is answered
3213 // later, inside `reconcile_keyed`, against its own exact per-slot key
3214 // matches — see the module docs' *Cache hygiene* section. The
3215 // *pending measured-anchor correction*, in contrast, is zeroed
3216 // unconditionally the moment the probe misses (below) — a narrower,
3217 // unrelated question the cache-reset decision does not gate.
3218 let mut anchor_probe_missed = false;
3219 if let Some(key_of) = self.key_of.as_ref() {
3220 match Self::anchor_shift_items(prev, element, old_item_count, key_of) {
3221 Some(shift_items) if shift_items != 0 => {
3222 element.apply_anchor_shift(shift_items);
3223 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3224 prepend_correction = shift_items > 0;
3225 }
3226 Some(_) => {}
3227 None => {
3228 anchor_probe_missed = true;
3229 // A pending measured-anchor correction describes rows
3230 // wholly above the viewport top *in the geometry this
3231 // frame's now-superseded window was planned against*
3232 // (module docs' *Measured anchor correction* section).
3233 // The anchor probe just proved that geometry cannot be
3234 // explained against this mutation by either hypothesis —
3235 // committing the accumulated correction into new data
3236 // below (`apply_pending_correction`, called after this
3237 // match) would be a guess, not a measurement. Zero it
3238 // here, before that commit point, regardless of whether
3239 // the measured *cache* itself resets: that is a separate,
3240 // narrower decision made later in `reconcile_keyed`
3241 // against its own exact per-slot key matches (see the
3242 // module docs' *Cache hygiene* section) — a probe miss
3243 // with a surviving on-screen row keeps the cache but
3244 // still owes this reset, since the correction was
3245 // computed against the pre-mutation window regardless of
3246 // whether any individual row's measurement survives.
3247 element.pending_correction = 0.0;
3248 }
3249 }
3250 }
3251
3252 if item_count_changed {
3253 // Content length changed — rearm the near-start "load older" and
3254 // near-end "load newer" edges so a list that grew (older rows
3255 // loaded, or newer rows appended) can trigger again — EXCEPT: a
3256 // keyed prepend correction just proved this exact growth was the
3257 // near-start edge's own fire being answered, so forcing it back
3258 // armed here would let the very next scroll event refire it
3259 // immediately, at the exact anchored offset the correction just
3260 // placed the user at (see the module docs' anchoring section).
3261 if !prepend_correction {
3262 element.near_start_armed = true;
3263 }
3264 element.near_end_armed = true;
3265 }
3266
3267 if self.item_count < old_item_count {
3268 // Cache hygiene: a shrunk keying provably cannot produce an entry
3269 // recorded past the new end (inert on the uniform path).
3270 element.evict_measured_stale_indices();
3271 }
3272
3273 // Measured anchor correction (variable extents only; the uniform path
3274 // measures nothing and never carries one): the previous layout recorded
3275 // how much taller or shorter the content above the viewport top actually
3276 // measured than the offset was planned against. Commit it here — the one
3277 // place the offset absorbs it, before this frame's window is planned —
3278 // unless a fling is live, in which case it keeps accumulating and lands
3279 // on the first rebuild after the fling stops. Placement has honored it
3280 // since the frame it was measured, so this moves nothing on screen; it
3281 // is still a `LAYOUT` report because the offset every row's origin is
3282 // derived from just changed. See the module docs' *Measured anchor
3283 // correction* section.
3284 if element.apply_pending_correction() {
3285 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3286 }
3287
3288 let offset_before_clamp = element.offset;
3289 element.clamp_offset();
3290 if element.is_variable() && element.offset != offset_before_clamp {
3291 // Variable extents only: measurements can revise the content extent
3292 // out from under the offset, which moves every row — the same
3293 // extent-affecting rule the item-count/extent checks above follow.
3294 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3295 }
3296
3297 // Programmatic scrolling: (re)bind the handle, then apply whatever it
3298 // queued now — after the anchor shift and the measured-correction
3299 // commit, *before* the window is planned — so the frame a jump lands
3300 // on materializes the rows it lands among (see the module docs'
3301 // *Programmatic scrolling* section). Anything it cannot apply yet (no
3302 // layout has measured a viewport) still owes a frame, and a settling
3303 // `scroll_to_item` owes the layout its next correction runs in.
3304 let rebound = element.attach_controller(self.controller.as_ref());
3305 if element.drain_controller() || element.item_target.is_some() {
3306 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
3307 }
3308 if rebound
3309 || element
3310 .controller
3311 .as_ref()
3312 .is_some_and(ScrollBinding::has_pending)
3313 {
3314 flags |= ChangeFlags::PAINT;
3315 }
3316
3317 let plan = element.desired_window();
3318 let delta = self.item_count as isize - old_item_count as isize;
3319 flags |= match self.key_of.as_ref() {
3320 Some(key_of) => {
3321 self.reconcile_keyed(prev, element, ctx, plan, key_of, delta, anchor_probe_missed)
3322 }
3323 None => {
3324 // A positional frame owns no key identities: drop whatever map a
3325 // previous keyed frame left (and, with it, every measurement
3326 // cached under one), so a list switched back to keyed later
3327 // cannot match against a window this path re-materialized by
3328 // index (a one-frame full re-materialization is the price of
3329 // swapping constructors mid-flight).
3330 element.reset_keyed_state();
3331 self.reconcile_positional(prev, element, ctx, plan)
3332 }
3333 };
3334 flags
3335 }
3336
3337 fn teardown(&self, element: &mut ListViewWidget, ctx: &mut BuildCtx<'_>) {
3338 for (index, pod) in element.keys.iter().zip(element.children.iter_mut()) {
3339 crate::authoring::teardown_child(&(self.builder)(*index), pod, ctx);
3340 }
3341 }
3342}
3343
3344impl Widget for ListViewWidget {
3345 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3346 let vw = if bc.max().width.is_finite() {
3347 bc.max().width
3348 } else {
3349 0.0
3350 };
3351 let vh = if bc.max().height.is_finite() {
3352 bc.max().height
3353 } else {
3354 // An unbounded height context: the list is as tall as its content.
3355 self.content_extent()
3356 };
3357 self.viewport = Size::new(vw, vh);
3358 self.clamp_offset();
3359 if let Some(estimate) = self.variable_estimate() {
3360 // Variable extents: rows size themselves and are measured here.
3361 self.layout_variable(ctx, vw, estimate);
3362 } else {
3363 // Uniform extent: every materialized row is exactly `item_extent` tall.
3364 let child_bc = BoxConstraints::tight(Size::new(vw, self.item_extent));
3365 for pod in &mut self.children {
3366 pod.layout_child(ctx, &child_bc);
3367 }
3368 }
3369 // A controller command resolves against the viewport and extents just
3370 // measured; a settling `scroll_to_item` re-resolves against them too.
3371 self.laid_out = true;
3372 self.motion = ControllerMotion::from_theme(Theme::from_layout_ctx(ctx));
3373 self.drain_controller();
3374 self.settle_item_target();
3375 self.sync_child_origins();
3376 self.publish_to_controller();
3377 bc.constrain(self.viewport)
3378 }
3379
3380 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
3381 self.last_frame_time = ctx.frame_time();
3382 // A command recorded since layout (or with layout skipped this frame)
3383 // applies before the pump, so a jump ends a fling before it advances.
3384 self.motion = ControllerMotion::from_theme(Theme::from_paint_ctx(ctx));
3385 self.drain_controller();
3386 self.pump_fling(ctx);
3387 self.publish_to_controller();
3388 if std::mem::take(&mut self.jump_notify_owed)
3389 && (self.pending_near_start || self.pending_near_end)
3390 {
3391 // Paint has no `&mut State`: owe the jump's edge fire to the next
3392 // frame's Housekeeping flush (the `ScrollWidget` precedent).
3393 frust_core::mark_pending_result_flush();
3394 ctx.request_frame();
3395 }
3396 if self
3397 .controller
3398 .as_ref()
3399 .is_some_and(ScrollBinding::has_pending)
3400 {
3401 // Recorded during this paint (a listener reacting to the publish
3402 // above): the next frame applies it.
3403 ctx.request_frame();
3404 }
3405 if self.item_target.is_some() {
3406 // A settling `scroll_to_item` corrects at layout: make sure the
3407 // next frame lays out even under the mobile intra-frame skip.
3408 ctx.request_layout();
3409 }
3410 scene.push_clip(ctx.origin(), ctx.size());
3411 self.sync_child_origins();
3412 // The stretch is PAINT-ONLY, and load-bearingly so: no layout pass
3413 // (nor the windowing math) reads `edge_pull` or the intensity derived
3414 // from it, and none may start to. A layout-affecting animation must
3415 // request a relayout on every frame of its motion or the mobile
3416 // shell's intra-frame layout skip leaves it frozen
3417 // (`docs/WIDGETS_CODE_STANDARDS.md`'s animation-pacing rule) —
3418 // keeping the stretch out of every layout read is what makes that
3419 // irrelevant here, and is why there is no `request_layout` in this
3420 // path either: the settle/ballistic pump above already asks for every
3421 // frame the decaying stretch needs. Pushed INSIDE the viewport clip
3422 // so stretched rows can never paint past the viewport's edges.
3423 // Mirrors `ScrollWidget::paint`, over this widget's own `edge_pull`.
3424 let stretch = match self.effect {
3425 OverscrollEffect::Stretch => {
3426 stretch_about_edge(ctx.origin(), ctx.size(), self.edge_pull)
3427 }
3428 OverscrollEffect::Translate | OverscrollEffect::None => None,
3429 };
3430 if let Some(transform) = stretch {
3431 scene.push_transform(transform);
3432 }
3433 for pod in &mut self.children {
3434 pod.paint_child(ctx, scene);
3435 }
3436 if stretch.is_some() {
3437 scene.pop_transform();
3438 }
3439 scene.pop_clip();
3440 // If the (now-known) viewport needs rows the materialized window does not
3441 // yet hold — first build, a constraint change, a fling that advanced the
3442 // offset past the buffer, or (variable extents) measurements that
3443 // revised the window out from under the plan rebuild worked from — ask
3444 // the shell for one more frame so the next rebuild re-windows. Converges
3445 // without idling under-materialized, and is the only path by which a
3446 // measurement changes what is materialized: layout and paint never build.
3447 // A correction this frame's layout recorded is committed by the *next*
3448 // rebuild, so a frame carrying one owes a continuation frame exactly
3449 // like an uncovered window does — the same one-frame convergence, and
3450 // the reason a measured correction never idles uncommitted. (While a
3451 // fling withholds it the pump above is already asking; the request here
3452 // is what covers the frame the fling stops on.)
3453 let WindowPlan { start, end, .. } = self.desired_window();
3454 let uncovered = self.item_count > 0 && !self.window_covers(start, end);
3455 if uncovered || self.pending_correction != 0.0 {
3456 ctx.request_frame();
3457 }
3458 }
3459
3460 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
3461 let t = self.event_time_ms();
3462 self.event_at(ctx, event, t)
3463 }
3464
3465 fn semantics(&self, ctx: &mut SemanticsCtx) {
3466 // A List container exposing its total item count and vertical scroll
3467 // range; the materialized rows contribute their own child nodes. Per the
3468 // pull-based seam, only the windowed rows are present — the accessible
3469 // set matches what is rendered, which is the documented v1 behavior.
3470 // `position_in_set` is not set on children: the semantics seam threads no
3471 // item index into `semantics_child`, and the generic builder need not
3472 // produce `ListItem`s, so the container's `size_of_set` carries the count.
3473 let max_offset = self.max_offset();
3474 let count = self.item_count;
3475 // The painted position, not the windowing offset: it is where the
3476 // content actually sits, and it is the value that stays put across a
3477 // correction commit (see the module docs' *Measured anchor
3478 // correction* section) — and, like `ScrollView`'s own semantics node,
3479 // it can momentarily read outside `[0, max_offset]` mid-overscroll
3480 // (see the module docs' *Overscroll and pull-to-refresh* section).
3481 // Placement, overscroll, and the raw offset all agree once the list is
3482 // in range, which is always true on the uniform path.
3483 let offset = self.painted_offset();
3484 ctx.push_container(
3485 Role::List,
3486 move |node| {
3487 node.set_size_of_set(count);
3488 node.set_scroll_y(offset);
3489 node.set_scroll_y_min(0.0);
3490 node.set_scroll_y_max(max_offset);
3491 },
3492 |ctx| {
3493 for pod in &self.children {
3494 pod.semantics_child(ctx);
3495 }
3496 },
3497 );
3498 }
3499
3500 crate::authoring::visit_children!(children);
3501}
3502
3503#[cfg(test)]
3504mod tests {
3505 use super::*;
3506 use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
3507 use crate::physics::rubber_band::RubberBand;
3508 use crate::scroll::ScrollWidget;
3509 use frust_core::{RenderRoot, any};
3510 use std::any::Any;
3511 use std::cell::{Cell, RefCell};
3512 use std::rc::Rc;
3513
3514 // --- A stateful row fixture: each built widget is stamped with a monotonic
3515 // "generation" from a shared counter, so a relocated (state-preserving)
3516 // row keeps its stamp while a rebuilt-fresh row gets a new one. ---
3517
3518 struct GenView {
3519 gens: Rc<Cell<u64>>,
3520 seen: Rc<GenLog>,
3521 index: usize,
3522 }
3523
3524 #[derive(Default)]
3525 struct GenLog {
3526 // index -> generation, last write wins (proves which pod rendered it).
3527 entries: std::cell::RefCell<std::collections::HashMap<usize, u64>>,
3528 }
3529
3530 struct GenWidget {
3531 generation: u64,
3532 seen: Rc<GenLog>,
3533 index: usize,
3534 }
3535
3536 impl View<()> for GenView {
3537 type Element = GenWidget;
3538 fn build(&self, _ctx: &mut BuildCtx<'_>) -> GenWidget {
3539 let generation = self.gens.get();
3540 self.gens.set(generation + 1);
3541 GenWidget {
3542 generation,
3543 seen: self.seen.clone(),
3544 index: self.index,
3545 }
3546 }
3547 fn rebuild(
3548 &self,
3549 _prev: &Self,
3550 element: &mut GenWidget,
3551 _ctx: &mut BuildCtx<'_>,
3552 ) -> ChangeFlags {
3553 // A same-type in-place rebuild keeps the generation (state preserved).
3554 element.index = self.index;
3555 element.seen = self.seen.clone();
3556 ChangeFlags::NONE
3557 }
3558 }
3559
3560 impl Widget for GenWidget {
3561 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3562 bc.constrain(bc.max())
3563 }
3564 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
3565 self.seen
3566 .entries
3567 .borrow_mut()
3568 .insert(self.index, self.generation);
3569 }
3570 }
3571
3572 struct NullScene;
3573 impl PaintScene for NullScene {
3574 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
3575 fn draw_text(&mut self, _o: Point, _t: &str) {}
3576 }
3577
3578 fn list_widget(root: &RenderRoot<(), ListView<()>>) -> &ListViewWidget {
3579 let id = root.root_id().expect("root built");
3580 (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
3581 .downcast_ref::<ListViewWidget>()
3582 .expect("root is a ListViewWidget")
3583 }
3584
3585 /// Drive a full rebuild → layout → paint frame at `ms`, returning the flags
3586 /// the rebuild reported (what the layout-skip contract is asserted against).
3587 fn frame(
3588 root: &mut RenderRoot<(), ListView<()>>,
3589 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
3590 state: &mut (),
3591 window: Size,
3592 ms: f64,
3593 ) -> ChangeFlags {
3594 let flags = root.rebuild(logic, state);
3595 root.layout(window);
3596 let mut sink = NullScene;
3597 root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
3598 flags
3599 }
3600
3601 fn ev(phase: PointerPhase, y: f64) -> InputEvent {
3602 InputEvent::Pointer(PointerEvent {
3603 phase,
3604 position: Point::new(10.0, y),
3605 button: PointerButton::Primary,
3606 })
3607 }
3608
3609 // --- (1) Only the visible window materializes, at multiple offsets. ---
3610
3611 #[test]
3612 fn only_the_visible_window_materializes() {
3613 fn logic(_: &mut ()) -> ListView<()> {
3614 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3615 }
3616 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3617 let mut state = ();
3618 let window = Size::new(200.0, 200.0);
3619
3620 // First frame builds a conservative window; a second converges to the
3621 // real viewport (200 / 50 = 4 rows + 2*BUFFER).
3622 frame(&mut root, &mut logic, &mut state, window, 0.0);
3623 frame(&mut root, &mut logic, &mut state, window, 16.0);
3624
3625 let w = list_widget(&root);
3626 // window = floor(0/50)-2 .. ceil(200/50)+2 = 0 .. 6
3627 assert_eq!(w.window(), &[0, 1, 2, 3, 4, 5]);
3628 assert!(
3629 w.children.len() < 20,
3630 "only a handful of the 1000 rows are materialized"
3631 );
3632 }
3633
3634 #[test]
3635 fn window_shifts_to_the_scrolled_offset() {
3636 fn logic(_: &mut ()) -> ListView<()> {
3637 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3638 }
3639 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3640 let mut state = ();
3641 let window = Size::new(200.0, 200.0);
3642 frame(&mut root, &mut logic, &mut state, window, 0.0);
3643 frame(&mut root, &mut logic, &mut state, window, 16.0);
3644
3645 // Wheel to offset 500 (10 lines * 40px/line = 400... use a pixel scroll).
3646 root.event(
3647 &mut state,
3648 &InputEvent::Scroll {
3649 position: Point::new(10.0, 50.0),
3650 delta: ScrollDelta::Pixels(0.0, 500.0),
3651 },
3652 );
3653 frame(&mut root, &mut logic, &mut state, window, 32.0);
3654
3655 let w = list_widget(&root);
3656 assert_eq!(w.offset(), 500.0);
3657 // window = floor(500/50)-2 .. ceil(700/50)+2 = 8 .. 16
3658 assert_eq!(w.window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
3659 }
3660
3661 // --- (2) A window shift relocates survivors (state preserved). ---
3662
3663 #[test]
3664 fn an_overlay_broadcast_reaches_every_realized_row() {
3665 use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
3666
3667 /// A row that counts the floated-surface broadcasts it receives — an
3668 /// overlay owner living in a realized row.
3669 struct Owner(Rc<Cell<u32>>);
3670 struct OwnerW(Rc<Cell<u32>>);
3671 impl View<()> for Owner {
3672 type Element = OwnerW;
3673 fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
3674 OwnerW(self.0.clone())
3675 }
3676 fn rebuild(&self, _p: &Self, e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
3677 e.0 = self.0.clone();
3678 ChangeFlags::NONE
3679 }
3680 }
3681 impl Widget for OwnerW {
3682 fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3683 bc.constrain(Size::new(200.0, 50.0))
3684 }
3685 fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
3686 fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
3687 if matches!(e, InputEvent::Overlay(_)) {
3688 self.0.set(self.0.get() + 1);
3689 // Reporting `Handled` must not stop the next row hearing it.
3690 return EventResult::Handled;
3691 }
3692 EventResult::Ignored
3693 }
3694 }
3695
3696 let seen = Rc::new(Cell::new(0u32));
3697 let seen_l = seen.clone();
3698 let mut logic = move |_: &mut ()| -> ListView<()> {
3699 let seen = seen_l.clone();
3700 list_view(1000, 50.0, move |_| any::<(), _>(Owner(seen.clone())))
3701 };
3702 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3703 let mut state = ();
3704 let window = Size::new(200.0, 200.0);
3705 frame(&mut root, &mut logic, &mut state, window, 0.0);
3706 frame(&mut root, &mut logic, &mut state, window, 16.0);
3707 let realized = list_widget(&root).children.len();
3708 assert!(realized > 1, "the fixture realizes a window of rows");
3709
3710 let outcome = root.event(
3711 &mut state,
3712 &InputEvent::Overlay(OverlayEvent {
3713 key: OverlayKey::next(),
3714 kind: OverlayEventKind::OutsideDown,
3715 }),
3716 );
3717 assert_eq!(
3718 seen.get() as usize,
3719 realized,
3720 "every realized row heard it, with no first-handler-wins short-circuit"
3721 );
3722 assert!(
3723 !outcome.handled,
3724 "a broadcast is never consumed, whatever a row returned"
3725 );
3726 }
3727
3728 #[test]
3729 fn window_shift_relocates_survivors_preserving_state() {
3730 let gens = Rc::new(Cell::new(0u64));
3731 let seen = Rc::new(GenLog::default());
3732 let gens_l = gens.clone();
3733 let seen_l = seen.clone();
3734 let mut logic = move |_: &mut ()| -> ListView<()> {
3735 let gens = gens_l.clone();
3736 let seen = seen_l.clone();
3737 list_view(1000, 50.0, move |i| {
3738 any::<(), _>(GenView {
3739 gens: gens.clone(),
3740 seen: seen.clone(),
3741 index: i,
3742 })
3743 })
3744 };
3745 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3746 let mut state = ();
3747 let window = Size::new(200.0, 200.0);
3748 frame(&mut root, &mut logic, &mut state, window, 0.0);
3749 frame(&mut root, &mut logic, &mut state, window, 16.0);
3750
3751 // Record the generation stamped on a row that will survive the shift.
3752 let survivor_gen = seen.entries.borrow()[&3];
3753
3754 // Nudge the offset down by one row (50px) so the window shifts by one but
3755 // index 3 stays inside it — via wheel so no child cancel is involved.
3756 root.event(
3757 &mut state,
3758 &InputEvent::Scroll {
3759 position: Point::new(10.0, 50.0),
3760 delta: ScrollDelta::Pixels(0.0, 50.0),
3761 },
3762 );
3763 frame(&mut root, &mut logic, &mut state, window, 32.0);
3764
3765 let entries = seen.entries.borrow();
3766 assert_eq!(
3767 entries[&3], survivor_gen,
3768 "a surviving row keeps its widget (and state): relocated, not rebuilt"
3769 );
3770 // window shifted to 0..7; index 6 newly entered → a strictly newer stamp.
3771 assert!(
3772 entries[&6] > survivor_gen,
3773 "a freshly-entered row is built anew (higher generation)"
3774 );
3775 }
3776
3777 // --- (3) A fling advances the window across frames. ---
3778
3779 #[test]
3780 fn fling_advances_the_window_across_frames() {
3781 fn logic(_: &mut ()) -> ListView<()> {
3782 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3783 }
3784 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3785 let mut state = ();
3786 let window = Size::new(200.0, 200.0);
3787 frame(&mut root, &mut logic, &mut state, window, 0.0);
3788 frame(&mut root, &mut logic, &mut state, window, 16.0);
3789
3790 // Drag up past the slop, build velocity, release → a fling downward.
3791 root.event(&mut state, &ev(PointerPhase::Down, 180.0));
3792 frame(&mut root, &mut logic, &mut state, window, 32.0);
3793 root.event(&mut state, &ev(PointerPhase::Move, 120.0)); // takeover
3794 frame(&mut root, &mut logic, &mut state, window, 48.0);
3795 root.event(&mut state, &ev(PointerPhase::Move, 60.0)); // build velocity
3796 root.event(&mut state, &ev(PointerPhase::Up, 60.0)); // release
3797
3798 assert!(list_widget(&root).is_flinging(), "release starts a fling");
3799 let start_first = list_widget(&root).window()[0];
3800
3801 // Pump several frames: paint advances the fling offset, the next rebuild
3802 // re-windows against it.
3803 for k in 0..8 {
3804 frame(
3805 &mut root,
3806 &mut logic,
3807 &mut state,
3808 window,
3809 64.0 + 16.0 * k as f64,
3810 );
3811 }
3812
3813 assert!(
3814 list_widget(&root).window()[0] > start_first,
3815 "the fling carried the window to higher indices across frames"
3816 );
3817 }
3818
3819 // --- (4) Extent / clamp math. ---
3820
3821 #[test]
3822 fn extent_is_exact_and_offset_clamps_with_no_overscroll() {
3823 let mut w = ListViewWidget::new(100, 40.0);
3824 w.viewport = Size::new(200.0, 300.0);
3825 // extent = 100 * 40 = 4000; max_offset = 4000 - 300 = 3700.
3826 assert_eq!(w.max_offset(), 3700.0);
3827 w.set_offset(10_000.0);
3828 assert_eq!(w.offset(), 3700.0, "clamped to the end, no overscroll");
3829 w.set_offset(-50.0);
3830 assert_eq!(w.offset(), 0.0, "clamped to the top");
3831
3832 // A viewport taller than the content pins the offset at zero.
3833 let mut short = ListViewWidget::new(2, 40.0);
3834 short.viewport = Size::new(200.0, 300.0);
3835 assert_eq!(short.max_offset(), 0.0);
3836 }
3837
3838 #[test]
3839 fn empty_list_materializes_nothing() {
3840 fn logic(_: &mut ()) -> ListView<()> {
3841 list_view(0, 50.0, |i| any::<(), _>(gen_stub(i)))
3842 }
3843 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3844 let mut state = ();
3845 let window = Size::new(200.0, 200.0);
3846 frame(&mut root, &mut logic, &mut state, window, 0.0);
3847 frame(&mut root, &mut logic, &mut state, window, 16.0);
3848 assert!(list_widget(&root).window().is_empty());
3849 }
3850
3851 // A stateless stand-in row used by the window/fling/extent tests (no shared
3852 // generation bookkeeping needed there).
3853 pub(super) fn gen_stub(_index: usize) -> impl View<()> {
3854 crate::test_support::leaf(200.0, 50.0)
3855 }
3856
3857 // --- (6) Semantics tree over the materialized window. ---
3858
3859 /// A minimal `Role::ListItem`-reporting row, standing in for
3860 /// `material::list_item::ListItem` so this baseline module's own test
3861 /// suite carries no dependency on a design-system catalog (see the
3862 /// [module docs](self) — `ListView` itself takes no opinion on what a row
3863 /// is; any child view that contributes a `Role::ListItem` node exercises
3864 /// the same semantics-forwarding path).
3865 struct RoleListItemRow;
3866
3867 struct RoleListItemRowWidget;
3868
3869 impl View<()> for RoleListItemRow {
3870 type Element = RoleListItemRowWidget;
3871 fn build(&self, _ctx: &mut BuildCtx<'_>) -> RoleListItemRowWidget {
3872 RoleListItemRowWidget
3873 }
3874 fn rebuild(
3875 &self,
3876 _prev: &Self,
3877 _element: &mut RoleListItemRowWidget,
3878 _ctx: &mut BuildCtx<'_>,
3879 ) -> ChangeFlags {
3880 ChangeFlags::NONE
3881 }
3882 }
3883
3884 impl Widget for RoleListItemRowWidget {
3885 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3886 bc.constrain(Size::new(300.0, 56.0))
3887 }
3888 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
3889 fn semantics(&self, ctx: &mut SemanticsCtx) {
3890 ctx.push_node(frust_core::accesskit::Role::ListItem, |_node| {});
3891 }
3892 }
3893
3894 #[test]
3895 fn semantics_is_a_list_container_over_the_windowed_rows() {
3896 use frust_core::accesskit::Role;
3897 use frust_text::TextContext;
3898
3899 fn logic(_s: &mut ()) -> ListView<()> {
3900 list_view(1000, 56.0, |_i| any::<(), _>(RoleListItemRow))
3901 }
3902 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3903 let mut state = ();
3904 let window = Size::new(300.0, 200.0);
3905
3906 // Converge the window (build → real-viewport rebuild), then collect.
3907 let mut tcx = TextContext::new();
3908 for _ in 0..2 {
3909 root.rebuild(&mut logic, &mut state);
3910 root.layout_with_text(window, &mut tcx as &mut dyn Any);
3911 let mut sink = NullScene;
3912 root.paint(&mut sink, FrameTime::ZERO);
3913 }
3914
3915 let update = root.semantics();
3916 let materialized = list_widget(&root).window().len();
3917
3918 let (_, list) = update
3919 .nodes
3920 .iter()
3921 .find(|(_, n)| n.role() == Role::List)
3922 .expect("the ListView contributes a Role::List container node");
3923 assert_eq!(
3924 list.size_of_set(),
3925 Some(1000),
3926 "the container advertises the full item count, not just the window"
3927 );
3928 assert_eq!(
3929 list.children().len(),
3930 materialized,
3931 "only the materialized rows are semantics children of the list"
3932 );
3933
3934 // Every materialized row contributes its own Role::ListItem node.
3935 let list_items = update
3936 .nodes
3937 .iter()
3938 .filter(|(_, n)| n.role() == Role::ListItem)
3939 .count();
3940 assert_eq!(list_items, materialized);
3941 }
3942
3943 // --- (7) Near-start "load older" callback. ---
3944
3945 /// A state that counts near-start fires (the "load older" trigger).
3946 #[derive(Default)]
3947 struct Loads {
3948 count: u32,
3949 }
3950
3951 /// Build a widget with a near-start callback installed, over a known viewport,
3952 /// bypassing the View layer (the callback path is exercised via `event_at`).
3953 fn near_start_widget(threshold: f64) -> ListViewWidget {
3954 let mut w = ListViewWidget::new(1000, 50.0);
3955 w.viewport = Size::new(200.0, 200.0);
3956 w.near_start_threshold = threshold;
3957 let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
3958 w.on_near_start = Some(crate::authoring::erase_callback(&cb));
3959 w.near_start_armed = true;
3960 w
3961 }
3962
3963 fn wheel(px: f64) -> InputEvent {
3964 InputEvent::Scroll {
3965 position: Point::new(10.0, 50.0),
3966 delta: ScrollDelta::Pixels(0.0, px),
3967 }
3968 }
3969
3970 fn run_loads(w: &mut ListViewWidget, state: &mut Loads, e: &InputEvent) {
3971 let sa: &mut dyn Any = state;
3972 let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
3973 w.event_at(&mut ctx, e, 0.0);
3974 }
3975
3976 #[test]
3977 fn on_near_start_fires_near_start_and_rearms_after_scrolling_away() {
3978 let mut w = near_start_widget(100.0);
3979 let mut state = Loads::default();
3980
3981 // Scroll away from the start (past 2×threshold = 200) → no fire.
3982 run_loads(&mut w, &mut state, &wheel(500.0));
3983 assert_eq!(w.offset(), 500.0);
3984 assert_eq!(state.count, 0, "away from the start does not fire");
3985
3986 // Scroll back within the threshold of the start → fires once.
3987 run_loads(&mut w, &mut state, &wheel(-460.0));
3988 assert_eq!(w.offset(), 40.0);
3989 assert_eq!(
3990 state.count, 1,
3991 "nearing the start fires the load-older hook"
3992 );
3993
3994 // Staying near the start does not re-fire (edge-triggered, disarmed).
3995 run_loads(&mut w, &mut state, &wheel(-20.0));
3996 assert_eq!(w.offset(), 20.0);
3997 assert_eq!(state.count, 1, "no re-fire while still near the start");
3998
3999 // Scroll away past 2×threshold to rearm, then back → fires again.
4000 run_loads(&mut w, &mut state, &wheel(500.0));
4001 assert_eq!(state.count, 1);
4002 run_loads(&mut w, &mut state, &wheel(-480.0));
4003 assert_eq!(w.offset(), 40.0);
4004 assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
4005 }
4006
4007 #[test]
4008 fn on_near_start_does_not_fire_at_the_bottom() {
4009 let mut w = near_start_widget(100.0);
4010 let mut state = Loads::default();
4011 // Scroll to the very bottom — nowhere near the start edge.
4012 run_loads(&mut w, &mut state, &wheel(1_000_000.0));
4013 assert_eq!(w.offset(), w.max_offset());
4014 assert_eq!(state.count, 0, "the bottom is not the load-older edge");
4015 }
4016
4017 #[test]
4018 fn near_start_content_growth_rearms_across_rebuild() {
4019 // A fling-free rebuild path: growing item_count (older rows loaded) rearms
4020 // the edge so a subsequent near-start approach fires again.
4021 let loads = Rc::new(Cell::new(0u32));
4022 let loads_l = loads.clone();
4023 let count = Rc::new(Cell::new(1000usize));
4024 let count_l = count.clone();
4025 let mut logic = move |_: &mut ()| -> ListView<()> {
4026 let loads = loads_l.clone();
4027 list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
4028 .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
4029 };
4030 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4031 let mut state = ();
4032 let window = Size::new(200.0, 200.0);
4033 frame(&mut root, &mut logic, &mut state, window, 0.0);
4034 frame(&mut root, &mut logic, &mut state, window, 16.0);
4035
4036 // At the top (offset 0), a wheel nudge within the threshold fires once.
4037 root.event(&mut state, &wheel(10.0));
4038 assert_eq!(loads.get(), 1, "near-start fires at the top");
4039 // Still near start: no re-fire.
4040 root.event(&mut state, &wheel(5.0));
4041 assert_eq!(loads.get(), 1);
4042
4043 // Grow the content (older rows loaded) → rebuild rearms the edge.
4044 count.set(2000);
4045 frame(&mut root, &mut logic, &mut state, window, 32.0);
4046 root.event(&mut state, &wheel(5.0));
4047 assert_eq!(loads.get(), 2, "content growth rearms the load-older edge");
4048 }
4049
4050 #[test]
4051 fn cancel_clears_a_pending_near_start_without_firing() {
4052 let mut w = near_start_widget(100.0);
4053 let mut state = Loads::default();
4054 // Simulate a fling-driven near-start recorded at paint time.
4055 w.pending_near_start = true;
4056 // A Cancel must drop it without invoking the callback.
4057 run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
4058 assert!(!w.pending_near_start);
4059 assert_eq!(state.count, 0, "Cancel never fires the callback");
4060
4061 // A pending fire is otherwise delivered on the next (non-Cancel) event.
4062 w.pending_near_start = true;
4063 run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
4064 assert!(!w.pending_near_start);
4065 assert_eq!(
4066 state.count, 1,
4067 "a pending fire is delivered on the next event"
4068 );
4069 }
4070
4071 // --- (8) Near-end "load newer" callback (mirrors near-start above). ---
4072
4073 /// Build a widget with a near-end callback installed, over a known viewport,
4074 /// starting scrolled to the very end (mirrors [`near_start_widget`], which
4075 /// starts at the top).
4076 fn near_end_widget(threshold: f64) -> ListViewWidget {
4077 let mut w = ListViewWidget::new(1000, 50.0);
4078 w.viewport = Size::new(200.0, 200.0);
4079 w.near_end_threshold = threshold;
4080 let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
4081 w.on_near_end = Some(crate::authoring::erase_callback(&cb));
4082 w.near_end_armed = true;
4083 w.offset = w.max_offset();
4084 w
4085 }
4086
4087 #[test]
4088 fn on_near_end_fires_near_end_and_rearms_after_scrolling_away() {
4089 let mut w = near_end_widget(100.0);
4090 let mut state = Loads::default();
4091 let start_offset = w.offset();
4092
4093 // Scroll away from the end (past 2×threshold = 200) → no fire.
4094 run_loads(&mut w, &mut state, &wheel(-500.0));
4095 assert_eq!(w.offset(), start_offset - 500.0);
4096 assert_eq!(state.count, 0, "away from the end does not fire");
4097
4098 // Scroll back within the threshold of the end → fires once.
4099 run_loads(&mut w, &mut state, &wheel(460.0));
4100 assert_eq!(w.offset(), start_offset - 40.0);
4101 assert_eq!(state.count, 1, "nearing the end fires the load-newer hook");
4102
4103 // Staying near the end does not re-fire (edge-triggered, disarmed).
4104 run_loads(&mut w, &mut state, &wheel(20.0));
4105 assert_eq!(w.offset(), start_offset - 20.0);
4106 assert_eq!(state.count, 1, "no re-fire while still near the end");
4107
4108 // Scroll away past 2×threshold to rearm, then back → fires again.
4109 run_loads(&mut w, &mut state, &wheel(-500.0));
4110 assert_eq!(state.count, 1);
4111 run_loads(&mut w, &mut state, &wheel(480.0));
4112 assert_eq!(w.offset(), start_offset - 40.0);
4113 assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
4114 }
4115
4116 #[test]
4117 fn on_near_end_does_not_fire_at_the_top() {
4118 let mut w = near_end_widget(100.0);
4119 let mut state = Loads::default();
4120 // Scroll all the way to the very top — nowhere near the end edge.
4121 run_loads(&mut w, &mut state, &wheel(-1_000_000.0));
4122 assert_eq!(w.offset(), 0.0);
4123 assert_eq!(state.count, 0, "the top is not the load-newer edge");
4124 }
4125
4126 #[test]
4127 fn near_end_content_growth_rearms_across_rebuild() {
4128 // Growing item_count (newer rows appended) rearms the edge so a
4129 // subsequent near-end approach fires again.
4130 let loads = Rc::new(Cell::new(0u32));
4131 let loads_l = loads.clone();
4132 let count = Rc::new(Cell::new(1000usize));
4133 let count_l = count.clone();
4134 let mut logic = move |_: &mut ()| -> ListView<()> {
4135 let loads = loads_l.clone();
4136 list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
4137 .on_near_end(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
4138 };
4139 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4140 let mut state = ();
4141 let window = Size::new(200.0, 200.0);
4142 frame(&mut root, &mut logic, &mut state, window, 0.0);
4143 frame(&mut root, &mut logic, &mut state, window, 16.0);
4144
4145 // Scroll to the very bottom, within the threshold of the end → fires
4146 // once.
4147 root.event(&mut state, &wheel(1_000_000.0));
4148 assert_eq!(loads.get(), 1, "near-end fires at the bottom");
4149 // Still near end: no re-fire.
4150 root.event(&mut state, &wheel(5.0));
4151 assert_eq!(loads.get(), 1);
4152
4153 // Grow the content (newer rows appended) → rebuild rearms the edge, and
4154 // the new (farther) bottom is no longer within the threshold.
4155 count.set(2000);
4156 frame(&mut root, &mut logic, &mut state, window, 32.0);
4157 root.event(&mut state, &wheel(1_000_000.0));
4158 assert_eq!(loads.get(), 2, "content growth rearms the load-newer edge");
4159 }
4160
4161 #[test]
4162 fn cancel_clears_a_pending_near_end_without_firing() {
4163 let mut w = near_end_widget(100.0);
4164 let mut state = Loads::default();
4165 // Simulate a fling-driven near-end recorded at paint time.
4166 w.pending_near_end = true;
4167 // A Cancel must drop it without invoking the callback.
4168 run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
4169 assert!(!w.pending_near_end);
4170 assert_eq!(state.count, 0, "Cancel never fires the callback");
4171
4172 // A pending fire is otherwise delivered on the next (non-Cancel) event.
4173 w.pending_near_end = true;
4174 run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
4175 assert!(!w.pending_near_end);
4176 assert_eq!(
4177 state.count, 1,
4178 "a pending fire is delivered on the next event"
4179 );
4180 }
4181
4182 // --- (8b) Overscroll and pull-to-refresh: `ListViewWidget::offset` (the
4183 // windowing offset) must never leave `[0, max_offset]`, only
4184 // `ListViewWidget::overscroll` may — mirrors `scroll.rs`'s own
4185 // overscroll/refresh test group, see the module docs' *Overscroll
4186 // and pull-to-refresh* section. ---
4187
4188 /// A stateless stand-in row, like [`gen_stub`], used by every test in this
4189 /// group (materialization-bounds assertions only, no per-row identity).
4190 fn overscroll_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
4191 move |_: &mut ()| list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
4192 }
4193
4194 /// [`overscroll_logic`] with the pre-seam [`RubberBand`] feel pinned
4195 /// explicitly — the fixture every *rubber-band* pin in this file builds
4196 /// from now that the widget's own default is the platform-adaptive physics
4197 /// (bouncing here, clamping on Android), each with curves of its own. A
4198 /// test whose assertions are a `0.5`-resisted displacement or a
4199 /// `SETTLE_DECAY` trace is pinning *this* physics, not the default; the
4200 /// `ScrollView` twin of this fixture is `scroll.rs`'s
4201 /// `laid_out_rubber_band`.
4202 fn rubber_band_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
4203 move |_: &mut ()| {
4204 list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i))).physics(RubberBand::new())
4205 }
4206 }
4207
4208 #[test]
4209 fn top_overscroll_resists_never_moves_the_windowing_offset_and_settles_with_no_refresh() {
4210 let refreshes = Rc::new(Cell::new(0u32));
4211 let refreshes_l = refreshes.clone();
4212 let mut logic = move |_: &mut ()| -> ListView<()> {
4213 let refreshes = refreshes_l.clone();
4214 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4215 .physics(RubberBand::new())
4216 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4217 };
4218 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4219 let mut state = ();
4220 let window = Size::new(200.0, 200.0);
4221 frame(&mut root, &mut logic, &mut state, window, 0.0);
4222 frame(&mut root, &mut logic, &mut state, window, 16.0);
4223
4224 // Down, then a drag downward crossing the slop takes the gesture over.
4225 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4226 frame(&mut root, &mut logic, &mut state, window, 32.0);
4227 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
4228 assert!(list_widget(&root).scrolling);
4229 assert_eq!(
4230 list_widget(&root).offset(),
4231 0.0,
4232 "the takeover move does not itself scroll"
4233 );
4234 frame(&mut root, &mut logic, &mut state, window, 48.0);
4235
4236 // Drag 20px further down past the already-at-top edge → resisted
4237 // overscroll, but the windowing offset stays put and no row
4238 // materializes before index 0.
4239 root.event(&mut state, &ev(PointerPhase::Move, 110.0));
4240 let w = list_widget(&root);
4241 assert_eq!(
4242 w.offset(),
4243 0.0,
4244 "the windowing offset never leaves [0, max]"
4245 );
4246 assert_eq!(
4247 w.overscroll, -10.0,
4248 "overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5), \
4249 matching ScrollView's own resistance exactly"
4250 );
4251 assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4252
4253 // Release under the trigger (|-10| < 64) → settles, never refreshes.
4254 root.event(&mut state, &ev(PointerPhase::Up, 110.0));
4255 let w = list_widget(&root);
4256 assert!(w.settling, "an overscrolled release settles, never flings");
4257 assert!(!w.is_flinging());
4258 assert_eq!(
4259 refreshes.get(),
4260 0,
4261 "release under the trigger does not refresh"
4262 );
4263
4264 // Pump frames until the settle completes.
4265 let mut ms = 64.0;
4266 for _ in 0..30 {
4267 frame(&mut root, &mut logic, &mut state, window, ms);
4268 ms += 16.0;
4269 if !list_widget(&root).settling {
4270 break;
4271 }
4272 }
4273 let w = list_widget(&root);
4274 assert!(!w.settling, "the settle terminated");
4275 assert_eq!(
4276 w.overscroll, 0.0,
4277 "the surface settles back to the clamped edge"
4278 );
4279 assert_eq!(w.offset(), 0.0);
4280 }
4281
4282 #[test]
4283 fn on_refresh_release_fires_once_past_the_trigger_and_only_on_release() {
4284 let refreshes = Rc::new(Cell::new(0u32));
4285 let refreshes_l = refreshes.clone();
4286 let mut logic = move |_: &mut ()| -> ListView<()> {
4287 let refreshes = refreshes_l.clone();
4288 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4289 .physics(RubberBand::new())
4290 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4291 };
4292 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4293 let mut state = ();
4294 let window = Size::new(200.0, 200.0);
4295 frame(&mut root, &mut logic, &mut state, window, 0.0);
4296 frame(&mut root, &mut logic, &mut state, window, 16.0);
4297
4298 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4299 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4300 frame(&mut root, &mut logic, &mut state, window, 32.0);
4301 root.event(&mut state, &ev(PointerPhase::Move, 290.0)); // raw -200 → -100
4302
4303 let w = list_widget(&root);
4304 assert_eq!(w.overscroll, -100.0, "past the trigger (|-100| > 64)");
4305 assert_eq!(
4306 w.offset(),
4307 0.0,
4308 "windowing offset stays 0 even far past the trigger"
4309 );
4310 assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4311 assert_eq!(refreshes.get(), 0, "no fire before release");
4312
4313 root.event(&mut state, &ev(PointerPhase::Up, 290.0));
4314 assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
4315 assert!(list_widget(&root).settling);
4316 }
4317
4318 #[test]
4319 fn bottom_overscroll_displaces_and_settles_but_never_fires_refresh() {
4320 let refreshes = Rc::new(Cell::new(0u32));
4321 let refreshes_l = refreshes.clone();
4322 let mut logic = move |_: &mut ()| -> ListView<()> {
4323 let refreshes = refreshes_l.clone();
4324 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4325 .physics(RubberBand::new())
4326 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4327 };
4328 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4329 let mut state = ();
4330 let window = Size::new(200.0, 200.0);
4331 frame(&mut root, &mut logic, &mut state, window, 0.0);
4332 frame(&mut root, &mut logic, &mut state, window, 16.0);
4333
4334 // Scroll to the very bottom first.
4335 root.event(&mut state, &wheel(1_000_000.0));
4336 frame(&mut root, &mut logic, &mut state, window, 32.0);
4337 let max_offset = list_widget(&root).max_offset();
4338 assert_eq!(list_widget(&root).offset(), max_offset);
4339
4340 // Down, then a drag *upward* crossing the slop takes the gesture over
4341 // (dragging the finger up exposes content past the bottom edge).
4342 root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4343 frame(&mut root, &mut logic, &mut state, window, 48.0);
4344 root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // 40px > slop → takeover
4345 assert_eq!(
4346 list_widget(&root).offset(),
4347 max_offset,
4348 "the takeover move does not itself scroll"
4349 );
4350 frame(&mut root, &mut logic, &mut state, window, 64.0);
4351
4352 // Drag 60px further up past the bottom edge → resisted overscroll,
4353 // the windowing offset pinned at max_offset, no row past the end.
4354 root.event(&mut state, &ev(PointerPhase::Move, 100.0));
4355 let w = list_widget(&root);
4356 assert_eq!(
4357 w.offset(),
4358 max_offset,
4359 "the windowing offset stays pinned at max_offset"
4360 );
4361 assert_eq!(
4362 w.overscroll, 30.0,
4363 "overscroll is the raw excess (60) * OVERSCROLL_RESISTANCE (0.5)"
4364 );
4365 assert_eq!(
4366 *w.window().last().unwrap(),
4367 999,
4368 "no row materializes past the last item"
4369 );
4370
4371 root.event(&mut state, &ev(PointerPhase::Up, 100.0));
4372 let w = list_widget(&root);
4373 assert!(
4374 w.settling,
4375 "a bottom overscroll release settles, never flings"
4376 );
4377 assert_eq!(
4378 refreshes.get(),
4379 0,
4380 "a bottom overscroll never fires refresh"
4381 );
4382
4383 let mut ms = 80.0;
4384 for _ in 0..30 {
4385 frame(&mut root, &mut logic, &mut state, window, ms);
4386 ms += 16.0;
4387 if !list_widget(&root).settling {
4388 break;
4389 }
4390 }
4391 let w = list_widget(&root);
4392 assert!(!w.settling);
4393 assert_eq!(w.overscroll, 0.0);
4394 assert_eq!(w.offset(), max_offset);
4395 assert_eq!(refreshes.get(), 0, "still never fired");
4396 }
4397
4398 #[test]
4399 fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
4400 let refreshes = Rc::new(Cell::new(0u32));
4401 let refreshes_l = refreshes.clone();
4402 let mut logic = move |_: &mut ()| -> ListView<()> {
4403 let refreshes = refreshes_l.clone();
4404 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4405 .physics(RubberBand::new())
4406 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4407 };
4408 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4409 let mut state = ();
4410 let window = Size::new(200.0, 200.0);
4411 frame(&mut root, &mut logic, &mut state, window, 0.0);
4412 frame(&mut root, &mut logic, &mut state, window, 16.0);
4413
4414 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4415 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4416 frame(&mut root, &mut logic, &mut state, window, 32.0);
4417 root.event(&mut state, &ev(PointerPhase::Move, 290.0)); // past trigger
4418 assert_eq!(list_widget(&root).overscroll, -100.0);
4419
4420 // A Cancel (gesture steal) must not fire on_refresh_release and snaps
4421 // the overscroll away with no settle animation.
4422 root.event(&mut state, &ev(PointerPhase::Cancel, 290.0));
4423 let w = list_widget(&root);
4424 assert_eq!(
4425 refreshes.get(),
4426 0,
4427 "Cancel never fires the refresh callback"
4428 );
4429 assert_eq!(
4430 w.overscroll, 0.0,
4431 "Cancel snaps the surface back into range"
4432 );
4433 assert!(!w.settling);
4434 assert_eq!(w.offset(), 0.0);
4435 }
4436
4437 #[test]
4438 fn wheel_never_overscrolls_past_either_edge_and_starts_no_settle() {
4439 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4440 let mut state = ();
4441 let window = Size::new(200.0, 200.0);
4442 let mut logic = overscroll_logic(1000);
4443 frame(&mut root, &mut logic, &mut state, window, 0.0);
4444 frame(&mut root, &mut logic, &mut state, window, 16.0);
4445
4446 // A large negative wheel delta at the top stays hard-clamped at 0.
4447 root.event(&mut state, &wheel(-5000.0));
4448 let w = list_widget(&root);
4449 assert_eq!(w.offset(), 0.0);
4450 assert_eq!(w.overscroll, 0.0, "wheel input never overscrolls the top");
4451 assert!(!w.settling, "wheel input starts no settle animation");
4452
4453 // A huge positive wheel delta at the bottom stays hard-clamped too.
4454 root.event(&mut state, &wheel(1_000_000.0));
4455 let w = list_widget(&root);
4456 assert_eq!(w.offset(), w.max_offset());
4457 assert_eq!(
4458 w.overscroll, 0.0,
4459 "wheel input never overscrolls the bottom"
4460 );
4461 assert!(!w.settling);
4462 }
4463
4464 #[test]
4465 fn near_start_and_refresh_fire_from_one_continuous_drag_sequence() {
4466 // Criterion 4: `on_near_start` fires on approach, then continuing the
4467 // same gesture past the top into overscroll and releasing fires
4468 // `on_refresh_release` too — two independent signals, one gesture.
4469 let loads = Rc::new(Cell::new(0u32));
4470 let refreshes = Rc::new(Cell::new(0u32));
4471 let (loads_l, refreshes_l) = (loads.clone(), refreshes.clone());
4472 let mut logic = move |_: &mut ()| -> ListView<()> {
4473 let (loads, refreshes) = (loads_l.clone(), refreshes_l.clone());
4474 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4475 .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
4476 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4477 };
4478 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4479 let mut state = ();
4480 let window = Size::new(200.0, 200.0);
4481 frame(&mut root, &mut logic, &mut state, window, 0.0);
4482 frame(&mut root, &mut logic, &mut state, window, 16.0);
4483
4484 // Start scrolled away from the top (150 > threshold, so approaching it
4485 // is a real edge crossing, not a trivial already-there state).
4486 root.event(&mut state, &wheel(150.0));
4487 frame(&mut root, &mut logic, &mut state, window, 32.0);
4488 assert_eq!(list_widget(&root).offset(), 150.0);
4489 assert_eq!(loads.get(), 0);
4490
4491 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4492 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
4493 frame(&mut root, &mut logic, &mut state, window, 48.0);
4494
4495 // Drag down 80px: offset 150 -> 70, inside the near-start threshold →
4496 // fires once, in range (no overscroll yet).
4497 root.event(&mut state, &ev(PointerPhase::Move, 170.0));
4498 let w = list_widget(&root);
4499 assert_eq!(w.offset(), 70.0);
4500 assert_eq!(w.overscroll, 0.0);
4501 assert_eq!(loads.get(), 1, "near-start fires on approach");
4502 assert_eq!(refreshes.get(), 0, "still in range, no refresh yet");
4503
4504 // Keep dragging the same gesture on down past the top, deep into
4505 // overscroll past the refresh trigger.
4506 root.event(&mut state, &ev(PointerPhase::Move, 500.0));
4507 let w = list_widget(&root);
4508 assert_eq!(w.offset(), 0.0, "windowing offset stays clamped");
4509 assert!(
4510 w.overscroll < -64.0,
4511 "well past the refresh trigger (REFRESH_TRIGGER_PX)"
4512 );
4513 assert_eq!(loads.get(), 1, "near-start does not re-fire mid-overscroll");
4514
4515 root.event(&mut state, &ev(PointerPhase::Up, 500.0));
4516 assert_eq!(
4517 refreshes.get(),
4518 1,
4519 "refresh fires on release, from the same gesture"
4520 );
4521 assert_eq!(loads.get(), 1, "and near-start's single fire still stands");
4522 }
4523
4524 // --- (8c) A converging `max_offset` during content growth (rule 5:
4525 // overscroll resistance always reads the *current* max_offset, never
4526 // one cached at takeover — the mechanism the *Variable extents*
4527 // docs section relies on; exercised here at the uniform level, since
4528 // `apply_drag_offset` reads `max_offset()` fresh regardless of
4529 // mode). ---
4530
4531 #[test]
4532 fn overscroll_resistance_tracks_a_max_offset_that_grows_mid_drag() {
4533 // A short list (10 rows * 50px = 500 content over a 200px viewport,
4534 // max_offset = 300) scrolled to the bottom, then overscrolled past it.
4535 let mut logic = rubber_band_logic(10);
4536 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4537 let mut state = ();
4538 let window = Size::new(200.0, 200.0);
4539 frame(&mut root, &mut logic, &mut state, window, 0.0);
4540 frame(&mut root, &mut logic, &mut state, window, 16.0);
4541
4542 root.event(&mut state, &wheel(1_000_000.0));
4543 frame(&mut root, &mut logic, &mut state, window, 32.0);
4544 assert_eq!(list_widget(&root).max_offset(), 300.0);
4545 assert_eq!(list_widget(&root).offset(), 300.0);
4546
4547 root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4548 root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
4549 frame(&mut root, &mut logic, &mut state, window, 48.0);
4550
4551 // Drag 60px further up past the (still 300px) bottom edge.
4552 root.event(&mut state, &ev(PointerPhase::Move, 100.0));
4553 let w = list_widget(&root);
4554 assert_eq!(w.offset(), 300.0);
4555 assert_eq!(
4556 w.overscroll, 30.0,
4557 "resisted against the old max_offset (300)"
4558 );
4559
4560 // Grow the content mid-drag (still `w.scrolling`): 20 rows now, content
4561 // 1000px, max_offset 800 — a rebuild interleaved into the same drag.
4562 logic = rubber_band_logic(20);
4563 frame(&mut root, &mut logic, &mut state, window, 64.0);
4564 assert_eq!(list_widget(&root).max_offset(), 800.0);
4565
4566 // The *same* continuing drag now reads the new, larger max_offset: 20
4567 // more px up lands the drag position back in range. The surface was
4568 // *showing* content y 330 (offset 300 + the 30px it was displaced by),
4569 // so the grown extent makes that 330 a real in-range offset and the
4570 // next 20px of finger takes it to 350 — the drag position is a
4571 // physics-mapped accumulator, so what the resistance already swallowed
4572 // is not handed back when the content grows under the finger.
4573 root.event(&mut state, &ev(PointerPhase::Move, 80.0));
4574 let w = list_widget(&root);
4575 assert_eq!(
4576 w.overscroll, 0.0,
4577 "the grown content absorbed what used to be overscroll"
4578 );
4579 assert_eq!(
4580 w.offset(),
4581 350.0,
4582 "the windowing offset advanced by exactly the finger delta, now in range"
4583 );
4584 }
4585
4586 // --- (8c) The physics seam: the default `RubberBand` install produces the
4587 // same numbers this widget has always produced, and `edge_pull`
4588 // tracks the displacement exactly while nothing is rejected (the
4589 // `ScrollView` twins of these two live in `scroll.rs`). ---
4590
4591 #[test]
4592 fn rubber_band_drag_mapping_matches_legacy_math() {
4593 let mut logic = rubber_band_logic(1000);
4594 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4595 let mut state = ();
4596 let window = Size::new(200.0, 200.0);
4597 frame(&mut root, &mut logic, &mut state, window, 0.0);
4598 frame(&mut root, &mut logic, &mut state, window, 16.0);
4599
4600 // In range: the drag delta passes through the physics untouched, into
4601 // the windowing offset, with no displacement at all.
4602 root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4603 root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
4604 root.event(&mut state, &ev(PointerPhase::Move, 100.0)); // 60px up
4605 let w = list_widget(&root);
4606 assert_eq!(w.offset(), 60.0, "an in-range drag maps one-for-one");
4607 assert_eq!(w.overscroll, 0.0);
4608 assert_eq!(w.edge_pull, 0.0);
4609 root.event(&mut state, &ev(PointerPhase::Cancel, 100.0));
4610
4611 // Past the top: half the raw excess shows, the windowing offset pinned.
4612 root.event(&mut state, &wheel(-1_000_000.0)); // back to the top edge
4613 frame(&mut root, &mut logic, &mut state, window, 32.0);
4614 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4615 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4616 root.event(&mut state, &ev(PointerPhase::Move, 110.0)); // 20px past the top
4617 let w = list_widget(&root);
4618 assert_eq!(w.offset(), 0.0, "the windowing offset never leaves range");
4619 assert_eq!(w.overscroll, -10.0, "raw excess (-20) halved by resistance");
4620 root.event(&mut state, &ev(PointerPhase::Cancel, 110.0));
4621
4622 // Past the bottom: the same rule against a fresh `max_offset`.
4623 root.event(&mut state, &wheel(1_000_000.0));
4624 frame(&mut root, &mut logic, &mut state, window, 32.0);
4625 let max_offset = list_widget(&root).max_offset();
4626 root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4627 root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
4628 root.event(&mut state, &ev(PointerPhase::Move, 100.0)); // 60px past the bottom
4629 let w = list_widget(&root);
4630 assert_eq!(w.offset(), max_offset);
4631 assert_eq!(w.overscroll, 30.0, "raw excess (60) halved by resistance");
4632 }
4633
4634 #[test]
4635 fn edge_pull_equals_overscroll_under_rubber_band() {
4636 let mut logic = rubber_band_logic(1000);
4637 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4638 let mut state = ();
4639 let window = Size::new(200.0, 200.0);
4640 frame(&mut root, &mut logic, &mut state, window, 0.0);
4641 frame(&mut root, &mut logic, &mut state, window, 16.0);
4642
4643 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4644 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4645 root.event(&mut state, &ev(PointerPhase::Move, 110.0)); // 20px past the top
4646 let w = list_widget(&root);
4647 assert_eq!(w.overscroll, -10.0);
4648 assert_eq!(
4649 w.edge_pull, -10.0,
4650 "nothing rejected → the pull is the displacement, same sign"
4651 );
4652
4653 // Release, then settle: both decay together and both reach zero.
4654 root.event(&mut state, &ev(PointerPhase::Up, 110.0));
4655 assert!(list_widget(&root).settling);
4656 let mut ms = 32.0;
4657 let mut eased = false;
4658 for _ in 0..40 {
4659 frame(&mut root, &mut logic, &mut state, window, ms);
4660 ms += 16.0;
4661 let w = list_widget(&root);
4662 assert_eq!(
4663 w.edge_pull, w.overscroll,
4664 "the pull tracks the displacement through the whole settle"
4665 );
4666 if w.overscroll < 0.0 && w.overscroll > -10.0 {
4667 eased = true;
4668 }
4669 if !w.settling {
4670 break;
4671 }
4672 }
4673 assert!(eased, "the settle eased through intermediate values");
4674 let w = list_widget(&root);
4675 assert!(!w.settling, "the settle terminates");
4676 assert_eq!(w.overscroll, 0.0);
4677 assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
4678 }
4679
4680 // --- The platform default (`physics::default_physics`): bouncing on this
4681 // host, clamping on Android. The `ScrollView` twins of this group
4682 // live in `scroll.rs`; these pin that the windowing/displacement
4683 // split reaches the same numbers through this widget's own drag
4684 // path. ---
4685
4686 fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
4687 assert!(
4688 (actual - expected).abs() < epsilon,
4689 "{what}: {actual} is not within {epsilon} of {expected}"
4690 );
4691 }
4692
4693 #[test]
4694 fn a_fresh_list_installs_the_platform_default() {
4695 let mut logic = overscroll_logic(1000);
4696 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4697 frame(&mut root, &mut logic, &mut (), Size::new(200.0, 200.0), 0.0);
4698 let w = list_widget(&root);
4699 assert_eq!(
4700 format!("{:?}", w.physics),
4701 format!("{:?}", crate::physics::default_physics())
4702 );
4703 assert_eq!(w.effect, crate::physics::default_overscroll_effect());
4704 }
4705
4706 #[test]
4707 fn default_drag_tension_tightens_with_depth() {
4708 let mut logic = overscroll_logic(1000);
4709 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4710 let mut state = ();
4711 let window = Size::new(200.0, 200.0);
4712 frame(&mut root, &mut logic, &mut state, window, 0.0);
4713 frame(&mut root, &mut logic, &mut state, window, 16.0);
4714
4715 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4716 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
4717 frame(&mut root, &mut logic, &mut state, window, 32.0);
4718
4719 // 20px past the top from zero depth: 20 · 0.52 = 10.4 displaced, and
4720 // the windowing offset still pinned at 0.
4721 root.event(&mut state, &ev(PointerPhase::Move, 110.0));
4722 let first = list_widget(&root).overscroll;
4723 assert_eq!(list_widget(&root).offset(), 0.0);
4724 assert_close(
4725 first,
4726 -20.0 * DecelerationRate::NORMAL_FRICTION,
4727 1e-12,
4728 "the first past-edge move",
4729 );
4730
4731 // 20px more, now 10.4px deep in a 200px viewport: the factor tightens
4732 // to 0.52·(1 − 0.052)² = 0.46732608, adding 9.3465216 for a total of
4733 // 19.7465216.
4734 root.event(&mut state, &ev(PointerPhase::Move, 130.0));
4735 let w = list_widget(&root);
4736 assert_close(w.overscroll, -19.746_521_6, 1e-9, "the accumulated pull");
4737 assert!(
4738 (w.overscroll - first).abs() < first.abs(),
4739 "the deeper pull displaces less per raw px"
4740 );
4741 assert_eq!(w.offset(), 0.0, "…and the windowing offset never moves");
4742 assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4743 }
4744
4745 /// The signed start velocity of whatever post-release motion the last `Up`
4746 /// produced — the physics-supplied curve's own, the legacy fling's, or
4747 /// `0.0` for a release that started no motion at all. The `ScrollView`
4748 /// twin of this helper reads the same two fields.
4749 fn release_velocity(w: &ListViewWidget) -> f64 {
4750 match w.ballistic.as_ref() {
4751 Some(state) => state.sim.dx(0.0),
4752 None => w.fling.unwrap_or(0.0),
4753 }
4754 }
4755
4756 /// The `ScrollView` twin of this test lives in `scroll.rs`, beside
4757 /// `default_carried_momentum_compounds_a_refling` — the same-direction pin
4758 /// this one is the mirror image of.
4759 #[test]
4760 fn reverse_refling_keeps_the_fingers_velocity() {
4761 // A bare list (no rows materialized — this pins release velocity, not
4762 // windowing), parked mid-content so neither edge is in play.
4763 let mut w = ListViewWidget::new(1000, 50.0);
4764 w.viewport = Size::new(200.0, 200.0);
4765 dispatch_list(&mut w, &wheel(1000.0), 0.0);
4766 assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
4767
4768 // A downward fling: 50px of finger travel up over 32ms.
4769 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4770 dispatch_list(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
4771 dispatch_list(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
4772 dispatch_list(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
4773 assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
4774
4775 // The finger lands on that live curve and flicks back the other way,
4776 // just as fast. The interrupted motion's momentum must not be added to
4777 // a release pointing the other way — it would cancel the flick out (or
4778 // reverse it), and the list would ignore the finger entirely.
4779 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
4780 dispatch_list(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); // takeover
4781 dispatch_list(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
4782 dispatch_list(&mut w, &ev(PointerPhase::Up, 150.0), 80.0);
4783 assert_close(
4784 release_velocity(&w),
4785 -1562.5,
4786 1e-9,
4787 "the reverse re-fling runs at the finger's own velocity",
4788 );
4789 assert!(
4790 w.ballistic.is_some(),
4791 "…as a real ballistic curve, not a stalled remnant"
4792 );
4793 }
4794
4795 /// The `ScrollView` twins of this pair live in `scroll.rs`
4796 /// (`momentum_retain_threshold_refuses_a_weak_refling`): the retain gate
4797 /// compares a release against the physics' **mapped** share of the
4798 /// interrupted velocity, not the raw interrupted speed. Interrupted at
4799 /// 1000 px/s, `Bouncing::new().carried_momentum(1000.0)` maps to ~649.7,
4800 /// putting the retain threshold at ~324.8 — well under the raw-carried
4801 /// threshold (500) the pre-fix gate used.
4802 #[test]
4803 fn momentum_retain_threshold_refuses_a_weak_refling() {
4804 let mut w = ListViewWidget::new(1000, 50.0);
4805 w.viewport = Size::new(200.0, 200.0);
4806 dispatch_list(&mut w, &wheel(1000.0), 0.0);
4807 assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
4808
4809 // Interrupted motion at 1000 px/s.
4810 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4811 dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
4812 dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
4813 dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
4814 assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
4815
4816 let mapped = Bouncing::new().carried_momentum(1000.0);
4817 let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
4818 assert!(
4819 (300.0..350.0).contains(&threshold),
4820 "the fixture's release values must straddle the threshold: {threshold}"
4821 );
4822
4823 // Same-direction re-flick at 300 px/s — under the mapped threshold.
4824 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
4825 dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
4826 dispatch_list(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); // 30px / 100ms → 300 px/s
4827 dispatch_list(&mut w, &ev(PointerPhase::Up, 70.0), 148.0);
4828 assert_close(
4829 release_velocity(&w),
4830 300.0,
4831 1e-9,
4832 "a release under the mapped threshold carries nothing forward",
4833 );
4834 }
4835
4836 /// The strong-side twin of `momentum_retain_threshold_refuses_a_weak_refling`:
4837 /// a release over the same mapped threshold carries `mapped` forward
4838 /// exactly, pre-clamp.
4839 #[test]
4840 fn momentum_retain_threshold_carries_a_strong_refling() {
4841 let mut w = ListViewWidget::new(1000, 50.0);
4842 w.viewport = Size::new(200.0, 200.0);
4843 dispatch_list(&mut w, &wheel(1000.0), 0.0);
4844 assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
4845
4846 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4847 dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
4848 dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
4849 dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
4850 assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
4851
4852 let mapped = Bouncing::new().carried_momentum(1000.0);
4853 let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
4854 assert!(
4855 (300.0..350.0).contains(&threshold),
4856 "the fixture's release values must straddle the threshold: {threshold}"
4857 );
4858
4859 // Same-direction re-flick at 350 px/s — over the mapped threshold.
4860 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
4861 dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
4862 dispatch_list(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); // 35px / 100ms → 350 px/s
4863 dispatch_list(&mut w, &ev(PointerPhase::Up, 65.0), 148.0);
4864 assert_close(
4865 release_velocity(&w),
4866 350.0 + mapped,
4867 1e-9,
4868 "a release over the mapped threshold carries `mapped` forward exactly",
4869 );
4870 }
4871
4872 // --- Pull-to-refresh under both shipped defaults — the twins of
4873 // `scroll.rs`'s pair, over this widget's own `edge_pull`. ---
4874
4875 #[test]
4876 fn refresh_trigger_under_the_bouncing_default() {
4877 let refreshes = Rc::new(Cell::new(0u32));
4878 let refreshes_l = refreshes.clone();
4879 let mut logic = move |_: &mut ()| -> ListView<()> {
4880 let refreshes = refreshes_l.clone();
4881 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4882 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4883 };
4884 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4885 let mut state = ();
4886 let window = Size::new(200.0, 200.0);
4887 frame(&mut root, &mut logic, &mut state, window, 0.0);
4888 frame(&mut root, &mut logic, &mut state, window, 16.0);
4889
4890 // 110px of raw pull at zero depth maps to 57.2 — under the trigger.
4891 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4892 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4893 frame(&mut root, &mut logic, &mut state, window, 32.0);
4894 root.event(&mut state, &ev(PointerPhase::Move, 200.0));
4895 let w = list_widget(&root);
4896 assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
4897 assert_eq!(
4898 w.edge_pull, w.overscroll,
4899 "a bouncing surface rejects nothing, so the pull IS the displacement"
4900 );
4901 root.event(&mut state, &ev(PointerPhase::Up, 200.0));
4902 assert_eq!(refreshes.get(), 0, "release under the trigger never fires");
4903
4904 // 150px of raw pull maps to 78.0 — past it.
4905 root.event(&mut state, &ev(PointerPhase::Cancel, 200.0));
4906 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4907 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4908 frame(&mut root, &mut logic, &mut state, window, 48.0);
4909 root.event(&mut state, &ev(PointerPhase::Move, 240.0));
4910 let w = list_widget(&root);
4911 assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
4912 assert!(crossed_refresh_trigger(w.edge_pull));
4913 assert_eq!(refreshes.get(), 0, "no fire before release");
4914 root.event(&mut state, &ev(PointerPhase::Up, 240.0));
4915 assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
4916 }
4917
4918 #[test]
4919 fn refresh_trigger_under_a_clamping_physics() {
4920 // Android's default, simulated on the host.
4921 let refreshes = Rc::new(Cell::new(0u32));
4922 let refreshes_l = refreshes.clone();
4923 let mut logic = move |_: &mut ()| -> ListView<()> {
4924 let refreshes = refreshes_l.clone();
4925 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4926 .physics(Clamping::new())
4927 .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4928 };
4929 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4930 let mut state = ();
4931 let window = Size::new(200.0, 200.0);
4932 frame(&mut root, &mut logic, &mut state, window, 0.0);
4933 frame(&mut root, &mut logic, &mut state, window, 16.0);
4934
4935 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4936 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4937 frame(&mut root, &mut logic, &mut state, window, 32.0);
4938 root.event(&mut state, &ev(PointerPhase::Move, 140.0));
4939 let w = list_widget(&root);
4940 assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
4941 assert_eq!(w.overscroll, 0.0);
4942 assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
4943 root.event(&mut state, &ev(PointerPhase::Up, 140.0));
4944 assert_eq!(refreshes.get(), 0, "50px of raw pull is under the trigger");
4945
4946 root.event(&mut state, &ev(PointerPhase::Cancel, 140.0));
4947 root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4948 root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4949 frame(&mut root, &mut logic, &mut state, window, 48.0);
4950 root.event(&mut state, &ev(PointerPhase::Move, 190.0));
4951 let w = list_widget(&root);
4952 assert_eq!(w.offset(), 0.0);
4953 assert_eq!(w.overscroll, 0.0);
4954 assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
4955 assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4956 root.event(&mut state, &ev(PointerPhase::Up, 190.0));
4957 assert_eq!(refreshes.get(), 1, "past 64px of raw pull, it fires once");
4958 assert!(
4959 list_widget(&root).settling,
4960 "the rejected pull settles rather than springs — nothing displaced"
4961 );
4962
4963 let mut ms = 64.0;
4964 for _ in 0..40 {
4965 frame(&mut root, &mut logic, &mut state, window, ms);
4966 ms += 16.0;
4967 if !list_widget(&root).settling {
4968 break;
4969 }
4970 }
4971 let w = list_widget(&root);
4972 assert!(!w.settling, "the settle terminated");
4973 assert_eq!(w.edge_pull, 0.0, "…leaving no pull for a stretch to paint");
4974 }
4975
4976 // --- (07) The public builder surface: `.physics(...)` — the ListView twin
4977 // of `scroll.rs`'s own group of the same name. ---
4978
4979 #[test]
4980 fn physics_builder_installs_custom_physics() {
4981 let window = Size::new(200.0, 200.0);
4982
4983 // A ListView built with `.physics(NeverScrollable::new())`: a drag
4984 // past slop does not scroll.
4985 let mut never_logic = move |_: &mut ()| -> ListView<()> {
4986 list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i))).physics(NeverScrollable::new())
4987 };
4988 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4989 let mut state = ();
4990 frame(&mut root, &mut never_logic, &mut state, window, 0.0);
4991 frame(&mut root, &mut never_logic, &mut state, window, 16.0);
4992 root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4993 root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // would cross slop
4994 root.event(&mut state, &ev(PointerPhase::Move, 100.0));
4995 let w = list_widget(&root);
4996 assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
4997 assert!(
4998 !w.scrolling,
4999 "NeverScrollable must never take the gesture over"
5000 );
5001
5002 // A default-built twin (no `.physics(...)` call) still scrolls
5003 // normally under `RubberBand` — same numbers as
5004 // `rubber_band_drag_mapping_matches_legacy_math`.
5005 let mut default_logic = overscroll_logic(1000);
5006 let mut default_root: RenderRoot<(), ListView<()>> = RenderRoot::new();
5007 let mut default_state = ();
5008 frame(
5009 &mut default_root,
5010 &mut default_logic,
5011 &mut default_state,
5012 window,
5013 0.0,
5014 );
5015 frame(
5016 &mut default_root,
5017 &mut default_logic,
5018 &mut default_state,
5019 window,
5020 16.0,
5021 );
5022 default_root.event(&mut default_state, &ev(PointerPhase::Down, 200.0));
5023 default_root.event(&mut default_state, &ev(PointerPhase::Move, 160.0));
5024 default_root.event(&mut default_state, &ev(PointerPhase::Move, 100.0));
5025 assert_eq!(
5026 list_widget(&default_root).offset(),
5027 60.0,
5028 "the default twin scrolls normally"
5029 );
5030 }
5031
5032 // --- (8d) Variable-extent overscroll: the same bounds hold when rows
5033 // size themselves (see the *Variable extents* module docs section
5034 // for `estimated_item_extent`). ---
5035
5036 #[test]
5037 fn variable_extent_top_overscroll_never_materializes_before_index_zero() {
5038 let fx = VarRows::new(tall_ids(60));
5039 let mut logic = fx.logic_rubber_band();
5040 let mut root = converged_var(&mut logic, &fx);
5041 assert_eq!(list_widget(&root).window()[0], 0);
5042
5043 root.event(&mut (), &ev(PointerPhase::Down, 50.0));
5044 root.event(&mut (), &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
5045 var_frame(&mut root, &mut logic, &fx, 100.0);
5046
5047 // Drag past the top edge.
5048 root.event(&mut (), &ev(PointerPhase::Move, 150.0));
5049 let w = list_widget(&root);
5050 assert_eq!(
5051 w.offset(),
5052 0.0,
5053 "the windowing offset never leaves [0, max] in variable-extent mode either"
5054 );
5055 assert!(w.overscroll < 0.0, "a past-top drag overscrolls");
5056 assert_eq!(w.window()[0], 0, "no row materializes before index 0");
5057
5058 root.event(&mut (), &ev(PointerPhase::Up, 150.0));
5059 assert!(
5060 list_widget(&root).settling,
5061 "an overscrolled release settles in variable-extent mode too"
5062 );
5063
5064 // Pump frames until the settle completes.
5065 for n in 0..60 {
5066 var_frame(&mut root, &mut logic, &fx, 116.0 + 16.0 * n as f64);
5067 if !list_widget(&root).settling {
5068 break;
5069 }
5070 }
5071 let w = list_widget(&root);
5072 assert!(!w.settling);
5073 assert_eq!(w.overscroll, 0.0);
5074 assert_eq!(w.offset(), 0.0);
5075 }
5076
5077 // --- (8e) Nested scrolling: innermost-wins arbitration with this widget as
5078 // the OUTER surface, over a row that owns a scroll of its own. The
5079 // mirror case (a nested `ListView` under a `ScrollView`) lives beside
5080 // the seam itself, in `scroll.rs`. See the module docs' *Nested
5081 // scrolling*. ---
5082
5083 /// A bare list for the nested-scroll fixtures: `count` rows of `extent` px
5084 /// in a `viewport_h`-tall viewport, no rows materialized yet ([`wire_row`]
5085 /// installs the one that matters).
5086 fn nested_list(count: usize, extent: f64, viewport_h: f64) -> ListViewWidget {
5087 let mut w = ListViewWidget::new(count, extent);
5088 w.viewport = Size::new(200.0, viewport_h);
5089 w
5090 }
5091
5092 /// Build and lay out the nested `ScrollView` a row hosts: a
5093 /// `viewport_h`-tall viewport over 1000px of plain content. Laid out tight
5094 /// because an enclosing scroll surface hands its child unbounded height,
5095 /// which would leave this one viewport == content with nothing to scroll —
5096 /// a real row's own extent is what bounds it, and [`wire_row`] re-applies
5097 /// exactly that.
5098 fn nested_scroll(viewport_h: f64) -> ScrollWidget {
5099 let view: crate::scroll::ScrollView<()> =
5100 crate::scroll::scroll_view(crate::test_support::leaf(200.0, 1000.0));
5101 let mut counter = 0u64;
5102 let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
5103 let mut lctx = LayoutCtx::new();
5104 w.layout(
5105 &mut lctx,
5106 &BoxConstraints::tight(Size::new(200.0, viewport_h)),
5107 );
5108 w
5109 }
5110
5111 /// Park a nested surface at `offset` px with a wheel scroll (hard-clamped,
5112 /// no overscroll and no gesture state) — how a real one reaches a
5113 /// mid-content position.
5114 fn park_scroll(w: &mut ScrollWidget, viewport_h: f64, offset: f64) {
5115 let mut unit = ();
5116 let sa: &mut dyn Any = &mut unit;
5117 let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
5118 w.event(
5119 &mut ctx,
5120 &InputEvent::Scroll {
5121 position: Point::new(10.0, 10.0),
5122 delta: ScrollDelta::Pixels(0.0, offset),
5123 },
5124 );
5125 assert_eq!(w.offset(), offset, "the fixture parked where it meant to");
5126 }
5127
5128 /// Wire `row` in as the list's single materialized row for item `index`,
5129 /// laid out tight at the list's own item extent so the pod carries the
5130 /// bounds `route_event` hit-tests against — and so the nested surface gets
5131 /// the bounded viewport a real row layout hands it.
5132 fn wire_row(outer: &mut ListViewWidget, index: usize, row: Box<dyn Widget>) {
5133 let mut pod = ChildPod::new(row);
5134 let mut lctx = LayoutCtx::new();
5135 pod.layout_child(
5136 &mut lctx,
5137 &BoxConstraints::tight(Size::new(200.0, outer.item_extent)),
5138 );
5139 outer.children = vec![pod];
5140 outer.keys = vec![index];
5141 outer.sync_child_origins();
5142 }
5143
5144 /// The nested surface [`wire_row`] installed as the list's one row.
5145 fn row_scroll(outer: &ListViewWidget) -> &ScrollWidget {
5146 (outer.children[0].widget() as &dyn Any)
5147 .downcast_ref::<ScrollWidget>()
5148 .expect("the fixture wired a ScrollWidget row")
5149 }
5150
5151 fn dispatch_list(w: &mut ListViewWidget, e: &InputEvent, t_ms: f64) {
5152 let mut unit = ();
5153 let sa: &mut dyn Any = &mut unit;
5154 let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
5155 w.event_at(&mut ctx, e, t_ms);
5156 }
5157
5158 #[test]
5159 fn list_view_outer_defers_when_the_nested_surface_can_consume() {
5160 // A 200px viewport scrolled to row 2 (content y 400..600, so the row
5161 // sits exactly over the viewport), that row hosting a scroll surface
5162 // parked mid-content — at neither of its own edges, so its claim comes
5163 // from actual room rather than a displacement-allowing physics.
5164 let mut outer = nested_list(10, 200.0, 200.0);
5165 outer.offset = 400.0;
5166 let mut row = nested_scroll(200.0);
5167 park_scroll(&mut row, 200.0, 300.0);
5168 wire_row(&mut outer, 2, Box::new(row));
5169
5170 dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
5171 assert!(
5172 outer.inner_at_down.registered,
5173 "the row's scroll surface reported itself on the routed Down"
5174 );
5175 assert!(outer.inner_at_down.can_consume_up_drag);
5176
5177 // 50px of finger-up drag, past the slop: the list stands down.
5178 dispatch_list(&mut outer, &ev(PointerPhase::Move, 50.0), 16.0);
5179 assert!(outer.deferring);
5180 assert!(!outer.scrolling, "the list never took the gesture over");
5181 assert_eq!(outer.offset, 400.0);
5182 assert!(
5183 outer.children[0].is_active(),
5184 "no takeover Cancel went out — the row keeps its capture"
5185 );
5186
5187 // The rest of the drag lands in the row, not in the list.
5188 dispatch_list(&mut outer, &ev(PointerPhase::Move, 10.0), 32.0);
5189 assert_eq!(outer.offset, 400.0, "the list still has not moved");
5190 assert_eq!(outer.overscroll, 0.0);
5191 assert_eq!(
5192 row_scroll(&outer).offset(),
5193 340.0,
5194 "the nested surface consumed the 40px"
5195 );
5196 }
5197
5198 #[test]
5199 fn list_view_outer_takes_over_when_the_nested_surface_is_pinned() {
5200 let mut outer = nested_list(10, 200.0, 200.0);
5201 outer.offset = 400.0;
5202 let mut row = nested_scroll(200.0);
5203 // At its own top under a physics that rejects every past-edge
5204 // proposal: a downward drag has nothing to do there.
5205 row.physics = Rc::new(crate::physics::parity::Clamping::new());
5206 wire_row(&mut outer, 2, Box::new(row));
5207
5208 dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
5209 assert!(outer.inner_at_down.registered);
5210 assert!(!outer.inner_at_down.can_consume_down_drag);
5211
5212 // 40px down, past the slop: the list takes over exactly as it always
5213 // has, cancelling the row on the way.
5214 dispatch_list(&mut outer, &ev(PointerPhase::Move, 140.0), 16.0);
5215 assert!(outer.scrolling);
5216 assert!(!outer.deferring);
5217 assert!(
5218 !outer.children[0].is_active(),
5219 "the takeover Cancel released the row's capture"
5220 );
5221
5222 dispatch_list(&mut outer, &ev(PointerPhase::Move, 200.0), 32.0);
5223 assert_eq!(outer.offset, 340.0, "the list consumed the 60px of drag");
5224 assert_eq!(outer.overscroll, 0.0, "…in range, so no displacement");
5225 assert_eq!(
5226 row_scroll(&outer).offset(),
5227 0.0,
5228 "the nested surface never moved"
5229 );
5230 }
5231
5232 // --- (9) Keyed reconciliation: row state follows the stable key. ---
5233
5234 /// Row height and viewport used by every keyed test: 200 / 50 = 4 visible
5235 /// rows + 2×[`BUFFER`], so a list of ≤ 6 rows materializes whole and a longer
5236 /// one virtualizes.
5237 const ROW_EXTENT: f64 = 50.0;
5238 const KEYED_WINDOW: Size = Size::new(200.0, 200.0);
5239
5240 /// What a keyed row reports about itself: which row id each live widget
5241 /// painted, and which rows were torn down.
5242 #[derive(Default)]
5243 struct RowLog {
5244 /// row id -> the build generation of the widget that painted it.
5245 painted: RefCell<HashMap<u64, u64>>,
5246 /// row ids whose pods were torn down, in teardown order.
5247 torn: RefCell<Vec<u64>>,
5248 }
5249
5250 impl RowLog {
5251 /// This frame's `row id -> generation` map (the paint log is cleared at
5252 /// the top of every keyed frame, so it describes exactly one frame).
5253 fn painted(&self) -> HashMap<u64, u64> {
5254 self.painted.borrow().clone()
5255 }
5256
5257 fn torn(&self) -> Vec<u64> {
5258 self.torn.borrow().clone()
5259 }
5260 }
5261
5262 /// A keyed-row fixture. The row carries a stable `id`; its widget is stamped
5263 /// with a generation from a shared counter (like [`GenView`]) so a relocated —
5264 /// i.e. state-preserving — row keeps its stamp while a rebuilt-fresh row gets
5265 /// a new one, and its teardown is recorded.
5266 ///
5267 /// This is the host-side stand-in for a hosted `Component`: the generation is
5268 /// the retained per-row state, and `View::teardown` is exactly the path a
5269 /// hosted component's owner disposal rides on (`ComponentWidget::teardown`),
5270 /// so "the removed row's pod was disposed" is observable here without pulling
5271 /// the reactive runtime into this signal-free crate.
5272 struct RowView {
5273 id: u64,
5274 gens: Rc<Cell<u64>>,
5275 log: Rc<RowLog>,
5276 }
5277
5278 struct RowWidget {
5279 id: u64,
5280 generation: u64,
5281 log: Rc<RowLog>,
5282 }
5283
5284 impl View<()> for RowView {
5285 type Element = RowWidget;
5286
5287 fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowWidget {
5288 let generation = self.gens.get();
5289 self.gens.set(generation + 1);
5290 RowWidget {
5291 id: self.id,
5292 generation,
5293 log: self.log.clone(),
5294 }
5295 }
5296
5297 fn rebuild(
5298 &self,
5299 _prev: &Self,
5300 element: &mut RowWidget,
5301 _ctx: &mut BuildCtx<'_>,
5302 ) -> ChangeFlags {
5303 // A same-type in-place rebuild keeps the generation (state preserved);
5304 // only the content this row renders is updated.
5305 element.id = self.id;
5306 ChangeFlags::NONE
5307 }
5308
5309 fn teardown(&self, element: &mut RowWidget, _ctx: &mut BuildCtx<'_>) {
5310 element.log.torn.borrow_mut().push(element.id);
5311 }
5312 }
5313
5314 impl Widget for RowWidget {
5315 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
5316 bc.constrain(bc.max())
5317 }
5318 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
5319 self.log
5320 .painted
5321 .borrow_mut()
5322 .insert(self.id, self.generation);
5323 }
5324 }
5325
5326 /// A mutable backing vec of row ids plus the shared generation counter and
5327 /// log — the whole fixture one keyed test drives.
5328 struct KeyedRows {
5329 rows: Rc<RefCell<Vec<u64>>>,
5330 gens: Rc<Cell<u64>>,
5331 log: Rc<RowLog>,
5332 }
5333
5334 impl KeyedRows {
5335 fn new(ids: Vec<u64>) -> Self {
5336 Self {
5337 rows: Rc::new(RefCell::new(ids)),
5338 gens: Rc::new(Cell::new(0)),
5339 log: Rc::new(RowLog::default()),
5340 }
5341 }
5342
5343 /// The app-logic closure for a **keyed** list. Each frame captures a fresh
5344 /// *snapshot* of the row ids into both `key_of` and `builder` — the real
5345 /// app shape, and the one the reconciler depends on: the retained view of
5346 /// the previous frame must still reconstruct the *previous* frame's rows
5347 /// (`prev.builder(prev_index)`), which a shared live handle would not.
5348 fn keyed_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
5349 let rows = self.rows.clone();
5350 let gens = self.gens.clone();
5351 let log = self.log.clone();
5352 move |_: &mut ()| {
5353 let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
5354 let (keys, items) = (snapshot.clone(), snapshot.clone());
5355 let (gens, log) = (gens.clone(), log.clone());
5356 ListView::builder_keyed(
5357 snapshot.len(),
5358 ROW_EXTENT,
5359 move |i| ChildKey::new(keys[i]),
5360 move |i| {
5361 any::<(), _>(RowView {
5362 id: items[i],
5363 gens: gens.clone(),
5364 log: log.clone(),
5365 })
5366 },
5367 )
5368 }
5369 }
5370
5371 /// The same list built **positionally** — the contrast case that pins the
5372 /// documented index-identity failure the keyed path fixes.
5373 fn positional_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
5374 let rows = self.rows.clone();
5375 let gens = self.gens.clone();
5376 let log = self.log.clone();
5377 move |_: &mut ()| {
5378 let items: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
5379 let (gens, log) = (gens.clone(), log.clone());
5380 ListView::builder(items.len(), ROW_EXTENT, move |i| {
5381 any::<(), _>(RowView {
5382 id: items[i],
5383 gens: gens.clone(),
5384 log: log.clone(),
5385 })
5386 })
5387 }
5388 }
5389 }
5390
5391 /// Drive one frame with the log cleared first, so it describes exactly this
5392 /// frame's materialized rows and this frame's teardowns.
5393 fn keyed_frame(
5394 root: &mut RenderRoot<(), ListView<()>>,
5395 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
5396 log: &RowLog,
5397 ms: f64,
5398 ) -> ChangeFlags {
5399 log.painted.borrow_mut().clear();
5400 log.torn.borrow_mut().clear();
5401 frame(root, logic, &mut (), KEYED_WINDOW, ms)
5402 }
5403
5404 /// Build a root and converge its window (build frame + real-viewport frame).
5405 fn converged(
5406 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
5407 log: &RowLog,
5408 ) -> RenderRoot<(), ListView<()>> {
5409 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
5410 keyed_frame(&mut root, logic, log, 0.0);
5411 keyed_frame(&mut root, logic, log, 16.0);
5412 root
5413 }
5414
5415 #[test]
5416 fn keyed_mid_list_insert_keeps_each_row_with_its_key() {
5417 let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
5418 let mut logic = fx.keyed_logic();
5419 let mut root = converged(&mut logic, &fx.log);
5420 let before = fx.log.painted();
5421 assert_eq!(before.len(), 5, "all five rows fit the viewport");
5422
5423 // Insert in the MIDDLE: every row after it shifts one index down.
5424 fx.rows.borrow_mut().insert(2, 25);
5425 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5426
5427 let after = fx.log.painted();
5428 for id in [10, 20, 30, 40, 50] {
5429 assert_eq!(
5430 after[&id], before[&id],
5431 "row {id} kept its own widget (and its state) across the insert"
5432 );
5433 }
5434 let newest = before.values().copied().max().expect("rows painted");
5435 assert!(
5436 after[&25] > newest,
5437 "the inserted row is built fresh, not handed a survivor's widget"
5438 );
5439 assert!(
5440 fx.log.torn().is_empty(),
5441 "no key left the window, so nothing is torn down"
5442 );
5443 assert!(
5444 flags.contains(ChangeFlags::LAYOUT),
5445 "a frame that materialized a new pod owes a layout pass"
5446 );
5447 }
5448
5449 #[test]
5450 fn positional_mid_list_insert_still_misattaches_row_state() {
5451 // The documented index-identity failure `builder_keyed` exists to fix,
5452 // pinned so "the positional path is unchanged" is a test, not a claim.
5453 let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
5454 let mut logic = fx.positional_logic();
5455 let mut root = converged(&mut logic, &fx.log);
5456 let before = fx.log.painted();
5457
5458 fx.rows.borrow_mut().insert(2, 25);
5459 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5460
5461 let after = fx.log.painted();
5462 assert_ne!(
5463 after[&30], before[&30],
5464 "positional identity leaves row 30's state behind at index 2"
5465 );
5466 assert_eq!(
5467 after[&25], before[&30],
5468 "and hands it to whatever content now occupies that index"
5469 );
5470 }
5471
5472 #[test]
5473 fn keyed_mid_list_remove_tears_down_only_the_removed_row() {
5474 let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
5475 let mut logic = fx.keyed_logic();
5476 let mut root = converged(&mut logic, &fx.log);
5477 let before = fx.log.painted();
5478
5479 fx.rows.borrow_mut().remove(2); // drop row 30 from the middle
5480 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5481
5482 let after = fx.log.painted();
5483 assert_eq!(
5484 fx.log.torn(),
5485 vec![30],
5486 "exactly the removed row's pod is torn down (its owner disposed)"
5487 );
5488 assert!(!after.contains_key(&30), "row 30 no longer paints");
5489 for id in [10, 20, 40, 50] {
5490 assert_eq!(
5491 after[&id], before[&id],
5492 "row {id} kept its own widget across the removal"
5493 );
5494 }
5495 assert!(
5496 flags.contains(ChangeFlags::LAYOUT),
5497 "a frame that tore a pod down owes a layout pass"
5498 );
5499 }
5500
5501 #[test]
5502 fn keyed_reorder_relocates_rows_and_reports_layout() {
5503 let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
5504 let mut logic = fx.keyed_logic();
5505 let mut root = converged(&mut logic, &fx.log);
5506 let before = fx.log.painted();
5507
5508 fx.rows.borrow_mut().reverse();
5509 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5510
5511 let after = fx.log.painted();
5512 for id in [10, 20, 30, 40, 50] {
5513 assert_eq!(
5514 after[&id], before[&id],
5515 "row {id} moved slot but kept its own widget"
5516 );
5517 }
5518 assert!(
5519 fx.log.torn().is_empty(),
5520 "a reorder builds and tears down nothing"
5521 );
5522 assert!(
5523 flags.contains(ChangeFlags::LAYOUT),
5524 "relocated pods sit at new origins, so the frame owes a layout pass"
5525 );
5526 }
5527
5528 #[test]
5529 fn an_unchanged_keyed_frame_reports_no_layout() {
5530 // The layout-skip contract in the other direction: a frame that neither
5531 // built, tore down, relocated, nor re-ranged a pod must NOT force a
5532 // layout pass, or the idiom degenerates into "relayout every frame".
5533 let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
5534 let mut logic = fx.keyed_logic();
5535 let mut root = converged(&mut logic, &fx.log);
5536
5537 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5538 assert!(
5539 flags.is_empty(),
5540 "an identical keyed frame is a no-op reconciliation, got {flags:?}"
5541 );
5542 }
5543
5544 #[test]
5545 fn keyed_window_shift_relocates_survivors_and_tears_down_the_leaver() {
5546 // A pure scroll over 1000 keyed rows: virtualization still works, and the
5547 // retained key map tracks the window it materialized.
5548 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5549 let mut logic = fx.keyed_logic();
5550 let mut root = converged(&mut logic, &fx.log);
5551 let before = fx.log.painted();
5552 assert_eq!(list_widget(&root).window(), &[0, 1, 2, 3, 4, 5]);
5553
5554 // Scroll 150px: the window becomes 1..9 — row 0 leaves, rows 6..8 enter.
5555 root.event(&mut (), &wheel(150.0));
5556 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5557
5558 let after = fx.log.painted();
5559 assert_eq!(list_widget(&root).window(), &[1, 2, 3, 4, 5, 6, 7, 8]);
5560 for id in [10, 20, 30, 40, 50] {
5561 assert_eq!(
5562 after[&id], before[&id],
5563 "row {id} stayed in the window and kept its widget"
5564 );
5565 }
5566 assert_eq!(
5567 fx.log.torn(),
5568 vec![0],
5569 "the row that scrolled out is torn down"
5570 );
5571 assert!(
5572 flags.contains(ChangeFlags::LAYOUT),
5573 "a shifted window owes a layout pass"
5574 );
5575 }
5576
5577 #[test]
5578 fn keyed_insert_under_a_scrolled_window_keeps_state_with_the_rows() {
5579 // The hard case: a mid-list insert *while* the list is virtualized, so
5580 // every row's index shifts under a window that would otherwise stay
5581 // put — except this insert is a prepend relative to the scrolled
5582 // viewport (index 0 sits above it), so scroll anchoring (see the
5583 // module docs' anchoring section) shifts the *offset* to compensate,
5584 // keeping the SAME rows on screen rather than sliding new content in.
5585 // Positional identity has no answer here (see the contrast test
5586 // above); keys carry each row's state with its index, and anchoring
5587 // carries the viewport with it too.
5588 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5589 let mut logic = fx.keyed_logic();
5590 let mut root = converged(&mut logic, &fx.log);
5591
5592 root.event(&mut (), &wheel(500.0));
5593 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5594 let before = fx.log.painted();
5595 assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
5596 // Rows 80..=150 (ids), one per materialized slot.
5597 assert_eq!(before.len(), 8);
5598 let offset_before = list_widget(&root).offset();
5599
5600 // Insert at the FRONT: every row slides one index down; the anchor
5601 // (row 80, topmost of the previous window) shifts the offset by
5602 // exactly one row's extent to hold it in place.
5603 fx.rows.borrow_mut().insert(0, 5);
5604 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5605
5606 let after = fx.log.painted();
5607 assert_eq!(
5608 list_widget(&root).offset(),
5609 offset_before + ROW_EXTENT,
5610 "anchored: the offset absorbs the shift instead of the window sliding"
5611 );
5612 for id in [80, 90, 100, 110, 120, 130, 140, 150] {
5613 assert_eq!(
5614 after[&id], before[&id],
5615 "row {id} kept its widget — anchoring shows the SAME rows, not new ones"
5616 );
5617 }
5618 assert!(
5619 fx.log.torn().is_empty(),
5620 "anchoring keeps the same window of rows visible: nothing leaves it"
5621 );
5622 assert!(flags.contains(ChangeFlags::LAYOUT));
5623 }
5624
5625 // --- (10) Prepend/removal scroll anchoring (keyed lists only). ---
5626
5627 /// The pixel `y` a materialized row paints at (its `ChildPod` origin),
5628 /// looked up by the item index it currently occupies — the literal paint
5629 /// position the anchoring assertions below are stated against.
5630 fn painted_y(w: &ListViewWidget, item_index: usize) -> f64 {
5631 let slot = w
5632 .keys
5633 .iter()
5634 .position(|&k| k == item_index)
5635 .expect("index is materialized");
5636 w.children[slot].origin().y
5637 }
5638
5639 #[test]
5640 fn keyed_prepend_while_scrolled_anchors_the_visible_rows() {
5641 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5642 let mut logic = fx.keyed_logic();
5643 let mut root = converged(&mut logic, &fx.log);
5644
5645 root.event(&mut (), &wheel(500.0));
5646 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5647 let before = fx.log.painted();
5648 assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
5649 let offset_before = list_widget(&root).offset();
5650 let y_before = painted_y(list_widget(&root), 8); // row id 80's pixel position
5651
5652 // Prepend 5 rows above the viewport.
5653 fx.rows
5654 .borrow_mut()
5655 .splice(0..0, [9990, 9980, 9970, 9960, 9950]);
5656 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5657
5658 let after = fx.log.painted();
5659 let w = list_widget(&root);
5660 assert_eq!(
5661 w.offset(),
5662 offset_before + 5.0 * ROW_EXTENT,
5663 "the offset shifts by exactly K * item_extent"
5664 );
5665 assert_eq!(
5666 painted_y(w, 13), // row id 80 slid from index 8 to index 13
5667 y_before,
5668 "the anchor row paints at the exact same pixel position"
5669 );
5670 for id in [80, 90, 100, 110, 120, 130, 140, 150] {
5671 assert_eq!(
5672 after[&id], before[&id],
5673 "row {id} kept its widget across the prepend — anchoring, not a rebuild"
5674 );
5675 }
5676 assert!(
5677 fx.log.torn().is_empty(),
5678 "anchoring keeps the same rows visible — nothing leaves the window"
5679 );
5680 assert!(flags.contains(ChangeFlags::LAYOUT));
5681 }
5682
5683 #[test]
5684 fn keyed_removal_above_the_viewport_anchors_in_reverse() {
5685 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5686 let mut logic = fx.keyed_logic();
5687 let mut root = converged(&mut logic, &fx.log);
5688
5689 root.event(&mut (), &wheel(500.0));
5690 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5691 let before = fx.log.painted();
5692 assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
5693 let offset_before = list_widget(&root).offset();
5694 let y_before = painted_y(list_widget(&root), 8);
5695
5696 // Remove the first 3 rows — all strictly above the viewport.
5697 fx.rows.borrow_mut().drain(0..3);
5698 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5699
5700 let after = fx.log.painted();
5701 let w = list_widget(&root);
5702 assert_eq!(
5703 w.offset(),
5704 offset_before - 3.0 * ROW_EXTENT,
5705 "removal above shifts the offset back by exactly the removed extent"
5706 );
5707 assert_eq!(
5708 painted_y(w, 5), // row id 80 slid from index 8 to index 5
5709 y_before,
5710 "the anchor row paints at the exact same pixel position"
5711 );
5712 for id in [80, 90, 100, 110, 120, 130, 140, 150] {
5713 assert_eq!(
5714 after[&id], before[&id],
5715 "row {id} kept its widget across the removal"
5716 );
5717 }
5718 assert!(fx.log.torn().is_empty());
5719 assert!(flags.contains(ChangeFlags::LAYOUT));
5720 }
5721
5722 #[test]
5723 fn keyed_removal_above_anchors_correctly_at_max_offset() {
5724 // Scripted at a third scroll position (0, mid, max_offset) per the
5725 // task's testing note: the closed-form correction must still clamp
5726 // correctly when scrolled all the way to the bottom.
5727 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5728 let mut logic = fx.keyed_logic();
5729 let mut root = converged(&mut logic, &fx.log);
5730
5731 root.event(&mut (), &wheel(1_000_000.0)); // scroll to the very bottom
5732 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5733 let before = fx.log.painted();
5734 let offset_before = list_widget(&root).offset();
5735 assert_eq!(offset_before, list_widget(&root).max_offset());
5736
5737 // Remove the first 3 rows, strictly above the (bottom-scrolled) viewport.
5738 fx.rows.borrow_mut().drain(0..3);
5739 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5740
5741 let after = fx.log.painted();
5742 let w = list_widget(&root);
5743 assert_eq!(
5744 w.offset(),
5745 offset_before - 3.0 * ROW_EXTENT,
5746 "anchors correctly even scrolled to the very bottom"
5747 );
5748 assert_eq!(
5749 w.offset(),
5750 w.max_offset(),
5751 "still pinned exactly at the new (smaller) bottom"
5752 );
5753 for (&id, &generation) in before.iter() {
5754 assert_eq!(
5755 after.get(&id),
5756 Some(&generation),
5757 "row {id} kept its widget"
5758 );
5759 }
5760 assert!(flags.contains(ChangeFlags::LAYOUT));
5761 }
5762
5763 #[test]
5764 fn keyed_mutation_below_the_viewport_leaves_the_offset_alone() {
5765 // The third documented case: a below-viewport mutation is a no-op for
5766 // anchoring (the index the anchor occupies doesn't move at all).
5767 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5768 let mut logic = fx.keyed_logic();
5769 let mut root = converged(&mut logic, &fx.log);
5770
5771 root.event(&mut (), &wheel(500.0));
5772 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5773 let offset_before = list_widget(&root).offset();
5774
5775 fx.rows.borrow_mut().truncate(900); // drop the last 100 rows
5776
5777 keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5778
5779 assert_eq!(
5780 list_widget(&root).offset(),
5781 offset_before,
5782 "a below-viewport mutation leaves an already-scrolled offset untouched"
5783 );
5784 }
5785
5786 #[test]
5787 fn keyed_prepend_at_the_very_top_still_anchors_off_zero() {
5788 // The module docs' anchor-always decision: even starting at
5789 // offset == 0 the offset shifts, revealing the new content above
5790 // rather than staying pinned at the literal top.
5791 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5792 let mut logic = fx.keyed_logic();
5793 let mut root = converged(&mut logic, &fx.log);
5794 assert_eq!(list_widget(&root).offset(), 0.0);
5795
5796 fx.rows.borrow_mut().splice(0..0, [9990, 9980, 9970]);
5797 let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5798
5799 assert_eq!(
5800 list_widget(&root).offset(),
5801 3.0 * ROW_EXTENT,
5802 "the offset shifts away from zero rather than staying pinned"
5803 );
5804 assert!(flags.contains(ChangeFlags::LAYOUT));
5805 }
5806
5807 #[test]
5808 fn keyed_prepend_near_top_does_not_immediately_refire_near_start() {
5809 // The edge-latch interaction: a correction must not make the very
5810 // next event refire an edge that just fired (and whose callback is
5811 // presumably what triggered the prepend).
5812 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5813 let loads = Rc::new(Cell::new(0u32));
5814 let loads_l = loads.clone();
5815 let rows = fx.rows.clone();
5816 let gens = fx.gens.clone();
5817 let log = fx.log.clone();
5818 let mut logic = move |_: &mut ()| -> ListView<()> {
5819 let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
5820 let (keys, items) = (snapshot.clone(), snapshot.clone());
5821 let (gens, log) = (gens.clone(), log.clone());
5822 let loads = loads_l.clone();
5823 ListView::builder_keyed(
5824 snapshot.len(),
5825 ROW_EXTENT,
5826 move |i| ChildKey::new(keys[i]),
5827 move |i| {
5828 any::<(), _>(RowView {
5829 id: items[i],
5830 gens: gens.clone(),
5831 log: log.clone(),
5832 })
5833 },
5834 )
5835 .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
5836 };
5837
5838 let mut root = converged(&mut logic, &fx.log);
5839 // A real near-start approach at the top fires once — the trigger a
5840 // real "load older" flow answers by prepending.
5841 root.event(&mut (), &wheel(0.0));
5842 assert_eq!(loads.get(), 1, "near-start fires once at the top");
5843
5844 // Prepend a single row — the anchor shifts the offset to 1 * extent,
5845 // well within the 100px threshold: exactly the case that would
5846 // spuriously refire without the correction's rearm suppression.
5847 fx.rows.borrow_mut().splice(0..0, [9999]);
5848 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5849 assert_eq!(
5850 list_widget(&root).offset(),
5851 ROW_EXTENT,
5852 "anchored one row's worth off zero"
5853 );
5854
5855 // The very next event, still near the (corrected) start, must NOT
5856 // refire.
5857 root.event(&mut (), &wheel(0.0));
5858 assert_eq!(
5859 loads.get(),
5860 1,
5861 "the corrected offset does not immediately re-fire the same edge"
5862 );
5863
5864 // Scrolling away past 2x the threshold and back still rearms normally.
5865 root.event(&mut (), &wheel(500.0));
5866 root.event(&mut (), &wheel(-450.0));
5867 assert_eq!(
5868 loads.get(),
5869 2,
5870 "the edge still rearms after genuinely scrolling away and back"
5871 );
5872 }
5873
5874 #[test]
5875 fn positional_prepend_does_not_anchor_the_offset() {
5876 // Criterion 4: positional identity cannot distinguish a prepend from
5877 // a full mutation, so the positional path must never apply the
5878 // anchoring correction — the offset moves only from user scrolling.
5879 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5880 let mut logic = fx.positional_logic();
5881 let mut root = converged(&mut logic, &fx.log);
5882
5883 root.event(&mut (), &wheel(500.0));
5884 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5885 let offset_before = list_widget(&root).offset();
5886
5887 fx.rows.borrow_mut().insert(0, 12345);
5888 keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5889
5890 assert_eq!(
5891 list_widget(&root).offset(),
5892 offset_before,
5893 "the positional path never corrects the offset"
5894 );
5895 }
5896
5897 // --- (11) Variable extents: estimate + measured-by-key cache. ---
5898
5899 /// The estimate every variable-extent test declares. Deliberately unequal to
5900 /// any row's true height, so an unmeasured region is visibly *estimated* and
5901 /// a measured one visibly is not.
5902 const ESTIMATE: f64 = 60.0;
5903
5904 /// Deterministic row height by id: 40/60/80/100/120 px, cycling.
5905 fn var_height(id: u64) -> f64 {
5906 40.0 + (id % 5) as f64 * 20.0
5907 }
5908
5909 /// Which pass the harness is currently driving — the invocation-context
5910 /// probe's alphabet.
5911 #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
5912 enum Pass {
5913 #[default]
5914 Idle,
5915 Rebuild,
5916 Layout,
5917 Paint,
5918 }
5919
5920 /// The invocation-context probe: the app's builder closure reports the pass
5921 /// it was called in, so "the builder never runs from layout or paint" (the
5922 /// wake hazard the module docs call out) is a test, not a claim.
5923 #[derive(Default)]
5924 struct BuildProbe {
5925 pass: Cell<Pass>,
5926 calls: Cell<usize>,
5927 violations: RefCell<Vec<Pass>>,
5928 }
5929
5930 impl BuildProbe {
5931 fn note(&self) {
5932 self.calls.set(self.calls.get() + 1);
5933 let pass = self.pass.get();
5934 if pass != Pass::Rebuild {
5935 self.violations.borrow_mut().push(pass);
5936 }
5937 }
5938 }
5939
5940 /// A keyed row of a *variable* height (`var_height(id)`), reporting the same
5941 /// generation/paint/teardown log as [`RowView`]. The height is intrinsic: the
5942 /// row honors the list's unbounded-height constraint by choosing its own
5943 /// height, exactly like a real self-sizing row.
5944 struct VarRowView {
5945 id: u64,
5946 gens: Rc<Cell<u64>>,
5947 log: Rc<RowLog>,
5948 }
5949
5950 struct VarRowWidget {
5951 id: u64,
5952 generation: u64,
5953 log: Rc<RowLog>,
5954 }
5955
5956 impl View<()> for VarRowView {
5957 type Element = VarRowWidget;
5958
5959 fn build(&self, _ctx: &mut BuildCtx<'_>) -> VarRowWidget {
5960 let generation = self.gens.get();
5961 self.gens.set(generation + 1);
5962 VarRowWidget {
5963 id: self.id,
5964 generation,
5965 log: self.log.clone(),
5966 }
5967 }
5968
5969 fn rebuild(
5970 &self,
5971 _prev: &Self,
5972 element: &mut VarRowWidget,
5973 _ctx: &mut BuildCtx<'_>,
5974 ) -> ChangeFlags {
5975 element.id = self.id;
5976 ChangeFlags::NONE
5977 }
5978
5979 fn teardown(&self, element: &mut VarRowWidget, _ctx: &mut BuildCtx<'_>) {
5980 element.log.torn.borrow_mut().push(element.id);
5981 }
5982 }
5983
5984 impl Widget for VarRowWidget {
5985 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
5986 bc.constrain(Size::new(bc.max().width, var_height(self.id)))
5987 }
5988 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
5989 self.log
5990 .painted
5991 .borrow_mut()
5992 .insert(self.id, self.generation);
5993 }
5994 }
5995
5996 /// The variable-extent counterpart of [`KeyedRows`]: a mutable backing vec of
5997 /// ids whose rows size themselves, plus the invocation probe.
5998 struct VarRows {
5999 rows: Rc<RefCell<Vec<u64>>>,
6000 gens: Rc<Cell<u64>>,
6001 log: Rc<RowLog>,
6002 probe: Rc<BuildProbe>,
6003 }
6004
6005 impl VarRows {
6006 fn new(ids: Vec<u64>) -> Self {
6007 Self {
6008 rows: Rc::new(RefCell::new(ids)),
6009 gens: Rc::new(Cell::new(0)),
6010 log: Rc::new(RowLog::default()),
6011 probe: Rc::new(BuildProbe::default()),
6012 }
6013 }
6014
6015 /// The app-logic closure: a keyed list in variable-extent mode, whose
6016 /// builder reports every invocation to the probe.
6017 fn logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
6018 let rows = self.rows.clone();
6019 let gens = self.gens.clone();
6020 let log = self.log.clone();
6021 let probe = self.probe.clone();
6022 move |_: &mut ()| {
6023 let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
6024 let (keys, items) = (snapshot.clone(), snapshot.clone());
6025 let (gens, log, probe) = (gens.clone(), log.clone(), probe.clone());
6026 ListView::builder_keyed(
6027 snapshot.len(),
6028 ESTIMATE,
6029 move |i| ChildKey::new(keys[i]),
6030 move |i| {
6031 probe.note();
6032 any::<(), _>(VarRowView {
6033 id: items[i],
6034 gens: gens.clone(),
6035 log: log.clone(),
6036 })
6037 },
6038 )
6039 .estimated_item_extent(ESTIMATE)
6040 }
6041 }
6042
6043 /// [`VarRows::logic`] with the pre-seam [`RubberBand`] feel pinned
6044 /// explicitly, for the same reason [`rubber_band_logic`] exists.
6045 fn logic_rubber_band(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
6046 let mut inner = self.logic();
6047 move |state: &mut ()| inner(state).physics(RubberBand::new())
6048 }
6049
6050 /// The true total content height of the current data.
6051 fn true_extent(&self) -> f64 {
6052 self.rows.borrow().iter().copied().map(var_height).sum()
6053 }
6054 }
6055
6056 /// Drive one frame with the probe told which pass is running, returning the
6057 /// rebuild flags and paint's continuation request.
6058 fn var_frame(
6059 root: &mut RenderRoot<(), ListView<()>>,
6060 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
6061 fx: &VarRows,
6062 ms: f64,
6063 ) -> (ChangeFlags, frust_core::PaintOutcome) {
6064 fx.log.painted.borrow_mut().clear();
6065 fx.log.torn.borrow_mut().clear();
6066 fx.probe.pass.set(Pass::Rebuild);
6067 let flags = root.rebuild(logic, &mut ());
6068 fx.probe.pass.set(Pass::Layout);
6069 root.layout(KEYED_WINDOW);
6070 fx.probe.pass.set(Pass::Paint);
6071 let mut sink = NullScene;
6072 let outcome = root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
6073 fx.probe.pass.set(Pass::Idle);
6074 (flags, outcome)
6075 }
6076
6077 /// Drive frames until paint stops asking for another one, returning how many
6078 /// it took. Panics past `limit` — the convergence-frame contract is "a
6079 /// measurement that changes the window costs *frames*", never a loop.
6080 fn settle(
6081 root: &mut RenderRoot<(), ListView<()>>,
6082 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
6083 fx: &VarRows,
6084 first_ms: f64,
6085 limit: usize,
6086 ) -> usize {
6087 for n in 0..limit {
6088 let (_, outcome) = var_frame(root, logic, fx, first_ms + 16.0 * n as f64);
6089 if !outcome.needs_frame {
6090 return n + 1;
6091 }
6092 }
6093 panic!("a variable-extent list did not settle within {limit} frames");
6094 }
6095
6096 /// Build a root over a variable-extent list and settle its first window.
6097 fn converged_var(
6098 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
6099 fx: &VarRows,
6100 ) -> RenderRoot<(), ListView<()>> {
6101 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
6102 settle(&mut root, logic, fx, 0.0, 6);
6103 root
6104 }
6105
6106 /// Assert the materialized rows tile the content with no gap and no overlap,
6107 /// and that they cover the whole viewport — the "cumulative extents minus
6108 /// offset" property, read off the actual pod origins/sizes.
6109 fn assert_tiles_and_covers(w: &ListViewWidget) {
6110 assert!(!w.children.is_empty(), "something must be materialized");
6111 for slot in 0..w.children.len() {
6112 let pod = &w.children[slot];
6113 let id = w.keys[slot] as u64;
6114 assert_eq!(
6115 pod.size().height,
6116 var_height(id),
6117 "row {id} laid out at its own intrinsic height"
6118 );
6119 assert_eq!(
6120 pod.origin().y,
6121 w.slot_y[slot] - w.offset(),
6122 "row {id} paints at its content position minus the offset"
6123 );
6124 if slot + 1 < w.children.len() {
6125 assert_eq!(
6126 w.children[slot + 1].origin().y,
6127 pod.origin().y + pod.size().height,
6128 "rows tile: no gap, no overlap between slots {slot} and {}",
6129 slot + 1
6130 );
6131 }
6132 }
6133 let first = w.children[0].origin().y;
6134 let last = w.children.last().expect("non-empty");
6135 let bottom = last.origin().y + last.size().height;
6136 assert!(
6137 first <= 0.0,
6138 "the first materialized row starts at or above the viewport top (got {first})"
6139 );
6140 assert!(
6141 bottom >= w.viewport.height,
6142 "the materialized window reaches the viewport bottom (got {bottom})"
6143 );
6144 }
6145
6146 #[test]
6147 fn variable_rows_tile_at_their_own_measured_heights() {
6148 let fx = VarRows::new((0..60).collect());
6149 let mut logic = fx.logic();
6150 let mut root = converged_var(&mut logic, &fx);
6151 assert_tiles_and_covers(list_widget(&root));
6152
6153 // Scroll into a region no row has been measured in yet, settle, and the
6154 // same tiling property must hold there.
6155 root.event(&mut (), &wheel(700.0));
6156 settle(&mut root, &mut logic, &fx, 100.0, 4);
6157 assert_tiles_and_covers(list_widget(&root));
6158
6159 // ...and back up over now-measured territory.
6160 root.event(&mut (), &wheel(-300.0));
6161 settle(&mut root, &mut logic, &fx, 200.0, 4);
6162 assert_tiles_and_covers(list_widget(&root));
6163 }
6164
6165 #[test]
6166 fn variable_scroll_into_unmeasured_territory_converges_in_one_frame() {
6167 let fx = VarRows::new((0..200).collect());
6168 let mut logic = fx.logic();
6169 let mut root = converged_var(&mut logic, &fx);
6170
6171 // A wheel jump far past anything measured: the next rebuild windows from
6172 // the estimate, layout measures what it materialized, and at most one
6173 // convergence frame re-windows against the revised extents.
6174 root.event(&mut (), &wheel(2_000.0));
6175 let frames = settle(&mut root, &mut logic, &fx, 100.0, 2);
6176 assert!(
6177 frames <= 2,
6178 "a scroll into unmeasured territory converges within one extra frame (took {frames})"
6179 );
6180 assert_tiles_and_covers(list_widget(&root));
6181 assert!(
6182 fx.probe.violations.borrow().is_empty(),
6183 "the builder ran outside rebuild: {:?}",
6184 fx.probe.violations.borrow()
6185 );
6186 }
6187
6188 #[test]
6189 fn the_builder_never_runs_outside_rebuild_even_under_a_fling() {
6190 // The same probe across the hardest path: a drag/fling that advances the
6191 // offset *at paint* across unmeasured rows. Paint must materialize
6192 // nothing — it may only ask for another frame.
6193 let fx = VarRows::new((0..400).collect());
6194 let mut logic = fx.logic();
6195 let mut root = converged_var(&mut logic, &fx);
6196
6197 root.event(&mut (), &ev(PointerPhase::Down, 180.0));
6198 var_frame(&mut root, &mut logic, &fx, 100.0);
6199 root.event(&mut (), &ev(PointerPhase::Move, 120.0)); // takeover
6200 var_frame(&mut root, &mut logic, &fx, 116.0);
6201 root.event(&mut (), &ev(PointerPhase::Move, 40.0)); // build velocity
6202 root.event(&mut (), &ev(PointerPhase::Up, 40.0)); // release → fling
6203 assert!(list_widget(&root).is_flinging());
6204
6205 for k in 0..12 {
6206 var_frame(&mut root, &mut logic, &fx, 132.0 + 16.0 * k as f64);
6207 }
6208
6209 assert!(fx.probe.calls.get() > 0, "the probe saw the builder at all");
6210 assert!(
6211 fx.probe.violations.borrow().is_empty(),
6212 "the builder ran outside rebuild: {:?}",
6213 fx.probe.violations.borrow()
6214 );
6215 // The fling crossed unmeasured rows without panicking or stalling.
6216 assert!(list_widget(&root).offset() > 0.0);
6217 }
6218
6219 #[test]
6220 fn uniform_mode_takes_the_closed_form_path_and_measures_nothing() {
6221 // Criterion 3: without `estimated_item_extent` nothing about the extent
6222 // model changes — no measurement is cached, no per-slot geometry is
6223 // retained, and rows sit at exactly `index * item_extent`.
6224 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
6225 let mut logic = fx.keyed_logic();
6226 let mut root = converged(&mut logic, &fx.log);
6227 root.event(&mut (), &wheel(500.0));
6228 keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
6229
6230 let w = list_widget(&root);
6231 assert!(w.estimated_extent.is_none(), "no estimate was declared");
6232 assert!(!w.is_variable(), "the uniform path is taken");
6233 assert!(w.measured.is_empty(), "the measured cache stays empty");
6234 assert_eq!(w.measured_sum, 0.0);
6235 assert!(w.slot_keys.is_empty() && w.slot_y.is_empty());
6236 assert_eq!(
6237 w.max_offset(),
6238 1000.0 * ROW_EXTENT - w.viewport.height,
6239 "the closed-form scroll extent is unchanged"
6240 );
6241 assert_eq!(
6242 painted_y(w, 10),
6243 10.0 * ROW_EXTENT - w.offset(),
6244 "rows sit at the closed-form position"
6245 );
6246 }
6247
6248 #[test]
6249 fn variable_total_extent_converges_to_the_true_sum() {
6250 use frust_core::accesskit::Role;
6251
6252 let fx = VarRows::new((0..30).collect());
6253 let mut logic = fx.logic();
6254 let mut root = converged_var(&mut logic, &fx);
6255
6256 // Before anything is visited the extent is mostly estimated.
6257 let estimated_total = list_widget(&root).content_extent();
6258 assert!(
6259 (estimated_total - fx.true_extent()).abs() > 1.0,
6260 "the estimate and the truth differ to begin with"
6261 );
6262
6263 // Walk the whole list a viewport at a time, so every row is materialized
6264 // (and therefore measured) at least once.
6265 for step in 0..40 {
6266 root.event(&mut (), &wheel(100.0));
6267 var_frame(&mut root, &mut logic, &fx, 100.0 + 16.0 * step as f64);
6268 }
6269
6270 let w = list_widget(&root);
6271 assert_eq!(w.measured.len(), 30, "every row was visited and measured");
6272 assert!(
6273 (w.content_extent() - fx.true_extent()).abs() < 1e-6,
6274 "the total extent converged to Σ true extents: {} vs {}",
6275 w.content_extent(),
6276 fx.true_extent()
6277 );
6278 assert!(
6279 (w.max_offset() - (fx.true_extent() - w.viewport.height)).abs() < 1e-6,
6280 "max_offset tracks the converged extent"
6281 );
6282
6283 let update = root.semantics();
6284 let (_, list) = update
6285 .nodes
6286 .iter()
6287 .find(|(_, n)| n.role() == Role::List)
6288 .expect("the ListView contributes a Role::List container node");
6289 assert_eq!(
6290 list.scroll_y_max(),
6291 Some(fx.true_extent() - KEYED_WINDOW.height),
6292 "the semantics scroll range tracks it too"
6293 );
6294 }
6295
6296 #[test]
6297 fn variable_cache_evicts_a_removed_row_but_keeps_a_scrolled_away_one() {
6298 let fx = VarRows::new((0..40).collect());
6299 let mut logic = fx.logic();
6300 let mut root = converged_var(&mut logic, &fx);
6301 assert!(
6302 list_widget(&root)
6303 .measured
6304 .contains_key(&ChildKey::new(0u64)),
6305 "row 0 was measured while it was on screen"
6306 );
6307
6308 // Scroll it out of the window: it left the *window*, not the data, so its
6309 // measurement must survive (this is what makes the extent converge).
6310 root.event(&mut (), &wheel(400.0));
6311 settle(&mut root, &mut logic, &fx, 100.0, 4);
6312 let w = list_widget(&root);
6313 assert!(!w.window().contains(&0), "row 0 is no longer materialized");
6314 assert!(
6315 w.measured.contains_key(&ChildKey::new(0u64)),
6316 "a row that only left the window keeps its measurement"
6317 );
6318
6319 // Now remove a row that IS in the window: its key left the data, so its
6320 // measurement is evicted and the running sum drops by exactly its height.
6321 let victim = list_widget(&root).window()[3] as u64;
6322 let sum_before = list_widget(&root).measured_sum;
6323 let count_before = list_widget(&root).measured.len();
6324 fx.rows.borrow_mut().retain(|&id| id != victim);
6325 root.rebuild(&mut logic, &mut ()); // rebuild alone: no re-measurement yet
6326
6327 let w = list_widget(&root);
6328 assert!(
6329 !w.measured.contains_key(&ChildKey::new(victim)),
6330 "the removed row's measurement is evicted"
6331 );
6332 assert_eq!(w.measured.len(), count_before - 1);
6333 assert!(
6334 (w.measured_sum - (sum_before - var_height(victim))).abs() < 1e-9,
6335 "the running sum drops by exactly the evicted extent"
6336 );
6337 }
6338
6339 #[test]
6340 fn variable_full_replace_clears_the_measured_cache() {
6341 let fx = VarRows::new((0..40).collect());
6342 let mut logic = fx.logic();
6343 let mut root = converged_var(&mut logic, &fx);
6344 assert!(!list_widget(&root).measured.is_empty());
6345
6346 // Every id replaced: no previous-window key survives either hypothesis,
6347 // which is reset semantics for the cache as well as for anchoring.
6348 *fx.rows.borrow_mut() = (0..40).map(|i| 10_000 + i).collect();
6349 root.rebuild(&mut logic, &mut ());
6350
6351 let w = list_widget(&root);
6352 assert!(w.measured.is_empty(), "a full replace clears the cache");
6353 assert_eq!(w.measured_sum, 0.0);
6354 }
6355
6356 #[test]
6357 fn same_frame_prepend_and_append_leaves_the_measured_cache_intact() {
6358 // A same-frame mutation on BOTH sides of the anchor (a prepend above
6359 // the viewport plus an append below it, in one keyed frame) is a
6360 // false negative for the rebuild-time anchor-shift probe: the net
6361 // `item_count` delta is +4 (3 prepended, 1 appended) but the true
6362 // per-row shift for every already-existing row is +3 (the append
6363 // never shifts an existing index), so the probe's two hypotheses
6364 // (unchanged position, or shifted by the net delta) both miss.
6365 //
6366 // Before this fix that bare miss also wiped the whole measured
6367 // cache (`ListView::rebuild`'s `None` arm called `clear_measured`
6368 // unconditionally). This pins the fix: the scroll-position
6369 // correction itself is knowingly still absent this frame (a visible
6370 // jump — the exact per-key anchor fix is a deferred follow-up, see
6371 // the module docs' *Cache hygiene* section), but the wholesale clear
6372 // no longer fires, because `reconcile_keyed` still matches *some*
6373 // on-screen rows into this frame's window by *exact* key, not by the
6374 // probe's hypotheses (`any_survivor`).
6375 //
6376 // Not every previously on-screen row is such a survivor, though: the
6377 // window itself is recomputed against the same (uncorrected, since
6378 // the probe missed) offset over now-shifted content, so a row whose
6379 // slot falls outside the recomputed window becomes an orphan the
6380 // main reconcile loop never claims. The departing-row eviction loop
6381 // (`ListView::reconcile_keyed`'s *Cache hygiene* section) tests that
6382 // orphan against the same two hypotheses the probe already found
6383 // unreliable this frame, and — per the module docs' *acknowledged
6384 // gaps* — conservatively evicts it rather than leaving it stale
6385 // forever: a merely-window-departed row costs one re-measurement on
6386 // its next visit; a genuinely data-departed row (a distinct
6387 // scenario pinned by
6388 // `probe_miss_with_survivor_evicts_a_row_that_left_the_data`) would
6389 // otherwise leak permanently. Both outcomes are asserted below,
6390 // derived from which rows the reconciled window actually still
6391 // names — not hardcoded — so this test tracks the real reconciliation
6392 // rather than one snapshot of its internals.
6393 let fx = VarRows::new((0..60).collect());
6394 let mut logic = fx.logic();
6395 let mut root = converged_var(&mut logic, &fx);
6396
6397 // Scroll to a middle window so there is real content both above and
6398 // below what is materialized.
6399 root.event(&mut (), &wheel(400.0));
6400 settle(&mut root, &mut logic, &fx, 100.0, 4);
6401
6402 let w = list_widget(&root);
6403 let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
6404 assert!(!on_screen_ids.is_empty(), "the window materialized rows");
6405 let measured_before: HashMap<u64, f64> = on_screen_ids
6406 .iter()
6407 .map(|&id| {
6408 let m = w.measured[&ChildKey::new(id)];
6409 (id, m.extent)
6410 })
6411 .collect();
6412 assert_eq!(
6413 measured_before.len(),
6414 on_screen_ids.len(),
6415 "every on-screen row was already measured, laid out once by `converged_var`"
6416 );
6417
6418 // Prepend 3 above and append 1 below in one borrow_mut batch, so both
6419 // mutations land in the same keyed rebuild.
6420 {
6421 let mut rows = fx.rows.borrow_mut();
6422 rows.splice(0..0, [9_001u64, 9_002, 9_003]);
6423 rows.push(9_050);
6424 }
6425 root.rebuild(&mut logic, &mut ()); // rebuild alone: pins cache state, not paint/layout
6426
6427 let w = list_widget(&root);
6428 let new_window_ids: std::collections::HashSet<u64> =
6429 w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
6430 assert!(
6431 on_screen_ids.iter().any(|id| new_window_ids.contains(id)),
6432 "at least one previously on-screen row is still reconciled into this \
6433 frame's window by exact key — `any_survivor`, the precondition that \
6434 keeps the wholesale clear from firing"
6435 );
6436
6437 let mut survivors = 0;
6438 let mut departed = 0;
6439 for (id, extent_before) in &measured_before {
6440 let entry = w.measured.get(&ChildKey::new(*id));
6441 if new_window_ids.contains(id) {
6442 survivors += 1;
6443 assert!(
6444 entry.is_some(),
6445 "row {id} is still named by this frame's reconciled window \
6446 (an exact key match), so its measurement must survive"
6447 );
6448 assert_eq!(
6449 entry.unwrap().extent,
6450 *extent_before,
6451 "row {id}'s measured height is unchanged"
6452 );
6453 } else {
6454 departed += 1;
6455 assert!(
6456 entry.is_none(),
6457 "row {id} left this frame's reconciled window and neither \
6458 departing-row hypothesis re-explains its new position — the \
6459 conservative eviction this fix restores (re-measured on its \
6460 next visit, never a permanent leak)"
6461 );
6462 }
6463 }
6464 assert!(survivors > 0, "the survivor assertion above is exercised");
6465 assert!(
6466 departed > 0,
6467 "the window-departure assertion above is exercised — if this ever \
6468 stops firing (e.g. a wider window swallows the whole shift), widen \
6469 the mutation so the same-frame-both-sides mismatch still orphans a \
6470 row and this test keeps covering both outcomes"
6471 );
6472 }
6473
6474 #[test]
6475 fn probe_miss_with_survivor_evicts_a_row_that_left_the_data() {
6476 // A same-frame mutation on both sides of the anchor (prepend above
6477 // the viewport, remove the topmost on-screen row itself, append
6478 // below) is the same false-negative shape
6479 // `same_frame_prepend_and_append_leaves_the_measured_cache_intact`
6480 // pins for the anchor-shift probe: removing the *first* on-screen
6481 // row means every remaining on-screen row's actual shift (+2: +3
6482 // from the prepend, −1 from the row removed ahead of it) differs
6483 // from the frame's net `item_count` delta (+3: +3 prepended, −1
6484 // removed, +1 appended), so the probe's two hypotheses (unchanged
6485 // position, or shifted by the net delta) miss for every key in the
6486 // previous window — `anchor_probe_missed`.
6487 //
6488 // Unlike that test, `reconcile_keyed`'s own exact per-slot lookup
6489 // still finds *some* of those rows in the new window
6490 // (`any_survivor`) — the precondition this test targets: a
6491 // probe-miss frame that is *not* a wholesale replace, so the
6492 // measured cache's own wholesale reset stays off. But the removed
6493 // row genuinely left the *data*, not merely the window, and must
6494 // still be evicted by the per-row eviction loop this fix restores —
6495 // left alone it would leak forever, since `record_measurement`
6496 // never revives a dead key and this frame's *growing* `item_count`
6497 // never triggers the shrink-only eviction rule either.
6498 let fx = VarRows::new((0..60).collect());
6499 let mut logic = fx.logic();
6500 let mut root = converged_var(&mut logic, &fx);
6501 root.event(&mut (), &wheel(400.0));
6502 settle(&mut root, &mut logic, &fx, 100.0, 4);
6503
6504 let w = list_widget(&root);
6505 let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
6506 assert!(
6507 on_screen_ids.len() > 1,
6508 "the window materialized more than the victim alone"
6509 );
6510 let victim = on_screen_ids[0];
6511 let offset_before = w.offset();
6512 let count_before = w.measured.len();
6513 let sum_before = w.measured_sum;
6514 assert!(
6515 w.measured.contains_key(&ChildKey::new(victim)),
6516 "the victim was already measured, laid out once by `converged_var`"
6517 );
6518
6519 // Prepend 3 above the viewport, remove the topmost on-screen row
6520 // itself, and append 1 below — all in one keyed rebuild.
6521 {
6522 let mut rows = fx.rows.borrow_mut();
6523 rows.splice(0..0, [9_001u64, 9_002, 9_003]);
6524 rows.retain(|&id| id != victim);
6525 rows.push(9_050);
6526 }
6527 root.rebuild(&mut logic, &mut ()); // rebuild alone: pins cache state, not paint/layout
6528
6529 let w = list_widget(&root);
6530 assert_eq!(
6531 w.offset(),
6532 offset_before,
6533 "the anchor probe missed, so no anchor-shift correction ran this \
6534 frame — the offset a genuine prepend/removal would anchor is \
6535 untouched here"
6536 );
6537
6538 let new_window_ids: std::collections::HashSet<u64> =
6539 w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
6540 assert!(
6541 on_screen_ids[1..]
6542 .iter()
6543 .any(|id| new_window_ids.contains(id)),
6544 "at least one other previously on-screen row is still reconciled \
6545 into this frame's window by exact key — `any_survivor`, the \
6546 precondition that keeps the wholesale clear from firing"
6547 );
6548 for id in &on_screen_ids[1..] {
6549 if new_window_ids.contains(id) {
6550 assert!(
6551 w.measured.contains_key(&ChildKey::new(*id)),
6552 "row {id} is still named by this frame's reconciled \
6553 window, so its measurement must survive"
6554 );
6555 }
6556 }
6557
6558 assert!(
6559 !w.measured.contains_key(&ChildKey::new(victim)),
6560 "the removed row's measurement is evicted that same frame, even \
6561 though the anchor probe missed — this fix's whole point"
6562 );
6563 assert!(
6564 w.measured.len() <= count_before,
6565 "the cache never grows across a rebuild-only pass over a removal"
6566 );
6567 assert!(
6568 (w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
6569 "the running sum still matches the surviving entries — \
6570 `measured_sum` and `measured` stay consistent, no leak"
6571 );
6572 assert!(
6573 w.measured_sum < sum_before,
6574 "the sum strictly dropped (at least the victim's own extent left it)"
6575 );
6576 }
6577
6578 #[test]
6579 fn variable_truncation_evicts_measurements_past_the_new_end() {
6580 let fx = VarRows::new((0..40).collect());
6581 let mut logic = fx.logic();
6582 let mut root = converged_var(&mut logic, &fx);
6583
6584 // Visit the far end so those rows carry measurements, then drop them from
6585 // the data while the viewport is nowhere near them.
6586 root.event(&mut (), &wheel(2_000.0));
6587 settle(&mut root, &mut logic, &fx, 100.0, 4);
6588 assert!(list_widget(&root).measured.len() > 10);
6589
6590 fx.rows.borrow_mut().truncate(5);
6591 root.rebuild(&mut logic, &mut ());
6592
6593 let w = list_widget(&root);
6594 assert!(
6595 w.measured.values().all(|m| m.index < 5),
6596 "every entry the shrunk keying cannot produce is gone"
6597 );
6598 assert!(
6599 (w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
6600 "the running sum still matches the surviving entries"
6601 );
6602 }
6603
6604 #[test]
6605 fn variable_prepend_anchors_by_the_estimate() {
6606 // Anchoring (the previous step) still holds in variable-extent mode: the
6607 // prepended rows are unmeasured by definition, so the correction — and
6608 // the prefix anchor it moves with it — steps by the estimate.
6609 let fx = VarRows::new((0..200).collect());
6610 let mut logic = fx.logic();
6611 let mut root = converged_var(&mut logic, &fx);
6612
6613 root.event(&mut (), &wheel(600.0));
6614 settle(&mut root, &mut logic, &fx, 100.0, 4);
6615 let anchor_id = list_widget(&root).window()[0] as u64;
6616 let offset_before = list_widget(&root).offset();
6617 let y_before = list_widget(&root).children[0].origin().y;
6618 let painted_before = fx.log.painted();
6619
6620 fx.rows.borrow_mut().splice(0..0, [9001, 9002, 9003]);
6621 let (flags, _) = var_frame(&mut root, &mut logic, &fx, 200.0);
6622
6623 let w = list_widget(&root);
6624 assert_eq!(
6625 w.offset(),
6626 offset_before + 3.0 * ESTIMATE,
6627 "the offset absorbs three unmeasured rows' worth of prepend"
6628 );
6629 assert_eq!(
6630 w.children[0].origin().y,
6631 y_before,
6632 "the anchor row paints at the same pixel position"
6633 );
6634 assert_eq!(w.keys[0] as u64, anchor_id + 3, "shifted by three indices");
6635 assert_eq!(
6636 fx.log.painted()[&anchor_id],
6637 painted_before[&anchor_id],
6638 "and kept its own widget"
6639 );
6640 assert!(flags.contains(ChangeFlags::LAYOUT));
6641 }
6642
6643 #[test]
6644 fn an_unchanged_variable_frame_reports_no_layout_once_settled() {
6645 // The layout-skip contract survives the extent model: once measurements
6646 // have settled, an identical frame must be a no-op reconciliation, or
6647 // variable extents would degenerate into "relayout every frame".
6648 //
6649 // Paint's convergence check asks for another frame only while the
6650 // materialized window fails to *cover* the viewport, so a window that
6651 // measured out *wider* than it needs to be is trimmed by the next
6652 // rebuild instead — one more structural frame after `settle` returns,
6653 // then steady. That trim frame is the one allowance here.
6654 let fx = VarRows::new((0..60).collect());
6655 let mut logic = fx.logic();
6656 let mut root = converged_var(&mut logic, &fx);
6657 root.event(&mut (), &wheel(500.0));
6658 settle(&mut root, &mut logic, &fx, 100.0, 4);
6659 var_frame(&mut root, &mut logic, &fx, 200.0); // the trim frame
6660
6661 for k in 0..4 {
6662 let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 216.0 + 16.0 * k as f64);
6663 assert!(
6664 flags.is_empty(),
6665 "identical variable-extent frame {k} is a no-op reconciliation, got {flags:?}"
6666 );
6667 assert!(
6668 !outcome.needs_frame,
6669 "and it does not keep asking for another frame"
6670 );
6671 }
6672 }
6673
6674 #[test]
6675 fn variable_short_and_empty_lists_are_well_behaved() {
6676 // Content shorter than the viewport pins the offset at zero and still
6677 // tiles from measured heights; an empty list materializes nothing and
6678 // never walks a prefix at all.
6679 let fx = VarRows::new(vec![0, 1, 2]); // 40 + 60 + 80 = 180 < 200
6680 let mut logic = fx.logic();
6681 let mut root = converged_var(&mut logic, &fx);
6682
6683 let w = list_widget(&root);
6684 assert_eq!(w.window(), &[0, 1, 2]);
6685 assert_eq!(
6686 w.max_offset(),
6687 0.0,
6688 "content shorter than the viewport does not scroll"
6689 );
6690 assert_eq!(w.children[0].origin().y, 0.0);
6691 assert_eq!(
6692 w.children[2].origin().y,
6693 100.0,
6694 "rows 0 and 1 (40 + 60) stack above row 2"
6695 );
6696
6697 fx.rows.borrow_mut().clear();
6698 settle(&mut root, &mut logic, &fx, 100.0, 3);
6699 assert!(list_widget(&root).window().is_empty());
6700 assert_eq!(list_widget(&root).max_offset(), 0.0);
6701 assert!(list_widget(&root).measured.is_empty());
6702 }
6703
6704 #[cfg(debug_assertions)]
6705 #[test]
6706 #[should_panic(expected = "requires ListView::builder_keyed")]
6707 fn estimated_item_extent_needs_a_keyed_list() {
6708 // Variable extents cache a measurement under row identity; a positional
6709 // list has none. Debug trips the tripwire; release ignores the estimate
6710 // and stays uniform (documented on the builder method).
6711 let _ = list_view(10, ROW_EXTENT, |i| any::<(), _>(gen_stub(i)))
6712 .estimated_item_extent(ROW_EXTENT);
6713 }
6714
6715 // Duplicate keys are ambiguous: the debug tripwire is the contract (release
6716 // carries on with first-claim-wins, documented on `builder_keyed`).
6717
6718 // --- (12) Measured anchor correction + the fling's accumulate-then-apply. ---
6719
6720 /// A row whose id is `≡ 3 (mod 5)` measures [`TALL`] — well above
6721 /// [`ESTIMATE`] — and one `≡ 0 (mod 5)` measures [`SHORT`], well below it
6722 /// ([`var_height`] cycles 40/60/80/100/120 by `id % 5`). Stepping ids by 5
6723 /// therefore builds a list of one known height, in either direction away
6724 /// from the estimate.
6725 const TALL: f64 = 100.0;
6726 const SHORT: f64 = 40.0;
6727
6728 fn tall_ids(n: usize) -> Vec<u64> {
6729 (0..n).map(|i| i as u64 * 5 + 3).collect()
6730 }
6731
6732 fn short_ids(n: usize) -> Vec<u64> {
6733 (0..n).map(|i| i as u64 * 5).collect()
6734 }
6735
6736 /// The topmost row the viewport actually shows — the anchor a correction
6737 /// holds still — as `(row id, painted y)`, read straight off the live pods.
6738 fn anchor_row(w: &ListViewWidget, fx: &VarRows) -> (u64, f64) {
6739 let rows = fx.rows.borrow();
6740 for (slot, pod) in w.children.iter().enumerate() {
6741 if pod.origin().y + pod.size().height > 0.0 {
6742 return (rows[w.keys[slot]], pod.origin().y);
6743 }
6744 }
6745 panic!("no materialized row reaches the viewport top");
6746 }
6747
6748 #[test]
6749 fn measured_correction_holds_the_anchor_when_rows_measure_taller() {
6750 // Criterion 1, upward: a jump lands in a region whose rows all measure
6751 // 100 against a 60px estimate, so the two rows the window buffers above
6752 // the viewport top measure taller than the offset was planned against.
6753 let fx = VarRows::new(tall_ids(300));
6754 let mut logic = fx.logic();
6755 let mut root = converged_var(&mut logic, &fx);
6756
6757 root.event(&mut (), &wheel(1_500.0));
6758 var_frame(&mut root, &mut logic, &fx, 100.0);
6759
6760 let w = list_widget(&root);
6761 let pending = w.pending_correction;
6762 let offset_before = w.offset();
6763 let (anchor_id, anchor_y) = anchor_row(w, &fx);
6764 assert!(
6765 pending > 0.0,
6766 "the rows above the viewport top measured taller than assumed"
6767 );
6768
6769 // The convergence frame commits it, and the anchor row is painted at the
6770 // pixel position it already occupied — before *and* after.
6771 let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 116.0);
6772 let w = list_widget(&root);
6773 assert_eq!(w.pending_correction, 0.0, "the correction was committed");
6774 assert!(
6775 (w.offset() - (offset_before + pending)).abs() < 1e-9,
6776 "the offset absorbed exactly the measured delta"
6777 );
6778 let (id_after, y_after) = anchor_row(w, &fx);
6779 assert_eq!(id_after, anchor_id, "the same row is still at the top");
6780 assert!(
6781 (y_after - anchor_y).abs() < 1e-9,
6782 "the anchor row's painted y is unchanged across the correction \
6783 ({anchor_y} -> {y_after})"
6784 );
6785 assert!(flags.contains(ChangeFlags::LAYOUT));
6786 assert!(!outcome.needs_frame, "one convergence frame, then settled");
6787 }
6788
6789 #[test]
6790 fn measured_correction_holds_the_anchor_when_rows_measure_shorter() {
6791 // Criterion 1, the other direction: rows measure 40 against the same
6792 // 60px estimate, so the correction is negative and the offset moves
6793 // *back* to hold the anchor still.
6794 let fx = VarRows::new(short_ids(300));
6795 let mut logic = fx.logic();
6796 let mut root = converged_var(&mut logic, &fx);
6797
6798 root.event(&mut (), &wheel(1_500.0));
6799 var_frame(&mut root, &mut logic, &fx, 100.0);
6800
6801 let w = list_widget(&root);
6802 let pending = w.pending_correction;
6803 let offset_before = w.offset();
6804 let (anchor_id, anchor_y) = anchor_row(w, &fx);
6805 assert_eq!(
6806 w.children[0].size().height,
6807 SHORT,
6808 "the fixture's rows really do measure shorter than the estimate"
6809 );
6810 assert!(
6811 pending < 0.0,
6812 "the rows above the viewport top measured shorter than assumed"
6813 );
6814
6815 let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
6816 let w = list_widget(&root);
6817 assert_eq!(w.pending_correction, 0.0);
6818 assert!((w.offset() - (offset_before + pending)).abs() < 1e-9);
6819 let (id_after, y_after) = anchor_row(w, &fx);
6820 assert_eq!(id_after, anchor_id);
6821 assert!(
6822 (y_after - anchor_y).abs() < 1e-9,
6823 "the anchor row's painted y is unchanged across the correction \
6824 ({anchor_y} -> {y_after})"
6825 );
6826 assert!(flags.contains(ChangeFlags::LAYOUT));
6827 }
6828
6829 #[test]
6830 fn full_replace_after_a_pending_correction_discards_it_instead_of_committing_it() {
6831 // Wheel-jump into unmeasured tall rows so a real frame leaves
6832 // `pending_correction > 0` — the same idiom
6833 // `measured_correction_holds_the_anchor_when_rows_measure_taller` and
6834 // `drag_takeover_seeds_from_raw_offset_not_the_pending_corrected_painted_offset`
6835 // use to produce a genuine (not synthetic) correction, rather than
6836 // committing it on the very next ordinary frame.
6837 let fx = VarRows::new(tall_ids(300));
6838 let mut logic = fx.logic();
6839 let mut root = converged_var(&mut logic, &fx);
6840
6841 root.event(&mut (), &wheel(1_500.0));
6842 var_frame(&mut root, &mut logic, &fx, 100.0);
6843
6844 let w = list_widget(&root);
6845 let pending = w.pending_correction;
6846 let offset_before = w.offset();
6847 assert!(
6848 pending > 0.0,
6849 "the rows above the viewport top measured taller than assumed"
6850 );
6851
6852 // Instead of an ordinary next frame (which would commit `pending`),
6853 // replace every id: no previous-window key survives either
6854 // hypothesis, a genuine full replace exactly like
6855 // `variable_full_replace_clears_the_measured_cache` — reset
6856 // semantics for the measured cache *and* for anchoring, on a frame
6857 // that also happens to be carrying a stale, now-unexplainable
6858 // correction computed against geometry this list no longer holds.
6859 *fx.rows.borrow_mut() = (0..300).map(|i| 10_000 + i).collect();
6860 root.rebuild(&mut logic, &mut ()); // rebuild alone: pins offset/cache state
6861
6862 let w = list_widget(&root);
6863 assert_eq!(
6864 w.pending_correction, 0.0,
6865 "the stale correction is discarded, not left pending"
6866 );
6867 assert_eq!(
6868 w.offset(),
6869 offset_before,
6870 "the offset did NOT absorb the stale correction — `item_count` is \
6871 unchanged (300 before and after) and {offset_before} is already \
6872 far inside `max_offset` either way, so the only way this could \
6873 differ is the bug this pins: committing `pending` (giving \
6874 {}) into geometry the full replace made unexplainable",
6875 offset_before + pending
6876 );
6877 assert!(
6878 w.measured.is_empty(),
6879 "a full replace clears the measured cache too (the round-0 fix, \
6880 still intact — `reconcile_keyed`'s wholesale-clear branch)"
6881 );
6882 assert_eq!(w.measured_sum, 0.0);
6883 }
6884
6885 #[test]
6886 fn drag_takeover_seeds_from_raw_offset_not_the_pending_corrected_painted_offset() {
6887 // Wheel-jump into unmeasured tall rows so a real frame leaves
6888 // `pending_correction > 0` — mirrors
6889 // `measured_correction_holds_the_anchor_when_rows_measure_taller`'s
6890 // idiom for producing a genuine (not synthetic) pending correction.
6891 let fx = VarRows::new(tall_ids(300));
6892 let mut logic = fx.logic();
6893 let mut root = converged_var(&mut logic, &fx);
6894
6895 root.event(&mut (), &wheel(1_500.0));
6896 var_frame(&mut root, &mut logic, &fx, 100.0);
6897
6898 let w = list_widget(&root);
6899 let pending = w.pending_correction;
6900 assert!(
6901 pending > 0.0,
6902 "the rows above the viewport top measured taller than assumed"
6903 );
6904 let painted_before = w.painted_offset();
6905
6906 // WITHOUT another rebuild: Down -> Move past TOUCH_SLOP (takeover) ->
6907 // a second Move with a known finger delta `dy`.
6908 let down_y = 100.0;
6909 root.event(&mut (), &ev(PointerPhase::Down, down_y));
6910 let takeover_y = down_y + TOUCH_SLOP + 1.0;
6911 root.event(&mut (), &ev(PointerPhase::Move, takeover_y)); // takeover
6912 assert!(
6913 list_widget(&root).scrolling,
6914 "the slop crossing took the gesture over"
6915 );
6916 assert_eq!(
6917 list_widget(&root).pending_correction,
6918 pending,
6919 "takeover itself never touches pending_correction"
6920 );
6921
6922 let dy = 30.0;
6923 root.event(&mut (), &ev(PointerPhase::Move, takeover_y + dy));
6924
6925 let w = list_widget(&root);
6926 assert!(
6927 (w.painted_offset() - (painted_before - dy)).abs() < 1e-9,
6928 "painted offset after the second Move equals the pre-takeover \
6929 painted offset minus exactly the finger delta \
6930 ({painted_before} - {dy} = {}, got {})",
6931 painted_before - dy,
6932 w.painted_offset()
6933 );
6934 assert_eq!(
6935 w.pending_correction, pending,
6936 "the drag path never commits or otherwise touches pending_correction \
6937 (only a rebuild does — see `ListViewWidget::apply_pending_correction`)"
6938 );
6939 }
6940
6941 #[test]
6942 fn variable_prepend_refines_from_the_estimate_to_the_measurement() {
6943 // Criterion 2's two steps, in consecutive frames: reconciliation shifts
6944 // by the estimate (a prepended row is unmeasured by definition), then
6945 // the frame that measures those rows refines the shift to the truth —
6946 // with the anchor row painting at the same y at every step.
6947 let fx = VarRows::new(tall_ids(60));
6948 let mut logic = fx.logic();
6949 let mut root = converged_var(&mut logic, &fx);
6950
6951 // Half a row down: the viewport top sits inside row 0, so a prepend
6952 // lands *inside* the materialized window and is measurable at all (a
6953 // prepend far above the window can only ever be estimated — see the
6954 // module docs' *What stays estimated*).
6955 root.event(&mut (), &wheel(TALL / 2.0));
6956 settle(&mut root, &mut logic, &fx, 100.0, 4);
6957 let w = list_widget(&root);
6958 let (anchor_id, anchor_y) = anchor_row(w, &fx);
6959 let offset_before = w.offset();
6960
6961 // Step one: the estimate-based shift.
6962 fx.rows.borrow_mut().splice(0..0, [5_003u64, 5_008u64]);
6963 var_frame(&mut root, &mut logic, &fx, 200.0);
6964 let w = list_widget(&root);
6965 assert!(
6966 (w.offset() - (offset_before + 2.0 * ESTIMATE)).abs() < 1e-9,
6967 "reconciliation shifts by the estimate first"
6968 );
6969 assert!(
6970 (w.pending_correction - 2.0 * (TALL - ESTIMATE)).abs() < 1e-9,
6971 "and layout records what the two prepended rows actually measured"
6972 );
6973 let (id_mid, y_mid) = anchor_row(w, &fx);
6974 assert_eq!(id_mid, anchor_id);
6975 assert!(
6976 (y_mid - anchor_y).abs() < 1e-9,
6977 "the anchor row did not move on the reconciling frame"
6978 );
6979
6980 // Step two: the measured refinement.
6981 let (flags, _) = var_frame(&mut root, &mut logic, &fx, 216.0);
6982 let w = list_widget(&root);
6983 assert_eq!(w.pending_correction, 0.0);
6984 assert!(
6985 (w.offset() - (offset_before + 2.0 * TALL)).abs() < 1e-9,
6986 "the offset ends up shifted by the prepended rows' *true* extent"
6987 );
6988 let (id_after, y_after) = anchor_row(w, &fx);
6989 assert_eq!(id_after, anchor_id, "still the same row at the top");
6990 assert!(
6991 (y_after - anchor_y).abs() < 1e-9,
6992 "and it never moved across either step"
6993 );
6994 assert!(flags.contains(ChangeFlags::LAYOUT));
6995 }
6996
6997 /// Fling **upward** (finger dragging down) over rows nothing has measured,
6998 /// pumping frames until the fling comes to rest. Returns one `(offset, still
6999 /// flinging, pending correction)` sample per frame, starting at the release.
7000 ///
7001 /// Upward is the interesting direction: rows enter the window *above* the
7002 /// viewport top, so every frame measures rows that correct the offset —
7003 /// against the fling when they measure taller than the estimate.
7004 fn fling_up_and_pump(
7005 root: &mut RenderRoot<(), ListView<()>>,
7006 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
7007 fx: &VarRows,
7008 first_ms: f64,
7009 ) -> Vec<(f64, bool, f64)> {
7010 root.event(&mut (), &ev(PointerPhase::Down, 20.0));
7011 var_frame(root, logic, fx, first_ms);
7012 root.event(&mut (), &ev(PointerPhase::Move, 90.0)); // takeover
7013 var_frame(root, logic, fx, first_ms + 16.0);
7014 root.event(&mut (), &ev(PointerPhase::Move, 190.0)); // build velocity
7015 root.event(&mut (), &ev(PointerPhase::Up, 190.0)); // release
7016 assert!(
7017 list_widget(root).is_flinging(),
7018 "the release started a fling"
7019 );
7020
7021 let w = list_widget(root);
7022 let mut trace = vec![(w.offset(), true, w.pending_correction)];
7023 for k in 0..300 {
7024 var_frame(root, logic, fx, first_ms + 32.0 + 16.0 * k as f64);
7025 let w = list_widget(root);
7026 trace.push((w.offset(), w.is_flinging(), w.pending_correction));
7027 if !w.is_flinging() {
7028 return trace;
7029 }
7030 }
7031 panic!("the fling never came to rest");
7032 }
7033
7034 /// Assert a fling trajectory only ever moved in the fling's own direction —
7035 /// the monotonicity a per-frame correction against a paint-advancing offset
7036 /// would break.
7037 fn assert_monotonic_upward(trace: &[(f64, bool, f64)]) {
7038 for pair in trace.windows(2) {
7039 let (before, _, _) = pair[0];
7040 let (after, _, _) = pair[1];
7041 assert!(
7042 after <= before,
7043 "the offset stepped backwards during an upward fling \
7044 ({before} -> {after})"
7045 );
7046 }
7047 }
7048
7049 #[test]
7050 fn an_upward_fling_accumulates_the_correction_and_applies_it_at_settle() {
7051 // Criterion 3. Every frame of this fling pulls unmeasured rows in above
7052 // the viewport top and measures them *taller*, i.e. a correction
7053 // pointing against the fling: withheld, the trajectory is the fling's
7054 // alone; applied per frame it would step backwards once the decaying
7055 // fling advance drops under the per-frame correction.
7056 let fx = VarRows::new(tall_ids(300));
7057 let mut logic = fx.logic();
7058 let mut root = converged_var(&mut logic, &fx);
7059
7060 // Jump deep into never-measured territory to fling back up through.
7061 root.event(&mut (), &wheel(8_000.0));
7062 settle(&mut root, &mut logic, &fx, 100.0, 4);
7063
7064 let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
7065 assert_monotonic_upward(&trace);
7066 assert!(
7067 trace.iter().any(|&(_, _, pending)| pending > 0.0),
7068 "the fling crossed rows measuring taller than the estimate"
7069 );
7070 let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
7071 assert!(
7072 owed > 0.0,
7073 "the whole accumulated sum is still owed when the fling stops"
7074 );
7075
7076 // The first rebuild after it stops commits the sum — invisibly.
7077 let w = list_widget(&root);
7078 let (anchor_id, anchor_y) = anchor_row(w, &fx);
7079 let (flags, _) = var_frame(&mut root, &mut logic, &fx, 6_000.0);
7080 let w = list_widget(&root);
7081 assert!(
7082 (w.offset() - (offset_at_rest + owed)).abs() < 1e-9,
7083 "settling committed the accumulated correction in full"
7084 );
7085 let (id_after, y_after) = anchor_row(w, &fx);
7086 assert_eq!(id_after, anchor_id, "the same row is still at the top");
7087 assert!(
7088 (y_after - anchor_y).abs() < 1e-9,
7089 "committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
7090 );
7091 assert!(flags.contains(ChangeFlags::LAYOUT));
7092
7093 // ...and the list comes to rest instead of oscillating.
7094 let mut settled = None;
7095 for k in 0..6 {
7096 let before = list_widget(&root).offset();
7097 var_frame(&mut root, &mut logic, &fx, 6_016.0 + 16.0 * k as f64);
7098 let w = list_widget(&root);
7099 if w.pending_correction == 0.0 && w.offset() == before {
7100 settled = Some(before);
7101 break;
7102 }
7103 }
7104 let settled = settled.expect("the list settles after the fling");
7105 for k in 0..3 {
7106 var_frame(&mut root, &mut logic, &fx, 6_112.0 + 16.0 * k as f64);
7107 assert_eq!(
7108 list_widget(&root).offset(),
7109 settled,
7110 "the offset is at rest, not oscillating"
7111 );
7112 }
7113 }
7114
7115 #[test]
7116 fn an_upward_fling_over_shorter_rows_accumulates_the_other_way() {
7117 // The mirror: rows measure 40 against the same 60px estimate, so the
7118 // withheld sum is negative — the direction that, applied per frame,
7119 // over-travels the fling rather than reversing it. Same contract.
7120 let fx = VarRows::new(short_ids(400));
7121 let mut logic = fx.logic();
7122 let mut root = converged_var(&mut logic, &fx);
7123
7124 root.event(&mut (), &wheel(8_000.0));
7125 settle(&mut root, &mut logic, &fx, 100.0, 4);
7126
7127 let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
7128 assert_monotonic_upward(&trace);
7129 let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
7130 assert!(owed < 0.0, "shorter-than-estimated rows owe a negative sum");
7131
7132 let w = list_widget(&root);
7133 let (anchor_id, anchor_y) = anchor_row(w, &fx);
7134 var_frame(&mut root, &mut logic, &fx, 6_000.0);
7135 let w = list_widget(&root);
7136 assert!((w.offset() - (offset_at_rest + owed)).abs() < 1e-9);
7137 let (id_after, y_after) = anchor_row(w, &fx);
7138 assert_eq!(id_after, anchor_id);
7139 assert!(
7140 (y_after - anchor_y).abs() < 1e-9,
7141 "committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
7142 );
7143 }
7144
7145 #[test]
7146 fn layout_and_paint_never_move_the_offset_while_a_correction_is_pending() {
7147 // Criterion 4, pass by pass: layout may measure a correction but never
7148 // apply one, paint touches the offset only through the fling pump (inert
7149 // here), and rebuild is the one pass that commits.
7150 let fx = VarRows::new(tall_ids(200));
7151 let mut logic = fx.logic();
7152 let mut root = converged_var(&mut logic, &fx);
7153
7154 root.event(&mut (), &wheel(1_500.0));
7155 fx.probe.pass.set(Pass::Rebuild);
7156 root.rebuild(&mut logic, &mut ());
7157 let after_rebuild = list_widget(&root).offset();
7158
7159 fx.probe.pass.set(Pass::Layout);
7160 root.layout(KEYED_WINDOW);
7161 let w = list_widget(&root);
7162 assert!(
7163 w.pending_correction != 0.0,
7164 "layout measured a correction to record"
7165 );
7166 assert_eq!(
7167 w.offset(),
7168 after_rebuild,
7169 "...and did not apply it to the offset"
7170 );
7171
7172 fx.probe.pass.set(Pass::Paint);
7173 let mut sink = NullScene;
7174 let outcome = root.paint(&mut sink, FrameTime::from_nanos(100_000_000));
7175 fx.probe.pass.set(Pass::Idle);
7176 let w = list_widget(&root);
7177 assert!(
7178 !w.is_flinging(),
7179 "no fling is in flight, so the pump is inert"
7180 );
7181 assert_eq!(
7182 w.offset(),
7183 after_rebuild,
7184 "paint moves the offset only via the fling pump"
7185 );
7186 assert!(w.pending_correction != 0.0, "the correction is still owed");
7187 assert!(
7188 outcome.needs_frame,
7189 "and paint asked for the frame that commits it"
7190 );
7191
7192 let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
7193 let w = list_widget(&root);
7194 assert_eq!(w.pending_correction, 0.0);
7195 assert!(
7196 w.offset() > after_rebuild,
7197 "rebuild is the pass that commits it"
7198 );
7199 assert!(flags.contains(ChangeFlags::LAYOUT));
7200 }
7201
7202 #[test]
7203 fn uniform_and_positional_modes_never_carry_a_correction() {
7204 // Criterion 5: this is variable-extent-only machinery. A uniform keyed
7205 // list and a positional list measure nothing, so neither can ever owe a
7206 // correction — and their placement is literally their offset.
7207 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
7208 let mut keyed = fx.keyed_logic();
7209 let mut root = converged(&mut keyed, &fx.log);
7210 root.event(&mut (), &wheel(500.0));
7211 keyed_frame(&mut root, &mut keyed, &fx.log, 32.0);
7212 fx.rows.borrow_mut().splice(0..0, [9990, 9980]);
7213 keyed_frame(&mut root, &mut keyed, &fx.log, 48.0);
7214 let w = list_widget(&root);
7215 assert_eq!(
7216 w.pending_correction, 0.0,
7217 "a uniform keyed list measures nothing to correct from"
7218 );
7219 assert_eq!(w.placement_offset(), w.offset());
7220
7221 let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
7222 let mut positional = fx.positional_logic();
7223 let mut root = converged(&mut positional, &fx.log);
7224 root.event(&mut (), &wheel(500.0));
7225 keyed_frame(&mut root, &mut positional, &fx.log, 32.0);
7226 fx.rows.borrow_mut().insert(0, 12_345);
7227 keyed_frame(&mut root, &mut positional, &fx.log, 48.0);
7228 let w = list_widget(&root);
7229 assert_eq!(
7230 w.pending_correction, 0.0,
7231 "a positional list measures nothing to correct from"
7232 );
7233 assert_eq!(w.placement_offset(), w.offset());
7234 }
7235
7236 /// A hand-built variable-extent widget carrying an uncommitted correction and
7237 /// a near-start callback — the deterministic shape of "an uncommitted
7238 /// correction decides no edge" (the [`near_start_widget`] idiom).
7239 fn pending_correction_widget(pending: f64) -> ListViewWidget {
7240 let mut w = ListViewWidget::new(1000, ESTIMATE);
7241 w.key_of = Some(Rc::new(|i: usize| ChildKey::new(i as u64)));
7242 w.estimated_extent = Some(ESTIMATE);
7243 w.viewport = Size::new(200.0, 200.0);
7244 w.near_start_threshold = 100.0;
7245 let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
7246 w.on_near_start = Some(crate::authoring::erase_callback(&cb));
7247 w.near_start_armed = true;
7248 // The raw offset sits inside the near-start threshold; the content it
7249 // describes does not.
7250 w.offset = 50.0;
7251 w.pending_correction = pending;
7252 w
7253 }
7254
7255 #[test]
7256 fn an_uncommitted_correction_decides_no_edge() {
7257 // The edge-latch half: edges read the placement, so an uncommitted
7258 // correction neither fires one on the raw offset's say-so nor re-fires
7259 // one when it commits (the placement is exactly what a commit leaves
7260 // unchanged).
7261 let mut w = pending_correction_widget(500.0);
7262 let mut state = Loads::default();
7263 run_loads(&mut w, &mut state, &wheel(0.0));
7264 assert_eq!(
7265 state.count, 0,
7266 "at a placement of 550 the list is nowhere near the start"
7267 );
7268
7269 assert!(w.apply_pending_correction(), "the commit applies");
7270 assert_eq!(w.offset(), 550.0);
7271 assert_eq!(w.pending_correction, 0.0);
7272 run_loads(&mut w, &mut state, &wheel(0.0));
7273 assert_eq!(
7274 state.count, 0,
7275 "committing a correction is bookkeeping, not scroll motion"
7276 );
7277
7278 // A genuine approach still fires it, exactly once.
7279 run_loads(&mut w, &mut state, &wheel(-500.0));
7280 assert_eq!(w.offset(), 50.0);
7281 assert_eq!(state.count, 1, "a real approach fires the load-older edge");
7282 run_loads(&mut w, &mut state, &wheel(-20.0));
7283 assert_eq!(state.count, 1, "and it stays edge-triggered");
7284 }
7285
7286 #[test]
7287 fn a_live_fling_withholds_the_correction_until_it_stops() {
7288 // The accumulate-then-apply rule at its own granularity, over both ways
7289 // a fling ends: decaying under FLING_STOP, and a pointer taking over.
7290 let mut w = pending_correction_widget(500.0);
7291 w.fling = Some(-40.0);
7292 assert!(
7293 !w.apply_pending_correction(),
7294 "a live fling withholds the commit"
7295 );
7296 assert_eq!(w.pending_correction, 500.0, "the sum keeps accumulating");
7297 for _ in 0..12 {
7298 w.tick(16.0);
7299 }
7300 assert!(!w.is_flinging(), "the fling decayed under FLING_STOP");
7301 assert!(w.apply_pending_correction(), "and the next rebuild commits");
7302 assert_eq!(w.pending_correction, 0.0);
7303
7304 // Pointer-down takeover: same commit, one event earlier.
7305 let mut w = pending_correction_widget(500.0);
7306 let mut state = Loads::default();
7307 w.fling = Some(-4_000.0);
7308 assert!(!w.apply_pending_correction());
7309 run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
7310 assert!(!w.is_flinging(), "a pointer down takes the gesture over");
7311 assert!(w.apply_pending_correction());
7312 assert_eq!(w.pending_correction, 0.0);
7313 }
7314
7315 #[cfg(debug_assertions)]
7316 #[test]
7317 #[should_panic(expected = "duplicate key")]
7318 fn duplicate_keys_trip_the_debug_assert_on_build() {
7319 let mut logic = |_: &mut ()| {
7320 ListView::builder_keyed(
7321 5,
7322 ROW_EXTENT,
7323 |_| ChildKey::new(7u32),
7324 |i| any::<(), _>(gen_stub(i)),
7325 )
7326 };
7327 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
7328 frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
7329 }
7330
7331 #[cfg(debug_assertions)]
7332 #[test]
7333 #[should_panic(expected = "duplicate key")]
7334 fn duplicate_keys_trip_the_debug_assert_on_rebuild() {
7335 let collide = Rc::new(Cell::new(false));
7336 let collide_l = collide.clone();
7337 let mut logic = move |_: &mut ()| {
7338 let collide = collide_l.clone();
7339 ListView::builder_keyed(
7340 5,
7341 ROW_EXTENT,
7342 move |i| ChildKey::new(if collide.get() { 7 } else { i }),
7343 |i| any::<(), _>(gen_stub(i)),
7344 )
7345 };
7346 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
7347 frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
7348 collide.set(true);
7349 frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 16.0);
7350 }
7351
7352 // --- (13) The M3E stretch effect, the twin of `scroll.rs`'s own group:
7353 // rows stay where the windowing offset puts them and the pull is
7354 // painted as an affine about the held edge instead. ---
7355
7356 /// [`rubber_band_logic`] with [`OverscrollEffect::Stretch`] selected on the
7357 /// view, so the effect reaches the widget through the real build/rebuild
7358 /// path rather than being poked onto the element. Pinned to `RubberBand`
7359 /// like the rest of the feel fixtures — the pull *values* the stretch is
7360 /// read from are this physics' (the effect itself is physics-agnostic, and
7361 /// `stretch_under_boundary_rejection_uses_edge_pull` covers the clamping
7362 /// pairing the Android default ships).
7363 fn stretch_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
7364 move |_: &mut ()| {
7365 let mut view = list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
7366 .physics(RubberBand::new());
7367 view.effect = OverscrollEffect::Stretch;
7368 view
7369 }
7370 }
7371
7372 /// Drive the shared past-top drag: `Down`, a 40px past-slop takeover, then
7373 /// 20px further past the already-at-top edge (`scroll.rs`'s fixture
7374 /// gesture), painting a frame between each so the tree stays live.
7375 fn drag_20px_past_top(
7376 root: &mut RenderRoot<(), ListView<()>>,
7377 logic: &mut impl FnMut(&mut ()) -> ListView<()>,
7378 state: &mut (),
7379 window: Size,
7380 ) {
7381 frame(root, logic, state, window, 0.0);
7382 frame(root, logic, state, window, 16.0);
7383 root.event(state, &ev(PointerPhase::Down, 50.0));
7384 frame(root, logic, state, window, 32.0);
7385 root.event(state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
7386 frame(root, logic, state, window, 48.0);
7387 root.event(state, &ev(PointerPhase::Move, 110.0));
7388 }
7389
7390 /// Paint the live tree into a recording scene and hand back the transforms
7391 /// the stretch pushed (mirrors `scroll.rs`'s `painted_stretch` observable).
7392 fn painted_transforms(root: &mut RenderRoot<(), ListView<()>>, ms: f64) -> Vec<kurbo::Affine> {
7393 let mut scene = crate::test_support::RecordingScene::default();
7394 root.paint(&mut scene, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
7395 assert_eq!(
7396 scene.transforms.len(),
7397 scene.transform_pops as usize,
7398 "every pushed transform must be popped in the same paint"
7399 );
7400 scene.transforms
7401 }
7402
7403 #[test]
7404 fn stretch_keeps_row_origins_fixed() {
7405 let window = Size::new(200.0, 200.0);
7406
7407 // The same drag under each effect: the physics' answer is identical,
7408 // only what paint does with it differs.
7409 let mut translated: RenderRoot<(), ListView<()>> = RenderRoot::new();
7410 drag_20px_past_top(
7411 &mut translated,
7412 &mut rubber_band_logic(1000),
7413 &mut (),
7414 window,
7415 );
7416 let mut stretched: RenderRoot<(), ListView<()>> = RenderRoot::new();
7417 drag_20px_past_top(&mut stretched, &mut stretch_logic(1000), &mut (), window);
7418
7419 let translate = list_widget(&translated);
7420 let stretch = list_widget(&stretched);
7421 assert_eq!(stretch.offset(), 0.0, "the windowing offset never moves");
7422 assert_eq!(
7423 stretch.overscroll, -10.0,
7424 "the resisted displacement is the physics' answer, effect or not"
7425 );
7426 assert_eq!(translate.overscroll, stretch.overscroll);
7427 assert_eq!(translate.edge_pull, stretch.edge_pull);
7428
7429 // Row 0 sits at content y = 0: Translate paints it 10px down (the
7430 // displacement), Stretch leaves it at the viewport's top edge.
7431 assert_eq!(translate.children[0].origin().y, 10.0);
7432 assert_eq!(stretch.children[0].origin().y, 0.0);
7433 assert_eq!(
7434 translate.children[0].origin().y - stretch.children[0].origin().y,
7435 -stretch.overscroll,
7436 "the two fixtures differ by exactly the overscroll displacement"
7437 );
7438
7439 // …and only the stretched one paints a transform for the pull, about
7440 // the pulled (top) edge: `[1, 0, 0, s, 0, anchor·(1 − s)]`.
7441 assert!(painted_transforms(&mut translated, 64.0).is_empty());
7442 let transforms = painted_transforms(&mut stretched, 64.0);
7443 assert_eq!(
7444 transforms.len(),
7445 1,
7446 "one stretch transform for the viewport"
7447 );
7448 let [a, b, c, d, e, f] = transforms[0].as_coeffs();
7449 assert_eq!([a, b, c, e], [1.0, 0.0, 0.0, 0.0], "scroll-axis-only scale");
7450 assert!(
7451 (d - (1.0 + crate::scroll::stretch_intensity(-10.0, 200.0))).abs() < 1e-12,
7452 "scale is 1 + the shared curve's intensity: {d}"
7453 );
7454 assert!(
7455 f.abs() < 1e-9,
7456 "a top pull scales about the viewport's top edge: {f}"
7457 );
7458 }
7459
7460 #[test]
7461 fn stretch_settles_back_to_identity() {
7462 let window = Size::new(200.0, 200.0);
7463 let mut logic = stretch_logic(1000);
7464 let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
7465 let mut state = ();
7466 drag_20px_past_top(&mut root, &mut logic, &mut state, window);
7467 assert_eq!(painted_transforms(&mut root, 64.0).len(), 1);
7468
7469 // Release under the refresh trigger → the settle decays the pull, and
7470 // the stretch with it (no `request_layout` anywhere: the pump's own
7471 // continuation frames are what keep it animating).
7472 root.event(&mut state, &ev(PointerPhase::Up, 110.0));
7473 assert!(list_widget(&root).settling);
7474 let mut ms = 80.0;
7475 for _ in 0..30 {
7476 frame(&mut root, &mut logic, &mut state, window, ms);
7477 ms += 16.0;
7478 if !list_widget(&root).settling {
7479 break;
7480 }
7481 }
7482 let w = list_widget(&root);
7483 assert!(!w.settling, "the settle terminated");
7484 assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
7485 assert_eq!(w.overscroll, 0.0);
7486 assert_eq!(w.children[0].origin().y, 0.0, "the rows never moved");
7487 assert!(
7488 painted_transforms(&mut root, ms).is_empty(),
7489 "…so paint pushes no transform at all — back to identity"
7490 );
7491 }
7492
7493 /// The one transform a bare fixture's own `paint` pushes for the stretch,
7494 /// as `(anchor_y, scale_y)` off the
7495 /// `translate(anchor)·scale(1, s)·translate(−anchor)` sandwich — the twin
7496 /// of `scroll.rs`'s `painted_stretch`, over a widget with no materialized
7497 /// rows so the stretch is the whole scene. `None` when paint pushed none.
7498 ///
7499 /// **At rest only**: `paint` pumps the animation clock, so calling this
7500 /// mid-flight would reseed it against this context's zero frame time.
7501 fn painted_stretch_at_rest(w: &mut ListViewWidget) -> Option<(f64, f64)> {
7502 let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
7503 let mut scene = crate::test_support::RecordingScene::default();
7504 w.paint(&mut ctx, &mut scene);
7505 assert_eq!(
7506 scene.transforms.len(),
7507 scene.transform_pops as usize,
7508 "every pushed transform must be popped in the same paint"
7509 );
7510 let [a, b, c, d, e, f] = scene.transforms.first()?.as_coeffs();
7511 assert_eq!(
7512 [a, b, c, e],
7513 [1.0, 0.0, 0.0, 0.0],
7514 "a scroll-axis-only scale: no x scale, no skew, no x translation"
7515 );
7516 Some((f / (1.0 - d), d))
7517 }
7518
7519 /// The `ScrollView` twin of this test lives in `scroll.rs`, as
7520 /// `clamping_fling_into_the_edge_settles_the_stretch`.
7521 #[test]
7522 fn clamping_fling_into_the_edge_settles_the_stretch() {
7523 use crate::physics::Tolerance;
7524 use crate::physics::simulation::ClampingScrollSimulation;
7525
7526 // The shipped Android pairing, run on the host: parked 100px short of
7527 // the bottom, released at 1000 px/s straight into it. The clamping
7528 // curve is unbounded, so the driver pins the offset and routes the
7529 // whole overshoot into `edge_pull` — the release must not leave that
7530 // pull, and the stretch it paints, standing. A bare fixture (no rows
7531 // materialized): this pins the release/settle arithmetic, not
7532 // windowing.
7533 let mut w = ListViewWidget::new(1000, 50.0);
7534 w.viewport = Size::new(200.0, 200.0);
7535 w.physics = Rc::new(Clamping::new());
7536 w.effect = OverscrollEffect::Stretch;
7537 let bottom = w.max_offset();
7538 dispatch_list(&mut w, &wheel(bottom - 100.0), 0.0);
7539 assert_eq!(w.offset(), bottom - 100.0, "parked 100px short of the end");
7540
7541 dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
7542 dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); // 32px > slop
7543 dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
7544 dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
7545 assert_close(release_velocity(&w), 1000.0, 1e-9, "the release velocity");
7546 assert!(w.ballistic.is_some(), "…as a real ballistic curve");
7547
7548 let curve = ClampingScrollSimulation::new(
7549 bottom - 100.0,
7550 1000.0,
7551 ClampingScrollSimulation::DEFAULT_FRICTION,
7552 Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
7553 );
7554 assert!(
7555 curve.final_x() > bottom + 100.0,
7556 "the spline must overshoot the extent by >100px: {}",
7557 curve.final_x()
7558 );
7559 let spline_frames = (curve.duration() * 1000.0 / 16.0).ceil();
7560
7561 let mut ms = 100.0;
7562 let mut frames = 0.0;
7563 let mut ballistic_frames = 0.0;
7564 let mut peak = 0.0f64;
7565 loop {
7566 let mut ctx = PaintCtx::for_test(
7567 Point::ZERO,
7568 w.viewport,
7569 FrameTime::from_nanos((ms * 1_000_000.0) as u64),
7570 );
7571 w.pump_fling(&mut ctx);
7572 peak = peak.max(w.edge_pull.abs());
7573 if w.ballistic.is_some() {
7574 ballistic_frames += 1.0;
7575 }
7576 ms += 16.0;
7577 frames += 1.0;
7578 assert!(
7579 frames < 2_000.0,
7580 "the release never came to rest: edge_pull {}",
7581 w.edge_pull
7582 );
7583 if !ctx.needs_frame() {
7584 break;
7585 }
7586 }
7587
7588 assert!(
7589 peak > 1.0,
7590 "the fling must actually reach the edge for this to mean anything: {peak}"
7591 );
7592 assert_close(
7593 w.offset(),
7594 bottom,
7595 1e-9,
7596 "the offset ends pinned at the end",
7597 );
7598 assert_eq!(
7599 w.overscroll, 0.0,
7600 "the windowing offset carries no leftover"
7601 );
7602 assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
7603 assert_eq!(
7604 painted_stretch_at_rest(&mut w),
7605 None,
7606 "…so paint pushes no transform at all — back to identity"
7607 );
7608 assert!(
7609 ballistic_frames < spline_frames / 2.0,
7610 "the pinned curve ran {ballistic_frames} frames of a {spline_frames}-frame spline"
7611 );
7612 }
7613
7614 /// The windowing list's mirror of `ScrollWidget`'s focus bypass: a clipboard
7615 /// verb reaches the focused *row* through `route_event`'s focus branch, not
7616 /// the gesture machinery and not a hit test.
7617 #[test]
7618 fn an_edit_command_reaches_the_focused_row() {
7619 use crate::text_input;
7620 use frust_core::EditCommand;
7621
7622 struct Field {
7623 value: String,
7624 }
7625 fn logic(state: &mut Field) -> ListView<Field> {
7626 let value = state.value.clone();
7627 list_view(3, 50.0, move |_| {
7628 let value = value.clone();
7629 any::<Field, _>(text_input(value, |s: &mut Field, v: String| {
7630 s.value = v;
7631 }))
7632 })
7633 }
7634
7635 let mut state = Field {
7636 value: "hello".to_string(),
7637 };
7638 let mut root: RenderRoot<Field, ListView<Field>> = RenderRoot::new();
7639 root.rebuild(&mut logic, &mut state);
7640 root.layout(Size::new(200.0, 200.0));
7641
7642 // Tap the first row so it holds the recorded focus path.
7643 root.event(&mut state, &ev(PointerPhase::Down, 10.0));
7644 root.event(&mut state, &ev(PointerPhase::Up, 10.0));
7645 assert!(root.is_focus_active(), "the tap focused the row's field");
7646
7647 root.event(&mut state, &InputEvent::EditCommand(EditCommand::SelectAll));
7648 root.event(&mut state, &InputEvent::EditCommand(EditCommand::Copy));
7649
7650 assert_eq!(
7651 root.take_clipboard_write().as_deref(),
7652 Some("hello"),
7653 "the copy was answered by the focused row, through this router"
7654 );
7655 assert_eq!(state.value, "hello", "a copy edits nothing");
7656 }
7657}
7658
7659/// A takeover from a captured row ends a contact opt-in held inside it.
7660#[cfg(test)]
7661mod contact_release_tests {
7662 use super::*;
7663 use std::any::Any;
7664
7665 /// A row that captures every `Down`, opting into the gesture's other
7666 /// contacts when `opt_in` is set.
7667 struct Grab {
7668 opt_in: bool,
7669 }
7670 impl Widget for Grab {
7671 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
7672 bc.max()
7673 }
7674 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
7675 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
7676 if let InputEvent::Pointer(p) = event
7677 && p.phase == PointerPhase::Down
7678 {
7679 ctx.capture_pointer();
7680 if self.opt_in {
7681 ctx.capture_contacts();
7682 }
7683 }
7684 EventResult::Handled
7685 }
7686 }
7687
7688 /// A 200 px list of ten 200 px rows whose one materialized row is a
7689 /// [`Grab`].
7690 fn list(opt_in: bool) -> ListViewWidget {
7691 let mut w = ListViewWidget::new(10, 200.0);
7692 w.viewport = Size::new(200.0, 200.0);
7693 let mut pod = ChildPod::new(Box::new(Grab { opt_in }));
7694 pod.layout_child(
7695 &mut LayoutCtx::new(),
7696 &BoxConstraints::tight(Size::new(200.0, 200.0)),
7697 );
7698 w.children = vec![pod];
7699 w.keys = vec![0];
7700 w.sync_child_origins();
7701 w
7702 }
7703
7704 fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
7705 InputEvent::Pointer(PointerEvent {
7706 phase,
7707 position: Point::new(10.0, y),
7708 button: PointerButton::Primary,
7709 })
7710 }
7711
7712 /// Dispatch into the list; whether a capture release bubbled out of it.
7713 fn released(w: &mut ListViewWidget, event: &InputEvent, t_ms: f64) -> bool {
7714 let mut unit = ();
7715 let sa: &mut dyn Any = &mut unit;
7716 let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
7717 w.event_at(&mut ctx, event, t_ms);
7718 ctx.is_capture_released()
7719 }
7720
7721 #[test]
7722 fn a_takeover_releases_the_row_and_signals_only_an_opt_in() {
7723 for opt_in in [true, false] {
7724 let mut w = list(opt_in);
7725 assert!(!released(&mut w, &pointer(PointerPhase::Down, 100.0), 0.0));
7726 assert!(w.children[0].is_active(), "the row captured");
7727 let signalled = released(&mut w, &pointer(PointerPhase::Move, 60.0), 16.0);
7728 assert!(w.scrolling, "the list took the drag over");
7729 assert!(!w.children[0].is_active(), "and released the row");
7730 assert_eq!(signalled, opt_in, "signalled only when the row opted in");
7731 }
7732 }
7733}
7734
7735#[cfg(test)]
7736mod contact_tests {
7737 use super::*;
7738 use crate::{PanZoomTransform, pan_zoom, pinch_detector};
7739 use frust_core::RenderRoot;
7740 use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
7741 use std::any::Any;
7742 use std::cell::RefCell;
7743
7744 #[derive(Default)]
7745 #[allow(dead_code)]
7746 struct App {
7747 transforms: Vec<PanZoomTransform>,
7748 scales: Vec<ScaleEvent>,
7749 }
7750
7751 type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
7752
7753 /// A 200-px-tall row content. Logs every pointer event it receives (into
7754 /// a shared log, never app state, so its `Cancel` arm stays state-free).
7755 /// With `grabs` it captures every primary `Down` and handles it — opting
7756 /// into the gesture's other contacts too with `opt_in` — and otherwise
7757 /// ignores pointers. With `consumes` it reports `Handled` for a `Scale`
7758 /// and for a broadcast; otherwise it ignores both.
7759 #[derive(Clone)]
7760 #[allow(dead_code)]
7761 struct RowContent {
7762 seen: Seen,
7763 grabs: bool,
7764 opt_in: bool,
7765 consumes: bool,
7766 }
7767 #[allow(dead_code)]
7768 struct RowContentWidget(RowContent);
7769 impl View<App> for RowContent {
7770 type Element = RowContentWidget;
7771 fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowContentWidget {
7772 RowContentWidget(self.clone())
7773 }
7774 fn rebuild(
7775 &self,
7776 _p: &Self,
7777 _e: &mut RowContentWidget,
7778 _c: &mut BuildCtx<'_>,
7779 ) -> ChangeFlags {
7780 ChangeFlags::NONE
7781 }
7782 }
7783 impl Widget for RowContentWidget {
7784 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
7785 bc.constrain(Size::new(400.0, 200.0))
7786 }
7787 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
7788 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
7789 match event {
7790 InputEvent::Pointer(p) => {
7791 self.0.seen.borrow_mut().push((ctx.pointer_id(), p.phase));
7792 if !self.0.grabs {
7793 return EventResult::Ignored;
7794 }
7795 if p.phase == PointerPhase::Down {
7796 ctx.capture_pointer();
7797 if self.0.opt_in {
7798 ctx.capture_contacts();
7799 }
7800 }
7801 EventResult::Handled
7802 }
7803 InputEvent::Scale(_) | InputEvent::Housekeeping if self.0.consumes => {
7804 EventResult::Handled
7805 }
7806 _ => EventResult::Ignored,
7807 }
7808 }
7809 }
7810
7811 #[allow(dead_code)]
7812 fn row_content(grabs: bool, opt_in: bool, consumes: bool) -> (RowContent, Seen) {
7813 let seen = Seen::default();
7814 let view = RowContent {
7815 seen: seen.clone(),
7816 grabs,
7817 opt_in,
7818 consumes,
7819 };
7820 (view, seen)
7821 }
7822
7823 #[allow(dead_code)]
7824 struct NullScene;
7825 impl PaintScene for NullScene {
7826 fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
7827 fn draw_text(&mut self, _o: Point, _t: &str) {}
7828 }
7829
7830 #[allow(dead_code)]
7831 fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
7832 InputEvent::PointerContact {
7833 pointer_id: PointerId::touch(slot),
7834 event: PointerEvent {
7835 phase,
7836 position: Point::new(x, y),
7837 button: PointerButton::Primary,
7838 },
7839 }
7840 }
7841
7842 /// A root over `logic`, laid out in a 400 × 300 window with a 10-item
7843 /// uniform-extent list where each item is 200 px tall.
7844 #[allow(dead_code)]
7845 fn root_over<V: View<App>>(logic: impl Fn() -> V + 'static) -> (RenderRoot<App, V>, App) {
7846 let mut root: RenderRoot<App, V> = RenderRoot::new();
7847 let mut state = App::default();
7848 root.rebuild(&mut move |_: &mut App| logic(), &mut state);
7849 root.layout(Size::new(400.0, 300.0));
7850 (root, state)
7851 }
7852
7853 #[allow(dead_code)]
7854 fn viewport(root: &RenderRoot<App, ListView<App>>) -> &ListViewWidget {
7855 let id = root.root_id().expect("root built");
7856 (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
7857 .downcast_ref::<ListViewWidget>()
7858 .expect("root is a ListViewWidget")
7859 }
7860
7861 #[test]
7862 fn a_pinch_survives_the_claimants_own_travel_past_slop() {
7863 use PointerPhase::{Down, Move};
7864 let (mut root, mut state) = root_over(|| {
7865 let (content, _seen) = row_content(false, false, false);
7866 ListView::builder(10, 200.0, move |_| {
7867 pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7868 })
7869 });
7870 let mut sink = NullScene;
7871 let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
7872 root.paint(&mut sink, ms(0));
7873 root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7874 root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7875 root.paint(&mut sink, ms(16));
7876 // The CLAIMANT's own finger spreads the pair vertically, past the
7877 // list's `TOUCH_SLOP` — a list reading only the `Down`-time
7878 // claim snapshot (always unregistered here; `pinch_detector` is not a
7879 // nested scrollable) would steal this as an ordinary scroll drag
7880 // (the counterexample this card fixes).
7881 root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7882 root.paint(&mut sink, ms(32));
7883 root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7884
7885 let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
7886 assert_eq!(
7887 phases,
7888 [ScalePhase::Begin, ScalePhase::Update],
7889 "the pinch recognizer saw the whole spread"
7890 );
7891 let list = viewport(&root);
7892 assert_eq!(list.offset(), 0.0, "the list never scrolled");
7893 assert!(!list.scrolling, "and never took the gesture over");
7894 assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
7895 assert!(
7896 root.pointer_capture_contacts(),
7897 "the list never cancelled/released the detector's opt-in"
7898 );
7899 }
7900
7901 #[test]
7902 fn a_pinch_over_a_child_owned_press_survives_the_claimants_own_travel() {
7903 use PointerPhase::{Down, Move};
7904 // `grabs = true`: the content captures the primary `Down` itself, so
7905 // `PanZoomWidget::begin_gesture` takes the child-owned branch, which
7906 // publishes nothing into the nested-scroll claim.
7907 let (mut root, mut state) = root_over(|| {
7908 let (content, _seen) = row_content(true, false, false);
7909 ListView::builder(10, 200.0, move |_| {
7910 pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t))
7911 })
7912 });
7913 root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7914 root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7915 // The claimant's finger travels well past TOUCH_SLOP vertically while
7916 // a second contact is tracked — exactly the counterexample this card
7917 // fixes for `PanZoomView`'s child-owned-press branch in a list.
7918 root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7919 root.event(&mut state, &touch(1, Move, 180.0, 40.0));
7920
7921 let list = viewport(&root);
7922 assert_eq!(list.offset(), 0.0, "the list never scrolled");
7923 assert!(!list.scrolling, "and never took the gesture over");
7924 assert!(
7925 !state.transforms.is_empty(),
7926 "pan_zoom's own pinch zoomed the view instead"
7927 );
7928 }
7929
7930 #[test]
7931 fn a_single_finger_drag_still_scrolls_through_a_pinch_detector_with_no_second_finger() {
7932 use PointerPhase::{Down, Move};
7933 let (mut root, mut state) = root_over(|| {
7934 let (content, _seen) = row_content(false, false, false);
7935 ListView::builder(10, 200.0, move |_| {
7936 pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7937 })
7938 });
7939 root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7940 root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7941 assert!(
7942 viewport(&root).scrolling,
7943 "no second finger ever arrived to veto the takeover"
7944 );
7945 root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7946 assert_eq!(viewport(&root).offset(), 30.0);
7947 assert!(state.scales.is_empty(), "never a pinch with one finger");
7948 }
7949
7950 #[test]
7951 fn releasing_the_second_finger_clears_the_veto_and_scrolling_resumes() {
7952 use PointerPhase::{Down, Move, Up};
7953 let (mut root, mut state) = root_over(|| {
7954 let (content, _seen) = row_content(true, false, false);
7955 ListView::builder(10, 200.0, move |_| {
7956 pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7957 })
7958 });
7959 let mut sink = NullScene;
7960 let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
7961 root.paint(&mut sink, ms(0));
7962 root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7963 root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7964 root.paint(&mut sink, ms(16));
7965 root.event(&mut state, &touch(0, Move, 100.0, 40.0)); // past slop while paired: no takeover
7966 assert!(
7967 !viewport(&root).scrolling,
7968 "the pinch still owns the gesture"
7969 );
7970
7971 root.event(&mut state, &touch(1, Up, 120.0, 40.0)); // the second finger lifts: pinch ends
7972
7973 // The claimant's very next `Move` is measured against its original
7974 // `down_start` as usual (the veto does not replay the suppressed slop
7975 // check) — already well past `TOUCH_SLOP`, so the list takes the
7976 // drag over immediately once the veto clears, per the existing
7977 // single-finger scroll contract.
7978 root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7979 assert!(
7980 viewport(&root).scrolling,
7981 "the list resumed scrolling once the pinch ended"
7982 );
7983 }
7984}
7985
7986#[cfg(test)]
7987mod controller_tests {
7988 //! The [`ScrollController`] seam on a list: offset commands mirrored from
7989 //! `ScrollView`, and `scroll_to_item`'s resolve → jump/tween →
7990 //! settle-and-correct loop over measured-or-estimated extents.
7991
7992 use std::any::Any;
7993 use std::cell::RefCell;
7994 use std::rc::Rc;
7995
7996 use frust_core::{Curve, PaintOutcome, RenderRoot, any};
7997
7998 use super::*;
7999 use crate::physics::parity::Bouncing;
8000 use crate::physics::rubber_band::RubberBand;
8001 use crate::test_support::{RecordingScene, leaf};
8002
8003 /// The list's viewport (the root window).
8004 const VIEWPORT: Size = Size::new(200.0, 200.0);
8005 /// The estimate every variable-extent fixture declares — unequal to every
8006 /// true row height, so an estimated region is visibly estimated.
8007 const ESTIMATE: f64 = 60.0;
8008 /// The uniform fixture's row extent.
8009 const ROW: f64 = 50.0;
8010
8011 /// Deterministic self-sized row height by id: 40/60/80/100/120, cycling.
8012 fn height_of(id: u64) -> f64 {
8013 40.0 + (id % 5) as f64 * 20.0
8014 }
8015
8016 type Logic = Box<dyn FnMut(&mut ()) -> ListView<()>>;
8017
8018 /// A keyed, variable-extent list of `count` rows keyed by their id (the
8019 /// item index) and sized by [`height_of`], with `controller` attached.
8020 fn variable_logic(count: usize, controller: &ScrollController) -> Logic {
8021 let controller = controller.clone();
8022 Box::new(move |_: &mut ()| {
8023 ListView::builder_keyed(
8024 count,
8025 ESTIMATE,
8026 |i| ChildKey::new(i as u64),
8027 |i| any::<(), _>(leaf(VIEWPORT.width, height_of(i as u64))),
8028 )
8029 .estimated_item_extent(ESTIMATE)
8030 .controller(controller.clone())
8031 })
8032 }
8033
8034 /// A keyed, uniform-extent list of `count` [`ROW`]-tall rows.
8035 fn uniform_logic(count: usize, controller: &ScrollController) -> Logic {
8036 let controller = controller.clone();
8037 Box::new(move |_: &mut ()| {
8038 ListView::builder_keyed(
8039 count,
8040 ROW,
8041 |i| ChildKey::new(i as u64),
8042 |_| any::<(), _>(leaf(VIEWPORT.width, ROW)),
8043 )
8044 .controller(controller.clone())
8045 })
8046 }
8047
8048 fn list(root: &RenderRoot<(), ListView<()>>) -> &ListViewWidget {
8049 let id = root.root_id().expect("root built");
8050 (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
8051 .downcast_ref::<ListViewWidget>()
8052 .expect("root is a ListViewWidget")
8053 }
8054
8055 /// One rebuild → layout → paint frame at `ms`.
8056 fn frame(root: &mut RenderRoot<(), ListView<()>>, logic: &mut Logic, ms: f64) -> PaintOutcome {
8057 root.rebuild(logic, &mut ());
8058 root.layout(VIEWPORT);
8059 root.paint(
8060 &mut RecordingScene::default(),
8061 FrameTime::from_nanos((ms * 1_000_000.0) as u64),
8062 )
8063 }
8064
8065 /// Drive frames from `start_ms` until paint asks for neither another frame
8066 /// nor a layout, returning how many it took; panics past `limit`.
8067 fn settle(
8068 root: &mut RenderRoot<(), ListView<()>>,
8069 logic: &mut Logic,
8070 start_ms: f64,
8071 limit: usize,
8072 ) -> usize {
8073 for n in 0..limit {
8074 let outcome = frame(root, logic, start_ms + 16.0 * n as f64);
8075 if !outcome.needs_frame && !outcome.needs_layout {
8076 return n + 1;
8077 }
8078 }
8079 panic!("the list did not settle within {limit} frames");
8080 }
8081
8082 fn converged(logic: &mut Logic) -> RenderRoot<(), ListView<()>> {
8083 let mut root = RenderRoot::new();
8084 settle(&mut root, logic, 0.0, 8);
8085 root
8086 }
8087
8088 /// Item `index`'s painted top and height in viewport coordinates, if it is
8089 /// materialized.
8090 fn row_rect(w: &ListViewWidget, index: usize) -> Option<(f64, f64)> {
8091 let slot = w.keys.iter().position(|&i| i == index)?;
8092 let pod = &w.children[slot];
8093 Some((pod.origin().y, pod.size().height))
8094 }
8095
8096 fn assert_near(actual: f64, expected: f64, what: &str) {
8097 assert!(
8098 (actual - expected).abs() <= ITEM_SETTLE_TOLERANCE_PX,
8099 "{what}: expected {expected}, got {actual}"
8100 );
8101 }
8102
8103 fn down(y: f64) -> InputEvent {
8104 InputEvent::Pointer(PointerEvent {
8105 phase: PointerPhase::Down,
8106 position: Point::new(10.0, y),
8107 button: PointerButton::Primary,
8108 })
8109 }
8110
8111 #[test]
8112 fn jump_to_and_animate_to_drive_a_list_like_a_scroll_view() {
8113 let controller = ScrollController::new();
8114 let mut logic = uniform_logic(100, &controller);
8115 let mut root = converged(&mut logic);
8116 assert!(controller.is_attached());
8117 assert_eq!(controller.max_offset(), 100.0 * ROW - VIEWPORT.height);
8118 assert_eq!(controller.viewport_extent(), VIEWPORT.height);
8119
8120 controller.jump_to(1234.0);
8121 settle(&mut root, &mut logic, 100.0, 4);
8122 assert_eq!(list(&root).offset(), 1234.0);
8123 assert_eq!(controller.offset(), 1234.0);
8124 let (top, _) = row_rect(list(&root), 24).expect("the landing row is materialized");
8125 assert_eq!(top, 24.0 * ROW - 1234.0, "the window re-materialized there");
8126
8127 controller.animate_to(
8128 234.0,
8129 AnimateTo {
8130 duration_ms: 200.0,
8131 curve: Curve::Linear,
8132 },
8133 );
8134 frame(&mut root, &mut logic, 1000.0);
8135 assert!(controller.is_animating());
8136 frame(&mut root, &mut logic, 1100.0);
8137 assert_near(controller.offset(), 734.0, "halfway through a linear tween");
8138 settle(&mut root, &mut logic, 1200.0, 6);
8139 assert_eq!(controller.offset(), 234.0);
8140 assert!(!controller.is_animating());
8141 }
8142
8143 #[test]
8144 fn scroll_to_item_on_a_uniform_list_lands_exactly() {
8145 let controller = ScrollController::new();
8146 let mut logic = uniform_logic(100, &controller);
8147 let mut root = converged(&mut logic);
8148 controller.scroll_to_item(ChildKey::new(40u64), ItemAlignment::Start, false);
8149 let frames = settle(&mut root, &mut logic, 100.0, 4);
8150 assert!(
8151 frames <= 2,
8152 "a closed-form position needs no correction ({frames} frames)"
8153 );
8154 assert_eq!(controller.offset(), 40.0 * ROW);
8155 assert_eq!(row_rect(list(&root), 40).map(|r| r.0), Some(0.0));
8156 assert!(list(&root).item_target.is_none());
8157 }
8158
8159 #[test]
8160 fn scroll_to_item_lands_an_unmeasured_row_at_start_after_settling() {
8161 let controller = ScrollController::new();
8162 let mut logic = variable_logic(300, &controller);
8163 let mut root = converged(&mut logic);
8164 let target = 250usize;
8165 assert!(
8166 !list(&root).measured.contains_key(&ChildKey::new(240u64)),
8167 "the rows ahead of the target were never measured"
8168 );
8169
8170 controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Start, false);
8171 let frames = settle(&mut root, &mut logic, 100.0, 8);
8172 assert!(
8173 frames <= usize::from(ITEM_SETTLE_MAX_FRAMES) + 1,
8174 "the settle loop is bounded ({frames} frames)"
8175 );
8176 let w = list(&root);
8177 assert!(w.item_target.is_none(), "the request finished settling");
8178 let (top, height) = row_rect(w, target).expect("the target row is materialized");
8179 assert_eq!(
8180 height,
8181 height_of(target as u64),
8182 "and measured at its own height"
8183 );
8184 assert_near(top, 0.0, "the row's top edge sits at the viewport top");
8185 assert_near(
8186 controller.offset(),
8187 w.placement_offset(),
8188 "the published offset",
8189 );
8190 }
8191
8192 #[test]
8193 fn scroll_to_item_center_and_end_alignments_correct_the_estimated_extent() {
8194 let controller = ScrollController::new();
8195 let mut logic = variable_logic(300, &controller);
8196 let mut root = converged(&mut logic);
8197
8198 // Row 154 is 120px tall against the 60px estimate, so both alignments
8199 // are wrong until the row itself has been measured.
8200 let target = 154usize;
8201 assert_eq!(height_of(target as u64), 120.0);
8202 controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Center, false);
8203 settle(&mut root, &mut logic, 100.0, 8);
8204 let (top, height) = row_rect(list(&root), target).expect("materialized");
8205 assert_near(
8206 top + height / 2.0,
8207 VIEWPORT.height / 2.0,
8208 "the row's center at the viewport center",
8209 );
8210
8211 let target = 209usize;
8212 assert_eq!(height_of(target as u64), 120.0);
8213 controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::End, false);
8214 settle(&mut root, &mut logic, 400.0, 8);
8215 let (top, height) = row_rect(list(&root), target).expect("materialized");
8216 assert_near(
8217 top + height,
8218 VIEWPORT.height,
8219 "the row's bottom at the viewport bottom",
8220 );
8221 assert!(list(&root).item_target.is_none());
8222 }
8223
8224 #[test]
8225 fn nearest_does_nothing_for_a_visible_row_and_the_least_otherwise() {
8226 let controller = ScrollController::new();
8227 let mut logic = variable_logic(300, &controller);
8228 let mut root = converged(&mut logic);
8229 // Rows 0..=3 are 40+60+80+100 = 280px; row 1 spans [40, 100), visible.
8230 controller.scroll_to_item(ChildKey::new(1u64), ItemAlignment::Nearest, false);
8231 settle(&mut root, &mut logic, 100.0, 4);
8232 assert_eq!(
8233 controller.offset(),
8234 0.0,
8235 "a fully visible row moves nothing"
8236 );
8237 assert!(list(&root).item_target.is_none());
8238
8239 // A row below the viewport aligns its bottom edge (`End`).
8240 controller.scroll_to_item(ChildKey::new(12u64), ItemAlignment::Nearest, false);
8241 settle(&mut root, &mut logic, 300.0, 8);
8242 let (top, height) = row_rect(list(&root), 12).expect("materialized");
8243 assert_near(
8244 top + height,
8245 VIEWPORT.height,
8246 "a row below lands at the bottom",
8247 );
8248
8249 // A row above the viewport aligns its top edge (`Start`).
8250 controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Nearest, false);
8251 settle(&mut root, &mut logic, 600.0, 8);
8252 let (top, _) = row_rect(list(&root), 3).expect("materialized");
8253 assert_near(top, 0.0, "a row above lands at the top");
8254 }
8255
8256 #[test]
8257 fn an_animated_scroll_to_item_eases_there_and_a_down_interrupts_it() {
8258 let controller = ScrollController::new();
8259 let mut logic = variable_logic(300, &controller);
8260 let mut root = converged(&mut logic);
8261
8262 controller.scroll_to_item(ChildKey::new(120u64), ItemAlignment::Start, true);
8263 frame(&mut root, &mut logic, 100.0); // drains and seeds the tween
8264 assert!(controller.is_animating());
8265 frame(&mut root, &mut logic, 200.0);
8266 let mid = controller.offset();
8267 assert!(mid > 0.0, "the tween is under way (offset {mid})");
8268 assert!(controller.is_animating());
8269
8270 root.event(&mut (), &down(50.0));
8271 assert!(!controller.is_animating(), "the Down ends the tween");
8272 assert!(list(&root).item_target.is_none(), "and the settle loop");
8273 assert!(list(&root).ballistic.is_none(), "the tween is really gone");
8274 // The first frame after the Down re-windows around where the tween
8275 // stopped and measures those rows (the measured anchor correction may
8276 // re-express the placement then, holding the on-screen rows still);
8277 // from there on nothing moves the content — the tween is not paused,
8278 // and no settle correction is left to run.
8279 frame(&mut root, &mut logic, 216.0);
8280 let frozen = list(&root).placement_offset();
8281 frame(&mut root, &mut logic, 600.0);
8282 frame(&mut root, &mut logic, 616.0);
8283 assert_near(
8284 list(&root).placement_offset(),
8285 frozen,
8286 "nothing moves the content afterwards",
8287 );
8288 assert!(!controller.is_animating());
8289 }
8290
8291 #[test]
8292 fn an_animated_scroll_to_item_lands_aligned_when_left_alone() {
8293 let controller = ScrollController::new();
8294 let mut logic = variable_logic(300, &controller);
8295 let mut root = converged(&mut logic);
8296 controller.scroll_to_item(ChildKey::new(120u64), ItemAlignment::Start, true);
8297 // The neutral theme's slow duration is 400ms; settle runs past it.
8298 settle(&mut root, &mut logic, 100.0, 40);
8299 assert!(!controller.is_animating());
8300 let (top, _) = row_rect(list(&root), 120).expect("materialized");
8301 assert_near(top, 0.0, "the row ends at the top after the tween + settle");
8302 }
8303
8304 #[test]
8305 fn on_change_publishes_the_settled_scroll_info() {
8306 let controller = ScrollController::new();
8307 let seen = Rc::new(RefCell::new(Vec::<ScrollInfo>::new()));
8308 let sink = Rc::clone(&seen);
8309 let _subscription = controller.on_change(move |info| sink.borrow_mut().push(info));
8310 let mut logic = variable_logic(300, &controller);
8311 let mut root = converged(&mut logic);
8312 seen.borrow_mut().clear();
8313
8314 controller.scroll_to_item(ChildKey::new(200u64), ItemAlignment::Start, false);
8315 settle(&mut root, &mut logic, 100.0, 8);
8316 let w = list(&root);
8317 let last = *seen.borrow().last().expect("the jump published");
8318 assert_eq!(
8319 last,
8320 ScrollInfo {
8321 offset: w.placement_offset(),
8322 max_offset: w.max_offset(),
8323 overscroll: 0.0,
8324 },
8325 "the last published info is the settled position"
8326 );
8327 assert_eq!(controller.offset(), last.offset);
8328 let (top, _) = row_rect(w, 200).expect("materialized");
8329 assert_near(top, 0.0, "and it is the position the row landed at");
8330 }
8331
8332 #[test]
8333 fn an_unknown_key_is_a_no_op() {
8334 let controller = ScrollController::new();
8335 let mut logic = variable_logic(50, &controller);
8336 let mut root = converged(&mut logic);
8337 controller.jump_to(300.0);
8338 settle(&mut root, &mut logic, 100.0, 6);
8339 let before = controller.offset();
8340
8341 controller.scroll_to_item("no-such-row", ItemAlignment::Start, false);
8342 settle(&mut root, &mut logic, 300.0, 4);
8343 assert_eq!(controller.offset(), before);
8344 assert!(list(&root).item_target.is_none());
8345
8346 // A positional list has no keys at all: every key is unknown.
8347 let positional = ScrollController::new();
8348 let handle = positional.clone();
8349 let mut logic: Logic = Box::new(move |_: &mut ()| {
8350 list_view(50, ROW, |_| any::<(), _>(leaf(VIEWPORT.width, ROW)))
8351 .controller(handle.clone())
8352 });
8353 let mut root = converged(&mut logic);
8354 positional.scroll_to_item(ChildKey::new(10u64), ItemAlignment::Start, false);
8355 settle(&mut root, &mut logic, 100.0, 4);
8356 assert_eq!(positional.offset(), 0.0);
8357 assert!(list(&root).item_target.is_none());
8358 }
8359
8360 /// The anchor jump a rendered Markdown document makes: 200 keyed
8361 /// heading/paragraph rows of self-sized, mostly unmeasured heights, a jump
8362 /// to the heading keyed `h-150`, and the heading at the top afterwards.
8363 #[test]
8364 fn a_markdown_style_anchor_jump_puts_the_heading_at_the_top() {
8365 fn key(i: usize) -> String {
8366 if i.is_multiple_of(2) {
8367 format!("h-{i}")
8368 } else {
8369 format!("p-{i}")
8370 }
8371 }
8372 fn height(i: usize) -> f64 {
8373 if i.is_multiple_of(2) {
8374 32.0
8375 } else {
8376 48.0 + (i * 37 % 120) as f64
8377 }
8378 }
8379 let controller = ScrollController::new();
8380 let handle = controller.clone();
8381 let mut logic: Logic = Box::new(move |_: &mut ()| {
8382 ListView::builder_keyed(
8383 200,
8384 56.0,
8385 |i| ChildKey::new(key(i)),
8386 |i| any::<(), _>(leaf(VIEWPORT.width, height(i))),
8387 )
8388 .estimated_item_extent(56.0)
8389 .controller(handle.clone())
8390 });
8391 let mut root = converged(&mut logic);
8392
8393 controller.scroll_to_item("h-150", ItemAlignment::Start, false);
8394 settle(&mut root, &mut logic, 100.0, 8);
8395 let offset = controller.offset();
8396 let w = list(&root);
8397 let slot = w
8398 .keys
8399 .iter()
8400 .position(|&i| i == 150)
8401 .expect("h-150 materialized");
8402 assert_eq!(w.slot_keys[slot], ChildKey::new("h-150"));
8403 assert_near(
8404 w.slot_y[slot],
8405 offset,
8406 "the heading's content top is the offset",
8407 );
8408 let (top, _) = row_rect(w, 150).expect("materialized");
8409 assert_near(top, 0.0, "the heading is at the top of the viewport");
8410 }
8411
8412 // --- A command applied mid-drag ends the drag (mirrors `ScrollView`'s
8413 // own tests of the same rule). ---
8414
8415 fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
8416 InputEvent::Pointer(PointerEvent {
8417 phase,
8418 position: Point::new(10.0, y),
8419 button: PointerButton::Primary,
8420 })
8421 }
8422
8423 /// A standalone, laid-out uniform keyed list of 100 [`ROW`]-tall rows
8424 /// running `physics`, with `controller` attached — driven directly rather
8425 /// than through a [`RenderRoot`] so a test can reach into its state.
8426 fn standalone(
8427 controller: &ScrollController,
8428 physics: Option<Rc<dyn ScrollPhysics>>,
8429 ) -> ListViewWidget {
8430 let view = uniform_logic(100, controller)(&mut ());
8431 let mut counter = 0u64;
8432 let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
8433 if let Some(physics) = physics {
8434 w.physics = physics;
8435 }
8436 w.layout(&mut LayoutCtx::new(), &BoxConstraints::tight(VIEWPORT));
8437 w
8438 }
8439
8440 fn send(w: &mut ListViewWidget, event: &InputEvent, t_ms: f64) {
8441 let mut unit = ();
8442 let state: &mut dyn Any = &mut unit;
8443 let mut ctx = EventCtx::new(state, Point::ZERO, w.viewport);
8444 w.event_at(&mut ctx, event, t_ms);
8445 }
8446
8447 /// Paint at an explicit frame time, returning whether paint asked for
8448 /// another frame.
8449 fn paint_at(w: &mut ListViewWidget, ms: f64) -> bool {
8450 let now = FrameTime::from_nanos((ms * 1_000_000.0) as u64);
8451 let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, now);
8452 w.paint(&mut ctx, &mut RecordingScene::default());
8453 ctx.needs_frame()
8454 }
8455
8456 /// Down, then two moves upward — the first crosses the slop and takes
8457 /// over, the second scrolls 30 px — leaving the drag live.
8458 fn drag_without_release(w: &mut ListViewWidget) {
8459 send(w, &pointer(PointerPhase::Down, 150.0), 0.0);
8460 send(w, &pointer(PointerPhase::Move, 120.0), 16.0);
8461 send(w, &pointer(PointerPhase::Move, 90.0), 32.0);
8462 assert!(w.scrolling && w.down_active, "the drag is live");
8463 }
8464
8465 /// How many of the three post-release motions are live at once.
8466 fn live_motions(w: &ListViewWidget) -> usize {
8467 usize::from(w.fling.is_some())
8468 + usize::from(w.settling)
8469 + usize::from(w.ballistic.is_some())
8470 }
8471
8472 fn both_physics() -> [Rc<dyn ScrollPhysics>; 2] {
8473 [Rc::new(RubberBand::new()), Rc::new(Bouncing::new())]
8474 }
8475
8476 #[test]
8477 fn an_animate_to_mid_drag_ends_the_drag_and_owns_the_offset() {
8478 for physics in both_physics() {
8479 let controller = ScrollController::new();
8480 let mut w = standalone(&controller, Some(physics));
8481 drag_without_release(&mut w);
8482 let start = w.offset();
8483 assert!(start > 0.0, "the drag moved the content");
8484 let stale_veto = Rc::clone(&w.live_veto);
8485
8486 controller.animate_to(
8487 start + 200.0,
8488 AnimateTo {
8489 duration_ms: 200.0,
8490 curve: Curve::Linear,
8491 },
8492 );
8493 assert!(paint_at(&mut w, 0.0), "the tween asks for frames");
8494 assert!(!w.scrolling, "the command ended the drag");
8495 assert!(!w.down_active, "and disarmed the gesture");
8496 assert!(!w.deferring);
8497 assert!(
8498 !Rc::ptr_eq(&w.live_veto, &stale_veto),
8499 "the gesture's veto cell is replaced, like on a Down"
8500 );
8501 assert!(controller.is_animating());
8502
8503 assert!(paint_at(&mut w, 100.0));
8504 let mid = w.offset();
8505 assert!(
8506 (mid - (start + 100.0)).abs() < 1e-6,
8507 "the offset tracks the tween, offset: {mid}"
8508 );
8509 send(&mut w, &pointer(PointerPhase::Move, 20.0), 116.0);
8510 assert_eq!(w.offset(), mid, "a later Move does not move the content");
8511 assert!(!w.scrolling, "and never re-arms the drag");
8512
8513 send(&mut w, &pointer(PointerPhase::Up, 20.0), 120.0);
8514 assert!(w.fling.is_none(), "no stray fling");
8515 assert!(!w.settling, "no stray settle");
8516 assert_eq!(live_motions(&w), 1, "only the tween is live");
8517 assert!(controller.is_animating(), "the tween runs on");
8518
8519 // (A standalone list keeps asking for the rebuild that would
8520 // re-window it, so the pump's own state is what is asserted.)
8521 paint_at(&mut w, 200.0);
8522 assert_eq!(live_motions(&w), 0, "nothing is left animating");
8523 assert_eq!(w.offset(), start + 200.0);
8524 assert!(!controller.is_animating(), "completion resolves it");
8525 assert_eq!(live_motions(&w), 0);
8526 }
8527 }
8528
8529 #[test]
8530 fn a_release_tears_down_a_tween_before_starting_its_own_motion() {
8531 // Defence in depth behind the rule above: a tween live under a
8532 // still-armed drag is replaced by the release through the single
8533 // tear-down path, on the legacy and the simulation release paths.
8534 for physics in both_physics() {
8535 let controller = ScrollController::new();
8536 let mut w = standalone(&controller, Some(physics));
8537 drag_without_release(&mut w);
8538 controller.animate_to(
8539 3000.0,
8540 AnimateTo {
8541 duration_ms: 400.0,
8542 curve: Curve::Linear,
8543 },
8544 );
8545 paint_at(&mut w, 0.0);
8546 assert!(controller.is_animating());
8547 // Force the drag back on, as if the command had not ended it.
8548 w.scrolling = true;
8549 w.down_active = true;
8550
8551 send(&mut w, &pointer(PointerPhase::Up, 90.0), 40.0);
8552 assert!(
8553 live_motions(&w) <= 1,
8554 "fling, settle and simulation are never live together"
8555 );
8556 assert!(
8557 !controller.is_animating(),
8558 "the replaced tween no longer reports itself"
8559 );
8560 assert!(!w.controller_animating);
8561 // Whatever the release started runs to rest on its own, never
8562 // resurrecting the tween's flag.
8563 let mut ms = 40.0;
8564 while live_motions(&w) > 0 {
8565 paint_at(&mut w, ms);
8566 assert!(live_motions(&w) <= 1);
8567 assert!(!controller.is_animating());
8568 ms += 16.0;
8569 assert!(ms < 10_000.0, "the release motion never settled");
8570 }
8571 }
8572 }
8573
8574 #[test]
8575 fn a_command_during_the_pre_slop_hold_keeps_the_drag_startable() {
8576 // Mirrors the ScrollView rule: a hold inside the slop writes no
8577 // offset, so a command drained meanwhile leaves it armed and the
8578 // finger's later travel still takes the gesture over and scrolls.
8579 for physics in both_physics() {
8580 let controller = ScrollController::new();
8581 let mut w = standalone(&controller, Some(physics));
8582 send(&mut w, &pointer(PointerPhase::Down, 150.0), 0.0);
8583 send(
8584 &mut w,
8585 &pointer(PointerPhase::Move, 150.0 - TOUCH_SLOP / 2.0),
8586 16.0,
8587 );
8588 assert!(w.down_active && !w.scrolling, "armed, not yet a drag");
8589
8590 controller.jump_to(20.0);
8591 paint_at(&mut w, 32.0);
8592 assert!(w.offset() > 0.0, "the jump applied");
8593 assert!(w.down_active, "the hold stays armed");
8594 assert!(!w.scrolling);
8595
8596 send(&mut w, &pointer(PointerPhase::Move, 110.0), 48.0);
8597 assert!(w.scrolling, "the slop takeover still happens");
8598 let after_takeover = w.offset();
8599 send(&mut w, &pointer(PointerPhase::Move, 100.0), 64.0);
8600 assert!(
8601 w.offset() > after_takeover,
8602 "the finger scrolls the content"
8603 );
8604
8605 send(&mut w, &pointer(PointerPhase::Up, 100.0), 80.0);
8606 assert!(!w.down_active && !w.scrolling);
8607 }
8608 }
8609
8610 #[test]
8611 fn a_pending_item_target_waits_out_a_live_drag() {
8612 let controller = ScrollController::new();
8613 let mut w = standalone(&controller, None);
8614 assert!(w.laid_out);
8615 w.item_target = Some(ItemTarget {
8616 key: ChildKey::new(40u64),
8617 alignment: ItemAlignment::Start,
8618 frames_left: ITEM_SETTLE_MAX_FRAMES,
8619 });
8620 w.scrolling = true;
8621 w.down_active = true;
8622 let before = w.placement_offset();
8623 w.settle_item_target();
8624 assert_eq!(
8625 w.placement_offset(),
8626 before,
8627 "no correcting jump fights the finger"
8628 );
8629 assert_eq!(
8630 w.item_target.map(|t| t.frames_left),
8631 Some(ITEM_SETTLE_MAX_FRAMES),
8632 "and none of the bounded frames is spent waiting"
8633 );
8634 assert!(w.scrolling, "the drag itself is untouched");
8635
8636 // The drag ends: the very next settle step lands the row.
8637 w.scrolling = false;
8638 w.down_active = false;
8639 w.settle_item_target();
8640 assert_eq!(w.placement_offset(), 40.0 * ROW, "the target resumes");
8641 }
8642
8643 #[test]
8644 fn a_scroll_to_item_mid_drag_ends_the_drag_and_settles_after_the_release() {
8645 for animated in [false, true] {
8646 let controller = ScrollController::new();
8647 let mut logic = variable_logic(300, &controller);
8648 let mut root = converged(&mut logic);
8649 root.event(&mut (), &pointer(PointerPhase::Down, 150.0));
8650 root.event(&mut (), &pointer(PointerPhase::Move, 120.0));
8651 root.event(&mut (), &pointer(PointerPhase::Move, 90.0));
8652 assert!(list(&root).scrolling, "the drag is live");
8653
8654 let target = 250usize;
8655 controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Start, animated);
8656 frame(&mut root, &mut logic, 100.0);
8657 let w = list(&root);
8658 assert!(!w.scrolling && !w.down_active, "the command ended the drag");
8659 assert_eq!(controller.is_animating(), animated);
8660 let placed = w.placement_offset();
8661 assert!(placed > 0.0, "the content sits off the top");
8662
8663 // The finger keeps going and lets go: neither touches the content.
8664 root.event(&mut (), &pointer(PointerPhase::Move, 20.0));
8665 assert_eq!(
8666 list(&root).placement_offset(),
8667 placed,
8668 "a later Move does not move the content"
8669 );
8670 root.event(&mut (), &pointer(PointerPhase::Up, 20.0));
8671 let w = list(&root);
8672 assert!(
8673 w.fling.is_none() && !w.settling,
8674 "the release starts nothing"
8675 );
8676 assert_eq!(w.ballistic.is_some(), animated, "a tween runs on");
8677
8678 // The request settles after the drag, landing the row at the top.
8679 settle(&mut root, &mut logic, 116.0, 40);
8680 let w = list(&root);
8681 assert!(w.item_target.is_none(), "the request finished settling");
8682 assert!(!controller.is_animating());
8683 let (top, _) = row_rect(w, target).expect("the target row is materialized");
8684 assert_near(top, 0.0, "the row's top edge sits at the viewport top");
8685 }
8686 }
8687
8688 #[test]
8689 fn dropping_the_widget_mid_tween_resolves_is_animating() {
8690 let controller = ScrollController::new();
8691 let mut w = standalone(&controller, None);
8692 controller.animate_to(
8693 400.0,
8694 AnimateTo {
8695 duration_ms: 200.0,
8696 curve: Curve::Linear,
8697 },
8698 );
8699 paint_at(&mut w, 0.0);
8700 assert!(controller.is_animating(), "the tween is running");
8701 assert!(controller.is_attached());
8702
8703 drop(w);
8704 assert!(
8705 !controller.is_animating(),
8706 "the dropped surface's tween no longer reports itself"
8707 );
8708 assert!(!controller.is_attached());
8709 }
8710
8711 #[test]
8712 fn a_second_surface_attaching_mid_tween_reports_not_animating() {
8713 let controller = ScrollController::new();
8714 let mut first = standalone(&controller, None);
8715 controller.animate_to(
8716 400.0,
8717 AnimateTo {
8718 duration_ms: 200.0,
8719 curve: Curve::Linear,
8720 },
8721 );
8722 paint_at(&mut first, 0.0);
8723 assert!(controller.is_animating(), "the first surface's tween runs");
8724
8725 // A second surface takes the handle over mid-tween — it has never
8726 // started a tween of its own, so the handle it now holds reports
8727 // not animating at once, not the first surface's stale `true`.
8728 let second = standalone(&controller, None);
8729 assert!(
8730 !controller.is_animating(),
8731 "the new holder has no tween of its own"
8732 );
8733
8734 // The displaced first surface's tween keeps running on its own
8735 // widget state regardless, but it no longer has a current binding
8736 // to publish through.
8737 assert!(
8738 paint_at(&mut first, 100.0),
8739 "the orphaned tween still pumps"
8740 );
8741 assert!(
8742 !controller.is_animating(),
8743 "the orphaned tween cannot resurrect the handle's flag"
8744 );
8745 drop(second);
8746 drop(first);
8747 }
8748
8749 #[test]
8750 fn rebinding_to_a_different_handle_mid_tween_moves_the_live_flag() {
8751 let old_controller = ScrollController::new();
8752 let old_view = uniform_logic(100, &old_controller)(&mut ());
8753 let mut counter = 0u64;
8754 let mut w = View::<()>::build(&old_view, &mut BuildCtx::new(&mut counter));
8755 w.layout(&mut LayoutCtx::new(), &BoxConstraints::tight(VIEWPORT));
8756
8757 old_controller.animate_to(
8758 400.0,
8759 AnimateTo {
8760 duration_ms: 200.0,
8761 curve: Curve::Linear,
8762 },
8763 );
8764 paint_at(&mut w, 0.0);
8765 assert!(
8766 old_controller.is_animating(),
8767 "the tween runs on the old handle"
8768 );
8769
8770 // A rebuild rebinds the same widget to a different handle while the
8771 // tween is still live — the widget's own tween survives the rebind
8772 // (it belongs to the widget, not the handle), so the new handle
8773 // reports it live and the old one is released.
8774 let new_controller = ScrollController::new();
8775 let new_view = uniform_logic(100, &new_controller)(&mut ());
8776 let mut counter2 = 100u64;
8777 let flags = View::<()>::rebuild(
8778 &new_view,
8779 &old_view,
8780 &mut w,
8781 &mut BuildCtx::new(&mut counter2),
8782 );
8783 assert!(flags.needs_paint());
8784
8785 assert!(
8786 !old_controller.is_animating(),
8787 "the superseded handle no longer reports the tween"
8788 );
8789 assert!(!old_controller.is_attached());
8790 assert!(
8791 new_controller.is_animating(),
8792 "the freshly bound handle publishes the still-running tween"
8793 );
8794
8795 // Running the tween to completion resolves it on the new handle. A
8796 // list's paint can still ask for one more frame purely to re-window
8797 // around the landed offset (unrelated to the tween itself), so the
8798 // assertion that matters is on `is_animating`, not paint's own
8799 // continuation request.
8800 paint_at(&mut w, 200.0);
8801 assert_eq!(w.offset(), 400.0, "the tween reached its target");
8802 assert!(
8803 !new_controller.is_animating(),
8804 "completion resolves is_animating on the current handle"
8805 );
8806 }
8807}