Skip to main content

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}