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`](reconcile::MAX_PREFIX_STEP) items counts as a data reset
128//! rather than a scroll: its bulk resolves in closed form against the estimate,
129//! only the remainder walks. The extent bookkeeping and the walk are
130//! `slivers/reconcile.rs`'s, shared with [`sliver_list`](crate::sliver_list).
131//!
132//! The builder still never runs outside rebuild, so a measurement that leaves the
133//! window no longer covering the viewport costs one convergence frame (*Viewport
134//! staleness*, below), never a layout-time build.
135//!
136//! **Cache hygiene.** The measured cache is bounded by the keys a session has
137//! actually visited (one `f64` + index per visited row; an LRU cap is a named
138//! deferral). Three rules trim it, all window-bounded or shrink-only:
139//!
140//! * a pod no slot claimed is probed with anchoring's same two hypotheses; if
141//!   neither still names its key the row left the *data*, not just the window,
142//!   and its measurement is dropped — two `key_of` calls per departing row. This
143//!   runs on every reconciled frame that isn't a genuine full replace (third
144//!   rule), *including* one where the anchor-shift probe missed: that miss proves
145//!   only that no uniform shift explained the whole previous window at once, never
146//!   that *this* row's own two hypotheses fail, and skipping it there leaks
147//!   permanently, a removed row having no later shrink or revisit to reclaim it,
148//! * a frame whose `item_count` shrank drops every entry whose recorded index is
149//!   past the new end — the only entries the new keying provably cannot produce,
150//!   scanned only on a shrink frame,
151//! * a wholesale replace clears the cache outright, but is decided against the
152//!   reconciled window's own *exact* key matches (`ListView::reconcile_keyed`'s
153//!   per-slot lookup), never the anchor-shift probe: that probe tests only two
154//!   candidate index shifts and can miss a same-frame mutation touching both
155//!   sides of the anchor while the on-screen rows are unchanged. Only a frame
156//!   where none of the previous window's keys matched a slot is a genuine
157//!   replace.
158//!
159//! Two acknowledged gaps: a row removed while *outside* the materialized window
160//! keeps its entry (finding it would mean re-keying all `item_count` items, the
161//! O(N) this design exists to avoid); and a same-frame mutation on both sides of
162//! the anchor can still miss the anchor-shift probe itself, leaving the *scroll
163//! position* uncorrected for that one frame (a visible jump), any pending
164//! measured-anchor correction being discarded rather than committed into geometry
165//! it can no longer explain. Both cost accuracy in an already-estimated total,
166//! never a misattached measurement or a leaked entry.
167//!
168//! # Measured anchor correction (variable extents only)
169//!
170//! A row measuring taller or shorter than it was *assumed* to be moves every row
171//! below it, including the one the viewport's top edge sits inside — the
172//! **anchor**. So layout accumulates, over the rows lying wholly above that edge
173//! *in the geometry this frame's window was planned against*, the signed
174//! `measured − assumed` delta into one [`ListViewWidget::pending_correction`]:
175//! what the offset owes to leave the anchor row exactly where it is. Rows at or
176//! below the anchor contribute nothing — a row growing pushes content below it
177//! down, the truth, not a jump.
178//!
179//! **Recorded at layout, committed at the next rebuild.** A *scroll* correction
180//! is neither layout's nor paint's to make — layout's only offset write stays the
181//! range clamp, paint's the fling pump — so paint asks for one continuation frame
182//! while a correction is pending and the next [`View::rebuild`] commits it
183//! *before* planning the window, folding `ChangeFlags::LAYOUT` into its report.
184//! The pending amount is nevertheless **honored visually the instant it is
185//! measured**: every reader that *places* content — the prefix walk, the window,
186//! each row's origin, the edge triggers, the semantics scroll position — reads
187//! [`ListViewWidget::placement_offset`] (`offset + pending`, clamped) instead of
188//! the raw offset, so the anchor row never moves and committing is pure
189//! bookkeeping. The raw `offset` — the fling pump's, the drag's, the wheel's and
190//! the clamp arithmetic's — moves only from input or a rebuild-time correction.
191//!
192//! **Fling interplay: accumulate, then apply at settle.** While a fling is live
193//! the pump advances `offset` at paint, and committing per-frame would fight it
194//! two ways: a correction *opposing* the fling exceeds a decayed fling step near
195//! the end of the animation and walks the offset backwards, and one *along* it
196//! can push the offset onto a bound, where [`ListViewWidget::tick`]'s at-bound
197//! check kills the fling early. So a correction taken during a fling only
198//! accumulates; the first rebuild after the fling stops (velocity below
199//! [`FLING_STOP`](frust_core::FLING_STOP), a bound reached, or a `Down` taking
200//! the gesture over — all
201//! three clear `fling`) commits the whole sum, invisibly, since placement honored
202//! it every frame anyway. The reversal is measured, not assumed: deleting the
203//! withhold makes this module's upward-fling test walk the offset backwards
204//! mid-flight (`7980 → 7993`, ~13px against the gesture). **Accepted artifact:**
205//! until a long fling over never-measured rows settles, the *committed* offset
206//! lags the painted placement by exactly the accumulated sum, so a reader of
207//! [`ListViewWidget::offset`] alone sees a stale scroll position.
208//!
209//! **Clamping and edge triggers.** A correction goes through the same
210//! `[0, max_offset]` clamp as every other offset write, against a `max_offset`
211//! itself moving as measurements revise the content extent; one clamped at an
212//! edge is truncated, not kept owing, so the top/bottom of the list wins over
213//! anchor fidelity. `near_start`/`near_end` evaluate on events and on the fling
214//! pump, never in rebuild, so a commit can never itself fire one; and because
215//! they read the *placement*, which a commit leaves unchanged, a correction
216//! cannot rearm or re-fire an edge that has not genuinely moved.
217//!
218//! **What stays estimated.** Only materialized rows are ever measured, so a
219//! prepend landing entirely *above* the window is anchored by the estimate alone
220//! — the closed-form shift above — refining only if the user scrolls back over
221//! those rows; a prepend landing inside the window takes both steps in
222//! consecutive frames, the estimate shift then the measured refinement.
223//!
224//! # Viewport staleness
225//!
226//! [`frust_core::BuildCtx`] carries no viewport size, so the widget caches
227//! `viewport: Size` from the previous layout pass (the `ScrollWidget` precedent)
228//! and rebuild reads it off the element. One frame of staleness on a constraint
229//! change is accepted, and the very first `build` materializes a conservative
230//! window from a zero viewport; to converge, [`Widget::paint`] requests one more
231//! frame whenever the materialized window does not yet *cover* the now-known
232//! viewport, so a stationary list never idles under-materialized. Coverage, not
233//! equality: an over-wide window asks for no extra frame, the next rebuild trims.
234//!
235//! # Scroll machinery (reused, not reinvented)
236//!
237//! The drag capture, wheel, fling, settle and ballistic pump are not this
238//! widget's own: it runs on the same shared scroll surface core
239//! (`crate::scroll_core`) [`crate::ScrollView`] does, supplying only its
240//! position storage and windowed geometry through the core's `ScrollSurface`
241//! seam — so the two surfaces cannot drift apart. During a scroll drag it
242//! captures the pointer and, on takeover, cancels any armed child (a `ListItem`'s
243//! press) via the structural-change contract — the accepted, Flutter-like
244//! tradeoff. An offset change requests a redraw; the fling advances the offset at
245//! paint and requests a continuation frame, so the next rebuild→layout→paint
246//! re-windows as the fling carries the list.
247//!
248//! [`ListViewWidget::offset`] — the *windowing* offset deciding which item
249//! indices materialize — is clamped to `[0, scroll extent - viewport.height]`
250//! (*The extent model*, above, for that extent on each path) and never leaves
251//! that range; see *Overscroll and pull-to-refresh* for the bounded out-of-range
252//! *visual* displacement layered on top of it.
253//!
254//! # Overscroll and pull-to-refresh
255//!
256//! [`ListView::on_refresh_release`] and a drag's rubber-band overscroll feel are
257//! [`crate::ScrollView`]'s, sharing `scroll.rs`'s `pub(crate)`
258//! resistance/trigger/settle items rather than a hand-copied second set so the
259//! two can never drift — the spoke doc's *Refresh/overscroll parity with
260//! ScrollView*. The settle runs in the shared core too, over this widget's own
261//! split of the position (below), which `ScrollWidget` has no windowing concept
262//! to keep separate.
263//!
264//! **The feel itself comes from a [`ScrollPhysics`]** ([`crate::physics`]),
265//! installed as [`crate::physics::default_physics`]'s platform-adaptive choice
266//! — the same seam, the same default, the same per-move drag convention, and
267//! the same parity rule as `ScrollView` (see its *Physics seam*): the drag
268//! mapping and boundary rejection are asked of the physics, while the legacy
269//! fling/settle path and the hard-clamped wheel path stay here. What is local
270//! to this widget is only how the answer is *stored* — split across a clamped
271//! windowing offset and a paint-only displacement, below.
272//!
273//! **Windowing offset vs. painted offset.** [`ListViewWidget::offset`] *is* the
274//! item-index math — where a `ScrollWidget::offset` can carry an out-of-range
275//! value directly, nothing there reading it as an index — so it must stay in
276//! `[0, max_offset]` at all times, and a drag past an edge splits the two:
277//! `offset` stays clamped (window planning, the prefix walk and the edge triggers
278//! all read [`ListViewWidget::placement_offset`], built on it),
279//! [`ListViewWidget::overscroll`] carries the signed, resisted past-edge
280//! displacement alone, and only [`ListViewWidget::painted_offset`]
281//! (`placement_offset() + overscroll`) — read solely by
282//! [`ListViewWidget::sync_child_origins`] and [`Widget::semantics`]'s scroll
283//! position — ever sees the out-of-range number. The content edge visually
284//! displaces; no row is ever materialized outside `[0, item_count)`.
285//!
286//! **How the pull is visualized is a separate axis**, shared verbatim with
287//! `ScrollView` (see its *Overscroll visuals*): under the default
288//! [`OverscrollEffect::Translate`] the displacement is what
289//! [`ListViewWidget::painted_offset`] layers in, above; under
290//! [`OverscrollEffect::Stretch`] the rows stay where the windowing offset puts
291//! them and [`Widget::paint`] scales them about the held edge from
292//! the core's edge pull instead — paint-only either way.
293//!
294//! **Resistance uses the *current*, converging `max_offset`.** Every drag `Move`
295//! re-splits the drag position against a freshly-read
296//! [`ListViewWidget::max_offset`], not a value cached at takeover, so a
297//! bottom-edge overscroll in variable-extent mode tracks the content extent as
298//! in-flight measurements revise it — content that grows under the finger
299//! absorbs the displacement it had already produced instead of the surface
300//! jumping by it.
301//!
302//! **`on_refresh_release` only ever arms at the top edge**, mirroring
303//! `ScrollView`: it fires on `Up` when `overscroll < -REFRESH_TRIGGER_PX`, a
304//! condition only the *negative* (past-top) direction can satisfy — a bottom
305//! overscroll releases into a settle like an under-threshold top one, but can
306//! never fire it. [`ListView::on_near_start`]/[`ListView::on_near_end`] read
307//! `placement_offset()`, which overscroll never touches, so one gesture can cross
308//! the near-start threshold on its way down (firing "load older") and *then* the
309//! refresh trigger before release — two independent signals, not a conflict.
310//!
311//! **Fling and wheel stay hard-clamped**, exactly like `ScrollView`: on the
312//! shipped feel only a drag ever *sets* a nonzero `overscroll` — wheel forces it
313//! back to `0.0` outright, and a fling can never enter it, since
314//! [`ListViewWidget::tick`]'s at-bound
315//! check stops a fling the instant `offset` reaches `0`/`max_offset`. (The
316//! generic ballistic driver is the one other writer, and only for a physics
317//! whose simulation is *allowed* past an edge — the bouncing default's spring
318//! is exactly that, `RubberBand` builds none.) A new
319//! `Down` does *not* reset it, only cancelling any in-progress settle
320//! (the core's `settling = false`), so a regrab mid-bounce continues from
321//! wherever the surface sits rather than snapping first — the shared `Down` arm
322//! leaves `ScrollWidget`'s own out-of-range `offset` untouched for the same
323//! reason.
324//!
325//! # Nested scrolling: innermost wins
326//!
327//! This widget is both halves of `scroll.rs`'s innermost-wins arbitration (see
328//! its *Nested scrolling*), on the same shared seam rather than a second copy:
329//! it reports itself into its host's ambient claim cell
330//! ([`crate::scroll::ambient_scroll_claim`]) as a forwarded `Down` reaches it,
331//! and it pushes its own cell ([`crate::scroll::with_scroll_claim`]) around the
332//! `Down` it routes to its own rows — in that order, so a row's own nested
333//! scrollable pairs with *this* list and not with whatever encloses it. At the
334//! takeover site a registered inner that can consume the drag's direction makes
335//! this list defer instead of cancelling its rows. A list with no nested
336//! scrollable in the window behaves exactly as it always has.
337//!
338//! # Programmatic scrolling
339//!
340//! [`ListView::controller`] attaches the same [`ScrollController`] handle a
341//! [`crate::ScrollView`] takes, with the same contract (`scroll.rs`'s
342//! *Programmatic scrolling*, and [`crate::scroll_controller`] for the handle's
343//! side): `jump_to`/`animate_to` apply on the next pass, every pass that moves
344//! the position publishes a [`ScrollInfo`], a jump/animate replaces whatever
345//! fling, settle or tween is in flight, and a user `Down`, wheel or `Cancel`
346//! ends a tween through the one tear-down path (the shared core's
347//! `stop_ballistic`). A command applied while a finger is down ends that drag
348//! (the core's `end_live_drag`), and every post-release start tears the
349//! previous motion down through the same path first, so fling, settle and
350//! simulation are never live together. A controller tween's frames owe the
351//! next frame's flush for any near-start/near-end fire they record, as a
352//! jump's does. Three things are local to this widget:
353//!
354//! * **Drained at rebuild too.** Besides the start of layout and paint, the
355//!   queue is drained in [`View::rebuild`] once the anchor shift and measured
356//!   correction have been committed and *before* the window is planned, so the
357//!   frame a jump lands on already materializes the rows it lands among
358//!   instead of painting one frame of the old window. Rebuild has no theme, so
359//!   it uses the motion tokens the last layout/paint resolved.
360//! * **Content-space offsets.** The published offset — and the offset a jump
361//!   writes — is [`ListViewWidget::placement_offset`], where the content
362//!   actually sits; a jump or animate folds any uncommitted measured
363//!   correction in first, so the position an app reads back is the position
364//!   it asked for.
365//! * **`scroll_to_item`.** A keyed list resolves
366//!   [`ScrollController::scroll_to_item`]'s key by scanning `key_of` over the
367//!   items (O(item count), checked against the window's own key map first —
368//!   there is no full reverse map, by design), places the row from the
369//!   measured-or-estimated extents ahead of it (walking from the prefix
370//!   anchor, the same walk the window plans with, so the two agree exactly),
371//!   aligns it, and jumps or tweens there. **Settle and correct:** the
372//!   estimate is only a guess for rows never laid out, so after the jump lands
373//!   (or the tween finishes) each following layout — which has just measured
374//!   the rows now on screen — re-resolves the row's aligned position and
375//!   jumps to it when it is off by more than [`ITEM_SETTLE_TOLERANCE_PX`]. The
376//!   loop ends as soon as the row is materialized and within tolerance, and
377//!   is **bounded to [`ITEM_SETTLE_MAX_FRAMES`] layouts** whatever happens
378//!   (each correction costs one frame to re-window and re-measure; the
379//!   measured anchor correction already holds a row's top in place as rows
380//!   above it measure, so `Start` usually settles on the first layout and the
381//!   others within one more). Paint requests a layout frame while the loop is
382//!   live, and a user `Down`, wheel or `Cancel`, or any later controller
383//!   command, ends it. A settle step never runs while a drag is live: the
384//!   request waits it out, consuming none of its bounded frames, and resumes
385//!   once the drag ends.
386
387use std::collections::HashMap;
388use std::rc::Rc;
389
390use frust_core::accesskit::Role;
391use frust_core::{
392    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
393    LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, SemanticsCtx, View,
394    Widget,
395};
396use frust_theme::{MotionScheme, Theme};
397use kurbo::{Point, Size};
398
399use crate::ChildKey;
400use crate::authoring::ErasedCallback;
401use crate::physics::effect::OverscrollEffect;
402use crate::physics::{ScrollPhysics, default_overscroll_effect};
403use crate::scroll::ScrollInfo;
404use crate::scroll_controller::{AnimateTo, ItemAlignment, ScrollController, ScrollToItem};
405use crate::scroll_core::{ScrollSurface, ScrollSurfaceCore};
406use crate::slivers::reconcile::{self, ItemViews, LiveRows, Measured};
407// The in-file tests reach these through `use super::*`; production code here
408// no longer names them since the machinery moved into `crate::scroll_core`.
409#[cfg(test)]
410use crate::physics::MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR;
411#[cfg(test)]
412use crate::scroll::{METRICS_FALLBACK_DPR, crossed_refresh_trigger};
413#[cfg(test)]
414use frust_core::{FrameTime, ScrollDelta, TOUCH_SLOP};
415
416/// Extra items materialized above and below the visible window, so a small
417/// scroll (or a fling's per-frame advance) reveals already-built rows instead of
418/// a blank edge before the next rebuild re-windows.
419const BUFFER: isize = 2;
420
421/// The debug-only tripwire message for two materialized slots claiming one
422/// identity, shared by the keyed build and rebuild paths. Mirrors
423/// [`crate::authoring::rebuild_children`]'s duplicate-key `debug_assert!`; unlike
424/// that one there is no positional fallback to take, so release builds carry on
425/// with first-claim-wins (see [`ListView::builder_keyed`]).
426const DUPLICATE_KEY_MSG: &str = "ListView::builder_keyed produced a duplicate key inside one \
427     window: row identity is ambiguous (release: the first slot claiming a key keeps the live \
428     row, later duplicates build fresh)";
429
430/// The debug-only tripwire message for [`ListView::estimated_item_extent`] on a
431/// positional list. Variable extents cache a measurement under the row's stable
432/// identity, which a positional list does not have; release builds ignore the
433/// estimate and stay on the uniform path rather than panicking live (see the
434/// [module docs](self)' *Variable extents* section).
435const ESTIMATE_NEEDS_KEYS_MSG: &str = "ListView::estimated_item_extent requires \
436     ListView::builder_keyed: a measured extent is cached under the row's stable key, which a \
437     positional list has none of (release: the estimate is ignored and the uniform extent path \
438     runs)";
439
440/// How many layouts a [`ScrollController::scroll_to_item`] request may spend
441/// settling — re-resolving the row's aligned position against freshly
442/// measured extents and correcting to it — before it stops correcting and
443/// leaves the list where it stands. Counted from the layout the jump lands on
444/// (or the first one after the tween finishes); each correction costs one
445/// frame to re-window and re-measure, and three corrections cover a target
446/// whose own extent and the extents above it were all estimates. See the
447/// [module docs](self)' *Programmatic scrolling* section.
448const ITEM_SETTLE_MAX_FRAMES: u8 = 4;
449
450/// How far (logical px) a settling [`ScrollController::scroll_to_item`] row
451/// may sit from its aligned position and still count as landed — half a
452/// pixel, below anything a correction jump could visibly improve.
453const ITEM_SETTLE_TOLERANCE_PX: f64 = 0.5;
454
455/// A view-held near-start "load older" callback (erased to [`ErasedCallback`] on
456/// build).
457type OnNearStart<State> = Rc<dyn Fn(&mut State)>;
458
459/// A view-held near-end "load newer" callback (erased to [`ErasedCallback`] on
460/// build). Mirrors [`OnNearStart`].
461type OnNearEnd<State> = Rc<dyn Fn(&mut State)>;
462
463/// A view-held pull-to-refresh release callback (erased to [`ErasedCallback`]
464/// on build). Mirrors [`crate::ScrollView`]'s type of the same shape exactly —
465/// see the [module docs](self)' *Overscroll and pull-to-refresh* section.
466type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
467
468/// A view-held stable-key function (`item index -> row identity`), installed by
469/// [`ListView::builder_keyed`] and absent for the positional
470/// [`ListView::builder`]. Retained (an [`Rc`]) beside the builder, and — like the
471/// builder — a pure function of the index.
472type KeyOf = Rc<dyn Fn(usize) -> ChildKey>;
473
474/// The motion tokens a controller command is applied with, resolved from the
475/// theme at every layout and paint and cached on the widget so the
476/// rebuild-time drain (whose `BuildCtx` threads no theme) applies a command
477/// with the same tokens the surrounding passes would.
478#[derive(Clone, Copy, Debug)]
479struct ControllerMotion {
480    /// The theme's `motion.reduce_motion`: collapses every tween to a jump.
481    reduce_motion: bool,
482    /// The tween an `animated` [`ScrollController::scroll_to_item`] runs:
483    /// `motion.durations.slow` eased by `motion.easing.spatial` — a spatial
484    /// move across possibly many rows, so the slow spatial pair rather than a
485    /// hand-rolled duration.
486    item_animation: AnimateTo,
487}
488
489impl ControllerMotion {
490    /// The tokens `theme` carries, or [`MotionScheme::neutral`]'s when no
491    /// theme is threaded (the unthemed fallback, rather than a local constant).
492    fn from_theme(theme: Option<&Theme>) -> Self {
493        let motion = theme.map_or(MotionScheme::neutral(), |theme| theme.motion);
494        Self {
495            reduce_motion: motion.reduce_motion,
496            item_animation: AnimateTo {
497                duration_ms: motion.durations.slow,
498                curve: motion.easing.spatial,
499            },
500        }
501    }
502}
503
504/// A [`ScrollController::scroll_to_item`] request still settling: the row's
505/// key (re-resolved every settle frame, so a mutation mid-settle follows the
506/// row), the concrete alignment it resolved to (`Nearest` already decided),
507/// and the layouts it has left. See the [module docs](self)' *Programmatic
508/// scrolling* section.
509#[derive(Clone, Copy, Debug)]
510struct ItemTarget {
511    key: ChildKey,
512    alignment: ItemAlignment,
513    frames_left: u8,
514}
515
516/// The window one frame should materialize: the `[start, end)` slot range plus
517/// the content-space `y` of `start` (the prefix walk's result in variable-extent
518/// mode; `start * item_extent` on the uniform path).
519#[derive(Clone, Copy, Debug)]
520struct WindowPlan {
521    start: usize,
522    end: usize,
523    y_start: f64,
524}
525
526impl WindowPlan {
527    /// The empty window (an empty list, or a degenerate extent).
528    const EMPTY: Self = Self {
529        start: 0,
530        end: 0,
531        y_start: 0.0,
532    };
533}
534
535/// A declarative, virtualized vertical list. See the [module docs](self).
536///
537/// `builder` is a pure function of the item index; it is retained (an [`Rc`]) so
538/// the previous frame's view for a surviving index can be reconstructed during
539/// reconciliation. All rows share the uniform, required `item_extent`.
540pub struct ListView<State: 'static> {
541    item_count: usize,
542    item_extent: f64,
543    builder: Rc<dyn Fn(usize) -> AnyView<State>>,
544    /// The stable-key function when this is a [`ListView::builder_keyed`] list;
545    /// `None` selects the positional (index-identity) reconciliation path. See
546    /// the [module docs](self)' *Row identity* section.
547    key_of: Option<KeyOf>,
548    /// The assumed extent of an unmeasured row, installed by
549    /// [`ListView::estimated_item_extent`]; `None` (the default) keeps the
550    /// closed-form uniform path. Only honored on a keyed list — see the
551    /// [module docs](self)' *Variable extents* section.
552    estimated_item_extent: Option<f64>,
553    /// Fired (edge-triggered) when the scrolled window comes within
554    /// `near_start_threshold` of content start — the "load older" edge. See
555    /// [`ListView::on_near_start`].
556    on_near_start: Option<OnNearStart<State>>,
557    /// Distance from content start (px) at which `on_near_start` fires.
558    near_start_threshold: f64,
559    /// Fired (edge-triggered) when the scrolled window comes within
560    /// `near_end_threshold` of content end — the "load newer" edge. See
561    /// [`ListView::on_near_end`].
562    on_near_end: Option<OnNearEnd<State>>,
563    /// Distance from content end (px) at which `on_near_end` fires.
564    near_end_threshold: f64,
565    /// Fired on pointer `Up` when the past-top overscroll exceeded
566    /// `REFRESH_TRIGGER_PX` (shared with [`crate::ScrollView`]). See
567    /// [`ListView::on_refresh_release`].
568    on_refresh_release: Option<OnRefresh<State>>,
569    /// A custom [`ScrollPhysics`] installed via [`ListView::physics`], or
570    /// `None` to leave whatever is already installed on the widget alone —
571    /// mirrors [`crate::ScrollView`]'s field of the same name/contract; see
572    /// [`ListView::physics`] for the full build/rebuild semantics.
573    physics: Option<Rc<dyn ScrollPhysics>>,
574    /// How past-edge pull is visualized, carried down to the widget's
575    /// installed effect on every build/rebuild. See
576    /// [`ListView::overscroll_effect`] (mirrors [`crate::ScrollView`]'s field
577    /// of the same name).
578    pub(crate) effect: OverscrollEffect,
579    /// The programmatic-scroll handle to attach, if any. See
580    /// [`ListView::controller`].
581    controller: Option<ScrollController>,
582}
583
584impl<State: 'static> ListView<State> {
585    /// Create a virtualized list of `item_count` rows, each `item_extent`
586    /// logical pixels tall, whose row at `index` is produced by `builder`.
587    ///
588    /// The builder may return any [`View`]; it is erased here. Rows that differ
589    /// in concrete view type return [`frust_core::any`] per branch. Panics if
590    /// `item_extent` is not positive (the uniform extent is the virtualization
591    /// fast path; a zero/negative extent has no well-defined window).
592    ///
593    /// # Contract
594    ///
595    /// Rows are reconciled by **raw item index**, not by a stable key. This is
596    /// safe for append-only, truncate-only, and full-replace data, but a
597    /// mid-list insert/remove/reorder silently reattaches a retained row's state
598    /// to different content at the same index. See the [module docs]'
599    /// *Row identity* section for the full rule, and
600    /// [`ListView::builder_keyed`] for the stable-key alternative that survives
601    /// a mid-list mutation.
602    ///
603    /// [module docs]: self
604    ///
605    /// ```
606    /// use frust_widgets::{ListView, text};
607    /// let list: ListView<()> = ListView::builder(100, 48.0, |i| text(format!("row {i}")));
608    /// # let _ = list;
609    /// ```
610    pub fn builder<V: View<State>>(
611        item_count: usize,
612        item_extent: f64,
613        builder: impl Fn(usize) -> V + 'static,
614    ) -> Self {
615        assert!(
616            item_extent > 0.0,
617            "ListView item_extent must be positive (uniform extent)"
618        );
619        Self {
620            item_count,
621            item_extent,
622            builder: Rc::new(move |index| AnyView::new(builder(index))),
623            key_of: None,
624            estimated_item_extent: None,
625            on_near_start: None,
626            near_start_threshold: 0.0,
627            on_near_end: None,
628            near_end_threshold: 0.0,
629            on_refresh_release: None,
630            physics: None,
631            effect: default_overscroll_effect(),
632            controller: None,
633        }
634    }
635
636    /// Create a virtualized list whose rows are reconciled by the **stable key**
637    /// `key_of(index)` instead of by raw item index — the mid-list-mutation-safe
638    /// counterpart of [`ListView::builder`], everything else identical.
639    ///
640    /// `key_of` returns the identity of the row at an item index (a
641    /// [`ChildKey`], built from any [`Hash`](std::hash::Hash) value — an item id,
642    /// a name — via `ChildKey::new`/`.into()`); it is called once per
643    /// *materialized* slot per frame, never `item_count` times, and must be a
644    /// pure function of the index over one frame's data, exactly like `builder`.
645    /// Panics if `item_extent` is not positive, like [`ListView::builder`].
646    ///
647    /// ```ignore
648    /// ListView::builder_keyed(
649    ///     rows.len(),
650    ///     56.0,
651    ///     move |i| ChildKey::new(rows[i].id),
652    ///     move |i| row_view(&rows[i]),
653    /// )
654    /// ```
655    ///
656    /// # Contract
657    ///
658    /// A row whose key stays in the window keeps its live widget — and so its
659    /// entire retained state — even when an insert/remove/reorder moves it to a
660    /// different index; a key that leaves the window (or the data) is torn down,
661    /// and a key entering is built fresh. **Keys must be unique within a
662    /// window:** a duplicate trips a `debug_assert!` and, in release, hands the
663    /// live row to the first slot claiming the key while later duplicates build
664    /// fresh (no panic, but which row keeps its state is arbitrary). See the
665    /// [module docs]' *Row identity* section.
666    ///
667    /// [module docs]: self
668    pub fn builder_keyed<V: View<State>>(
669        item_count: usize,
670        item_extent: f64,
671        key_of: impl Fn(usize) -> ChildKey + 'static,
672        builder: impl Fn(usize) -> V + 'static,
673    ) -> Self {
674        assert!(
675            item_extent > 0.0,
676            "ListView item_extent must be positive (uniform extent)"
677        );
678        Self {
679            item_count,
680            item_extent,
681            builder: Rc::new(move |index| AnyView::new(builder(index))),
682            key_of: Some(Rc::new(key_of)),
683            estimated_item_extent: None,
684            on_near_start: None,
685            near_start_threshold: 0.0,
686            on_near_end: None,
687            near_end_threshold: 0.0,
688            on_refresh_release: None,
689            physics: None,
690            effect: default_overscroll_effect(),
691            controller: None,
692        }
693    }
694
695    /// Let rows size themselves, taking `estimate_px` as the assumed extent of
696    /// every row that has not been measured yet — **variable-extent mode**,
697    /// available on [`ListView::builder_keyed`] lists only.
698    ///
699    /// A materialized row is laid out under the list's width with unbounded
700    /// height and reports whatever height it wants; that height is cached under
701    /// the row's stable key and used from then on for window math, the scroll
702    /// extent, and the row's content position. Unmeasured rows (everything not
703    /// yet laid out) count as `estimate_px`, so the scroll range converges on
704    /// the true content height as the user visits rows. The constructor's
705    /// `item_extent` is unused in this mode — pass the same value as the
706    /// estimate for clarity.
707    ///
708    /// ```ignore
709    /// ListView::builder_keyed(rows.len(), 72.0, key_of, builder)
710    ///     .estimated_item_extent(72.0)
711    /// ```
712    ///
713    /// # Contract
714    ///
715    /// **Keyed lists only.** A measured extent is cached under row identity, so
716    /// it must move with the row; a positional list has no identity to cache
717    /// under. Calling this on a [`ListView::builder`] list trips a
718    /// `debug_assert!` and is **inert** in release — the list keeps its
719    /// closed-form uniform extent rather than panicking live (the same shape as
720    /// the duplicate-key tripwire, see the [module docs]' *Variable extents*
721    /// section for why this is a `debug_assert` and not type-state). Panics if
722    /// `estimate_px` is not positive, like the constructors' `item_extent`.
723    ///
724    /// [module docs]: self
725    pub fn estimated_item_extent(mut self, estimate_px: f64) -> Self {
726        assert!(
727            estimate_px > 0.0,
728            "ListView estimated_item_extent must be positive"
729        );
730        debug_assert!(self.key_of.is_some(), "{}", ESTIMATE_NEEDS_KEYS_MSG);
731        self.estimated_item_extent = Some(estimate_px);
732        self
733    }
734
735    /// The estimate this view actually runs in variable-extent mode with:
736    /// `Some` only when a keyed list also named an estimate (the keyed-only
737    /// contract's release behavior — see [`ListView::estimated_item_extent`]).
738    fn variable_estimate(&self) -> Option<f64> {
739        match (self.key_of.as_ref(), self.estimated_item_extent) {
740            (Some(_), Some(estimate)) => Some(estimate),
741            _ => None,
742        }
743    }
744
745    /// Fire `callback` when the scrolled window comes within `threshold_px` of
746    /// content **start** — the load-older edge for a newest-at-bottom chat list.
747    ///
748    /// The callback is **edge-triggered**: it fires once per approach and rearms
749    /// only after the user scrolls away past `2 × threshold_px` (or the item
750    /// count changes). Drag, wheel, and fling motion all observe it — fling
751    /// motion is driven at paint time (no [`frust_core::EventCtx`]), so a
752    /// fling-triggered fire is recorded and delivered on the next event, one
753    /// event late; a `Cancel` clears a pending fire without invoking the
754    /// callback.
755    pub fn on_near_start<F: Fn(&mut State) + 'static>(
756        mut self,
757        callback: F,
758        threshold_px: f64,
759    ) -> Self {
760        self.on_near_start = Some(Rc::new(callback));
761        self.near_start_threshold = threshold_px;
762        self
763    }
764
765    /// Fire `callback` when the scrolled window comes within `threshold_px` of
766    /// content **end** — the load-newer edge for an infinite-scroll-downward
767    /// list.
768    ///
769    /// Mirrors [`ListView::on_near_start`] exactly, measured from content end
770    /// instead of start: **edge-triggered**, firing once per approach and
771    /// rearming only after the user scrolls back past `2 × threshold_px` away
772    /// from the end (or the item count changes). Drag, wheel, and fling motion
773    /// all observe it — a fling-triggered fire is recorded and delivered on the
774    /// next event, one event late; a `Cancel` clears a pending fire without
775    /// invoking the callback.
776    pub fn on_near_end<F: Fn(&mut State) + 'static>(
777        mut self,
778        callback: F,
779        threshold_px: f64,
780    ) -> Self {
781        self.on_near_end = Some(Rc::new(callback));
782        self.near_end_threshold = threshold_px;
783        self
784    }
785
786    /// The pull-to-refresh trigger: fires on pointer `Up` when the list was
787    /// pulled past the top by more than `REFRESH_TRIGGER_PX` (post-resistance)
788    /// — the same name, signature, and threshold behavior as
789    /// [`crate::ScrollView::on_refresh_release`], so a screen can swap between
790    /// the two containers without relearning the contract. Never fires on a
791    /// `Cancel`, and a bottom overscroll can never trigger it (only a past-top
792    /// pull can). See the [module docs](self)' *Overscroll and pull-to-refresh*
793    /// section.
794    pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
795        self.on_refresh_release = Some(Rc::new(callback));
796        self
797    }
798
799    /// Install a custom [`ScrollPhysics`] strategy — the same seam
800    /// [`crate::ScrollView::physics`] installs, sharing `scroll.rs`'s
801    /// resistance/trigger/settle constants and this crate's
802    /// [`crate::physics::parity`]/[`RubberBand`](crate::RubberBand)
803    /// implementations.
804    ///
805    /// ```
806    /// use frust_widgets::{ListView, NeverScrollable, text};
807    /// let view: ListView<()> = ListView::builder(3, 40.0, |i| {
808    ///     frust_core::any::<(), _>(text(i.to_string()))
809    /// })
810    /// .physics(NeverScrollable::new());
811    /// # let _ = view;
812    /// ```
813    ///
814    /// # Build/rebuild semantics
815    ///
816    /// Identical to [`crate::ScrollView::physics`]: a view built (or
817    /// rebuilt) *with* `.physics(...)` reinstalls it on the widget every
818    /// time; a view built (or rebuilt) *without* it leaves the widget's
819    /// currently-installed physics untouched (a fresh `build` still starts at
820    /// [`crate::physics::default_physics`], the widget's own constructor
821    /// default).
822    ///
823    /// Defaults to the platform-adaptive physics
824    /// ([`crate::physics::default_physics`] — Android clamping, elsewhere
825    /// bouncing) if never called; `.physics(RubberBand::new())` is how an app
826    /// asks for the pre-seam rubber-band feel instead.
827    pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
828        self.physics = Some(Rc::new(physics));
829        self
830    }
831
832    /// Select how past-edge pull is visualized. See [`OverscrollEffect`]
833    /// ([`crate::physics::effect`]) for the full contract — the same
834    /// selector [`crate::ScrollView::overscroll_effect`] installs.
835    ///
836    /// ```
837    /// use frust_widgets::{ListView, OverscrollEffect, text};
838    /// let view: ListView<()> = ListView::builder(3, 40.0, |i| {
839    ///     frust_core::any::<(), _>(text(i.to_string()))
840    /// })
841    /// .overscroll_effect(OverscrollEffect::Stretch);
842    /// # let _ = view;
843    /// ```
844    ///
845    /// Plain view-owned data, unlike [`ListView::physics`]: every
846    /// build/rebuild carries the current value down to the widget
847    /// unconditionally.
848    ///
849    /// Defaults to the effect paired with the platform's default physics
850    /// ([`crate::physics::default_overscroll_effect`]): the M3E
851    /// [`OverscrollEffect::Stretch`] on Android, translate overscroll
852    /// ([`OverscrollEffect::Translate`]) everywhere else.
853    pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
854        self.effect = effect;
855        self
856    }
857
858    /// Attach a [`ScrollController`] — the same handle
859    /// [`crate::ScrollView::controller`] takes, driving this list with
860    /// [`ScrollController::jump_to`]/[`ScrollController::animate_to`] and, on a
861    /// [`ListView::builder_keyed`] list,
862    /// [`ScrollController::scroll_to_item`]; read back through
863    /// [`ScrollController::offset`]/[`ScrollController::max_offset`]/
864    /// [`ScrollController::on_change`] from outside any dispatch.
865    ///
866    /// ```
867    /// use frust_widgets::{ChildKey, ListView, ScrollController, text};
868    /// use frust_widgets::scroll_controller::ItemAlignment;
869    /// let controller = ScrollController::new();
870    /// let view: ListView<()> = ListView::builder_keyed(
871    ///     100,
872    ///     40.0,
873    ///     |i| ChildKey::new(i),
874    ///     |i| frust_core::any::<(), _>(text(i.to_string())),
875    /// )
876    /// .controller(controller.clone());
877    /// controller.scroll_to_item(ChildKey::new(42usize), ItemAlignment::Start, false);
878    /// # let _ = view;
879    /// ```
880    ///
881    /// # Build/rebuild semantics
882    ///
883    /// Identical to [`crate::ScrollView::controller`]: a build binds the
884    /// handle (taking it over from any other surface); a rebuild with the same
885    /// handle keeps the binding and never takes it back from a surface that
886    /// has since taken it over; a rebuild with a different handle binds that
887    /// one; a rebuild with none detaches; dropping the widget detaches the
888    /// handle it still holds. See the [module docs](self)' *Programmatic
889    /// scrolling* section for what is local to a list.
890    pub fn controller(mut self, controller: ScrollController) -> Self {
891        self.controller = Some(controller);
892        self
893    }
894}
895
896/// The reconciliation half of [`View::rebuild`], split per identity mode. See the
897/// [module docs](self)' *Row identity* section for which one runs when.
898impl<State: 'static> ListView<State> {
899    /// Reconcile the materialized window to `[start, end)` **by item index** —
900    /// the [`ListView::builder`] path, unchanged since the keyed one landed
901    /// beside it: an index that stays in the window keeps its live pod and is
902    /// rebuilt in place against its own previous view, indices leaving are torn
903    /// down, indices entering are built fresh.
904    ///
905    /// Because the window is a contiguous range, any change to it necessarily
906    /// builds or tears down at least one pod, so `structural` covers the
907    /// "window shifted" case as well as the materialization one.
908    fn reconcile_positional(
909        &self,
910        prev: &Self,
911        element: &mut ListViewWidget,
912        ctx: &mut BuildCtx<'_>,
913        plan: WindowPlan,
914    ) -> ChangeFlags {
915        let flags = reconcile::reconcile_positional(
916            ItemViews {
917                prev: &*prev.builder,
918                next: &*self.builder,
919            },
920            LiveRows {
921                children: &mut element.children,
922                indices: &mut element.keys,
923            },
924            plan.start..plan.end,
925            ctx,
926        );
927        element.sync_child_origins();
928        flags
929    }
930
931    /// Reconcile the materialized window to `[start, end)` **by stable key** —
932    /// the [`ListView::builder_keyed`] path.
933    ///
934    /// Slots stay the contiguous index run `[start, end)` (the uniform-extent
935    /// fast path is untouched); identity is matched through the retained
936    /// [`ListViewWidget::key_index`] map instead. For each slot: the key
937    /// `key_of(index)` names the item index that row occupied last frame, whose
938    /// live pod is relocated into this slot and rebuilt against
939    /// `prev.builder(prev_index)` → `self.builder(index)`. Keys with no entry
940    /// build fresh; pods no slot claimed are torn down with the same
941    /// cancel-if-active [`crate::authoring::teardown_child`] path the positional
942    /// reconciler uses — nothing more. The matching itself is the shared
943    /// [`reconcile::reconcile_keyed`].
944    ///
945    /// A duplicate key is ambiguous and trips a `debug_assert!` mirroring
946    /// [`crate::authoring::rebuild_children`]'s; release builds carry on (first
947    /// claim keeps the live row, later duplicates build fresh) rather than panic.
948    ///
949    /// In variable-extent mode this pass additionally retains the window's slot
950    /// keys (so `layout` can cache each measurement under the right identity
951    /// without re-keying), re-pins the prefix anchor from `plan`, and applies
952    /// the departing-row half of the measured cache's hygiene rules — `delta` is
953    /// this frame's net `item_count` change, the second hypothesis of the same
954    /// probe [`ListView::anchor_shift_items`] uses. `anchor_probe_missed` is
955    /// that same probe's own outcome (`true` when it matched no previous-window
956    /// key at all); this pass is where the wholesale-replace half of the
957    /// hygiene rules is actually decided, against its own exact per-slot key
958    /// matches below rather than the probe's hypotheses — see the [module
959    /// docs](self)' *Variable extents* and *Cache hygiene* sections.
960    #[allow(clippy::too_many_arguments)]
961    fn reconcile_keyed(
962        &self,
963        prev: &Self,
964        element: &mut ListViewWidget,
965        ctx: &mut BuildCtx<'_>,
966        plan: WindowPlan,
967        key_of: &KeyOf,
968        delta: isize,
969        anchor_probe_missed: bool,
970    ) -> ChangeFlags {
971        // Whether any slot in this frame's reconciled window matched a key the
972        // previous window also held (`any_survivor`) is an *exact* hashmap
973        // lookup, unlike the rebuild-time anchor-shift probe's two index
974        // hypotheses. Read below to decide the wholesale-replace half of the
975        // measured-cache hygiene rules.
976        let outcome = reconcile::reconcile_keyed(
977            ItemViews {
978                prev: &*prev.builder,
979                next: &*self.builder,
980            },
981            LiveRows {
982                children: &mut element.children,
983                indices: &mut element.keys,
984            },
985            &mut element.key_index,
986            &**key_of,
987            plan.start..plan.end,
988            DUPLICATE_KEY_MSG,
989            ctx,
990        );
991
992        // Measured-cache hygiene (variable extents only).
993        if element.is_variable() {
994            if anchor_probe_missed && !outcome.any_survivor {
995                // The rebuild-time anchor-shift probe matched no previous-window
996                // key by hypothesis, *and* literally no previous-window key
997                // matched a slot in this frame's reconciled window by exact
998                // lookup either — a genuine full replace (or a jump far enough
999                // that nothing on screen is recognizable), which is reset
1000                // semantics for the measured cache too. Deliberately not
1001                // triggered by `anchor_probe_missed` alone: that probe only
1002                // tests two candidate index shifts and can miss on a same-frame
1003                // mutation touching both sides of the anchor (a prepend above
1004                // the viewport plus an append below it in one frame) while
1005                // `any_survivor` above still proves most on-screen rows are
1006                // exactly the ones they were — see the [module docs](self)'
1007                // *Cache hygiene* section.
1008                element.clear_measured();
1009            } else {
1010                // A pod no slot claimed left the window *or* the data, and only
1011                // the second case may drop its measurement. Probe each orphan's
1012                // key at its unchanged index and at that index shifted by this
1013                // frame's net item-count delta — the same two hypotheses
1014                // anchoring uses, at most two `key_of` calls per departing row,
1015                // never a scan of `item_count`. Same acknowledged imprecision as
1016                // the anchor probe (see the module docs' *Cache hygiene*
1017                // section's gaps) — narrower blast radius than the wholesale
1018                // clear above, since a false miss here only evicts one
1019                // already-departed row's entry.
1020                //
1021                // Runs on every frame that isn't a genuine full replace,
1022                // including one where the anchor-shift probe above missed
1023                // (`anchor_probe_missed && any_survivor`, the branch that lands
1024                // here rather than in the wholesale clear above): a probe miss
1025                // does not mean this loop's own two hypotheses are unreliable
1026                // for a *specific* orphaned row — it only means no *single*
1027                // uniform hypothesis explained every row in the previous
1028                // window at once (the same-frame both-sides-of-the-anchor
1029                // case). Gating this loop on the probe's outcome, as an
1030                // earlier version of this fix did, traded a bounded
1031                // over-eviction (re-measure one row that was merely outside
1032                // the still-mis-anchored window) for an unbounded leak: a row
1033                // truly removed from the data on such a frame has no later
1034                // shrink or revisit to reclaim it — `record_measurement` never
1035                // refreshes a dead key, and `evict_measured_stale_indices`
1036                // only scans on a frame whose `item_count` shrank, which a
1037                // frame that *adds* a sentinel/anchor row alongside a removal
1038                // (any_survivor's own precondition) need never be. A false
1039                // eviction here costs one row's re-measurement — the module's
1040                // own documented conservative direction — never a permanent
1041                // leak.
1042                let count = element.item_count;
1043                for (key, prev_index) in &outcome.departed {
1044                    if !Self::key_survives(*key, *prev_index, delta, count, key_of) {
1045                        element.forget_measured(key);
1046                    }
1047                }
1048            }
1049        }
1050
1051        if let Some(estimate) = element.variable_estimate() {
1052            // Retain this window's identities beside its slots, then re-pin the
1053            // prefix anchor and the per-slot content positions to the plan the
1054            // window was computed from (layout refines them from measurements).
1055            element.slot_keys = outcome.slot_keys;
1056            element.set_window_geometry(plan, estimate);
1057        }
1058        element.sync_child_origins();
1059        outcome.flags
1060    }
1061
1062    /// Determine the keyed prepend/removal scroll-anchor correction for this
1063    /// rebuild, in **items** (the caller multiplies by `item_extent`), or
1064    /// `None` if no correction should apply. See the [module docs](self)'
1065    /// anchoring section for the algorithm and the `offset == 0` decision.
1066    ///
1067    /// `element.item_count` must already hold this frame's (new) count —
1068    /// callers apply the item-count bookkeeping before calling this — while
1069    /// `old_item_count` is the count as of the *previous* frame.
1070    fn anchor_shift_items(
1071        prev: &Self,
1072        element: &ListViewWidget,
1073        old_item_count: usize,
1074        key_of: &KeyOf,
1075    ) -> Option<isize> {
1076        // A prior frame built with the positional path (or no key_of at all)
1077        // leaves no previous-frame key function to reconstruct an anchor
1078        // key from — nothing to anchor against.
1079        let prev_key_of = prev.key_of.as_ref()?;
1080        let new_item_count = element.item_count;
1081        let delta = new_item_count as isize - old_item_count as isize;
1082        for &i_prev in &element.keys {
1083            let anchor_key = prev_key_of(i_prev);
1084            // Unchanged position: the below-viewport/no-op case (the common
1085            // case — checked first, resolves in one extra `key_of` call).
1086            if i_prev < new_item_count && key_of(i_prev) == anchor_key {
1087                return Some(0);
1088            }
1089            // Shifted by the frame's net item-count delta: the
1090            // prepend/removal-above case, confirmed rather than assumed.
1091            if delta != 0 {
1092                let candidate = i_prev as isize + delta;
1093                if candidate >= 0
1094                    && (candidate as usize) < new_item_count
1095                    && key_of(candidate as usize) == anchor_key
1096                {
1097                    return Some(delta);
1098                }
1099            }
1100        }
1101        // No key in the previous window survived either hypothesis: a full
1102        // replace (or an edit this closed-form check can't characterize) —
1103        // reset semantics, not an anchoring case.
1104        None
1105    }
1106
1107    /// Whether `key` still identifies a row in this frame's data, probed at the
1108    /// index it last occupied and at that index shifted by the frame's net
1109    /// item-count `delta` — the same two hypotheses
1110    /// [`ListView::anchor_shift_items`] tests, at most two `key_of` calls. Used
1111    /// by the measured cache's departing-row eviction; a `false` here means the
1112    /// row left the data, not merely the window.
1113    fn key_survives(
1114        key: ChildKey,
1115        prev_index: usize,
1116        delta: isize,
1117        item_count: usize,
1118        key_of: &KeyOf,
1119    ) -> bool {
1120        reconcile::probe_key(key, prev_index, delta, item_count, &**key_of).is_some()
1121    }
1122}
1123
1124/// Create a virtualized [`ListView`] — the free-function spelling of
1125/// [`ListView::builder`]. `builder` may return any [`View`]; it is erased
1126/// here.
1127///
1128/// ```
1129/// use frust_widgets::{ListView, list_view, text};
1130/// let list: ListView<()> = list_view(100, 48.0, |i| text(format!("row {i}")));
1131/// # let _ = list;
1132/// ```
1133pub fn list_view<State: 'static, V: View<State>>(
1134    item_count: usize,
1135    item_extent: f64,
1136    builder: impl Fn(usize) -> V + 'static,
1137) -> ListView<State> {
1138    ListView::builder(item_count, item_extent, builder)
1139}
1140
1141/// The retained widget for a [`ListView`]: the materialized window of children
1142/// (`children[j]` renders item `keys[j]`), the scroll offset + cached viewport,
1143/// and the same fling bookkeeping as [`crate::ScrollWidget`].
1144pub struct ListViewWidget {
1145    /// The currently materialized rows, parallel to [`ListViewWidget::keys`].
1146    children: Vec<ChildPod>,
1147    /// The item index each entry of [`ListViewWidget::children`] renders. Always
1148    /// a contiguous ascending run (uniform extent → the window is a range).
1149    keys: Vec<usize>,
1150    /// Row identity for a [`ListView::builder_keyed`] list: `stable key -> the
1151    /// item index that row occupied in the materialized window`, as of the last
1152    /// build/rebuild. Empty for a positional [`ListView::builder`] list (and
1153    /// cleared by any positional rebuild, so a list switched between the two
1154    /// constructors never matches against a window the other path materialized).
1155    ///
1156    /// This is the retained half of keyed reconciliation: window slots stay
1157    /// contiguous indices for the uniform-extent fast path, and identity is
1158    /// carried here instead — read back by `rebuild` off the element exactly like
1159    /// [`ListViewWidget::offset`]/[`ListViewWidget::viewport`], since the view is
1160    /// reconstructed from scratch every frame.
1161    key_index: HashMap<ChildKey, usize>,
1162    /// The keyed list's own `index -> identity` function, retained (a cheap
1163    /// [`Rc`] clone, reinstalled every rebuild like the erased callbacks) so the
1164    /// widget's own passes can key an index without the view in scope: the
1165    /// variable-extent prefix walk looks a row's measurement up by key, and
1166    /// `paint`'s convergence check re-derives the same window. `None` for a
1167    /// positional list. This is the *key* function only — the builder is never
1168    /// retained here, and never runs outside rebuild (see the [module
1169    /// docs](self)' *Variable extents* section).
1170    key_of: Option<KeyOf>,
1171    /// The assumed extent of an unmeasured row in variable-extent mode, or
1172    /// `None` for the closed-form uniform path. Set from
1173    /// [`ListView::estimated_item_extent`], already resolved against the
1174    /// keyed-only contract.
1175    estimated_extent: Option<f64>,
1176    /// Variable-extent mode: every row this widget has ever laid out, by stable
1177    /// key. Bounded by the keys a session has visited — see the [module
1178    /// docs](self)' cache-hygiene rules (and the LRU deferral noted there).
1179    measured: HashMap<ChildKey, Measured>,
1180    /// The running Σ of [`ListViewWidget::measured`]'s extents, so the content
1181    /// extent is O(1) rather than a scan of the cache.
1182    measured_sum: f64,
1183    /// Variable-extent mode: the identity of each materialized slot, parallel to
1184    /// [`ListViewWidget::keys`], so `layout` can cache a measurement under the
1185    /// right key without calling `key_of` again. Empty on the uniform path.
1186    slot_keys: Vec<ChildKey>,
1187    /// Variable-extent mode: each materialized slot's content-space `y`,
1188    /// parallel to [`ListViewWidget::keys`] — what rows are placed at (minus the
1189    /// offset) instead of the uniform path's `index * item_extent`. Empty on the
1190    /// uniform path.
1191    slot_y: Vec<f64>,
1192    /// Variable-extent mode: the prefix anchor's item index — the item
1193    /// [`ListViewWidget::anchor_y`] gives the content-space top of, and the
1194    /// point every per-frame prefix walk starts from (the previous window's
1195    /// start). See the [module docs](self)' *Variable extents* section.
1196    anchor_index: usize,
1197    /// The content-space `y` of [`ListViewWidget::anchor_index`]'s top.
1198    anchor_y: f64,
1199    /// Variable-extent mode: the scroll correction `layout` has measured but no
1200    /// rebuild has committed into [`ListViewWidget::offset`] yet — Σ
1201    /// `measured − assumed` over the rows that lay wholly above the viewport top
1202    /// in the geometry the frame's window was planned against. Always `0.0` on
1203    /// the uniform path (which measures nothing). Read back through
1204    /// [`ListViewWidget::placement_offset`] by everything that places content, so
1205    /// it is honored the frame it is recorded; committed by
1206    /// [`ListViewWidget::apply_pending_correction`]. See the [module docs](self)'
1207    /// *Measured anchor correction* section.
1208    pending_correction: f64,
1209    item_count: usize,
1210    item_extent: f64,
1211    /// The **windowing** scroll offset — always in `[0, max_offset]`, the
1212    /// value every item-index computation (window planning, the prefix walk,
1213    /// edge triggers) reads through [`ListViewWidget::placement_offset`]. Never
1214    /// carries an out-of-range value; see [`ListViewWidget::overscroll`] and
1215    /// the [module docs](self)' *Overscroll and pull-to-refresh* section for
1216    /// where a drag past an edge is represented instead.
1217    offset: f64,
1218    /// The signed, resisted past-edge visual displacement a drag shows beyond
1219    /// the windowing [`ListViewWidget::offset`]: negative past the top,
1220    /// positive past the bottom, `0.0` while in range. Only a drag ever sets
1221    /// this nonzero; wheel and fling always force it back to `0.0` (both stay
1222    /// hard-clamped, matching `ScrollView`). See
1223    /// [`ListViewWidget::painted_offset`] and the [module docs](self)'
1224    /// *Overscroll and pull-to-refresh* section.
1225    overscroll: f64,
1226    /// Resolved viewport size (this widget's own size), cached from the previous
1227    /// layout so rebuild can window against it (BuildCtx carries no viewport).
1228    viewport: Size,
1229    /// The shared scroll machinery and its state (`crate::scroll_core`): the
1230    /// physics, drag and edge pull, takeover/claim/veto, fling/settle/ballistic
1231    /// motion and the controller binding. The position it reasons about is
1232    /// this widget's own [`ListViewWidget::offset`] plus
1233    /// [`ListViewWidget::overscroll`].
1234    core: ScrollSurfaceCore,
1235    /// The near-start "load older" callback (`None` if the view set none).
1236    on_near_start: Option<ErasedCallback>,
1237    /// Distance from content start (px) at which `on_near_start` fires.
1238    near_start_threshold: f64,
1239    /// Whether `on_near_start` is armed to fire on the next near-start approach.
1240    /// Cleared when it fires; rearmed after scrolling away past `2 × threshold`
1241    /// or an item-count change.
1242    near_start_armed: bool,
1243    /// A near-start fire detected by the paint-time fling pump (no `EventCtx`);
1244    /// delivered on the next event, cleared by a `Cancel` without firing.
1245    pending_near_start: bool,
1246    /// The near-end "load newer" callback (`None` if the view set none).
1247    on_near_end: Option<ErasedCallback>,
1248    /// Distance from content end (px) at which `on_near_end` fires.
1249    near_end_threshold: f64,
1250    /// Whether `on_near_end` is armed to fire on the next near-end approach.
1251    /// Cleared when it fires; rearmed after scrolling away past `2 ×
1252    /// threshold` or an item-count change.
1253    near_end_armed: bool,
1254    /// A near-end fire detected by the paint-time fling pump (no `EventCtx`);
1255    /// delivered on the next event, cleared by a `Cancel` without firing.
1256    pending_near_end: bool,
1257    /// The motion tokens the last layout/paint resolved, for the rebuild-time
1258    /// drain (see [`ControllerMotion`]).
1259    motion: ControllerMotion,
1260    /// A [`ScrollController::scroll_to_item`] request still settling, or
1261    /// `None`. See the [module docs](self)' *Programmatic scrolling* section.
1262    item_target: Option<ItemTarget>,
1263    /// Whether a controller jump — or a frame of a controller tween — recorded
1264    /// a near-start/near-end fire since the last paint: the paint that sees it
1265    /// requests the `Housekeeping` flush that delivers it (the fire has no
1266    /// `EventCtx` to go through when the position moves at layout or paint).
1267    /// The near-edge counterpart of the core's own owed `on_scroll` flush.
1268    jump_notify_owed: bool,
1269}
1270
1271/// The in-file tests predate the core and name its state as this widget's own
1272/// fields (`w.scrolling`, `w.edge_pull`, …); a test-build `Deref` keeps every
1273/// one of them reading the same state it always did. Production code names
1274/// `self.core` explicitly.
1275#[cfg(test)]
1276impl std::ops::Deref for ListViewWidget {
1277    type Target = ScrollSurfaceCore;
1278
1279    fn deref(&self) -> &ScrollSurfaceCore {
1280        &self.core
1281    }
1282}
1283
1284#[cfg(test)]
1285impl std::ops::DerefMut for ListViewWidget {
1286    fn deref_mut(&mut self) -> &mut ScrollSurfaceCore {
1287        &mut self.core
1288    }
1289}
1290
1291impl ListViewWidget {
1292    fn new(item_count: usize, item_extent: f64) -> Self {
1293        Self {
1294            children: Vec::new(),
1295            keys: Vec::new(),
1296            key_index: HashMap::new(),
1297            key_of: None,
1298            estimated_extent: None,
1299            measured: HashMap::new(),
1300            measured_sum: 0.0,
1301            slot_keys: Vec::new(),
1302            slot_y: Vec::new(),
1303            anchor_index: 0,
1304            anchor_y: 0.0,
1305            pending_correction: 0.0,
1306            item_count,
1307            item_extent,
1308            offset: 0.0,
1309            overscroll: 0.0,
1310            viewport: Size::ZERO,
1311            core: ScrollSurfaceCore::default(),
1312            on_near_start: None,
1313            near_start_threshold: 0.0,
1314            near_start_armed: true,
1315            pending_near_start: false,
1316            on_near_end: None,
1317            near_end_threshold: 0.0,
1318            near_end_armed: true,
1319            pending_near_end: false,
1320            motion: ControllerMotion::from_theme(None),
1321            item_target: None,
1322            jump_notify_owed: false,
1323        }
1324    }
1325
1326    /// Edge-detect the near-start "load older" condition, mutating the armed
1327    /// state: rearm once scrolled away past `2 × threshold`, and return `true`
1328    /// exactly once when armed and the offset comes within `threshold` of content
1329    /// start. Callers fire the callback on a `true` return.
1330    ///
1331    /// Measured against [`ListViewWidget::placement_offset`] — where the content
1332    /// actually sits — rather than the raw offset, so an uncommitted
1333    /// variable-extent correction can neither rearm nor fire this edge (the
1334    /// placement is exactly what a commit leaves unchanged). Identical to the
1335    /// offset on the uniform path, which never has a pending correction.
1336    fn evaluate_near_start(&mut self) -> bool {
1337        if self.on_near_start.is_none() || self.item_count == 0 {
1338            return false;
1339        }
1340        let threshold = self.near_start_threshold;
1341        let position = self.placement_offset();
1342        if position > 2.0 * threshold {
1343            self.near_start_armed = true;
1344        }
1345        if self.near_start_armed && position <= threshold {
1346            self.near_start_armed = false;
1347            return true;
1348        }
1349        false
1350    }
1351
1352    /// Fire `on_near_start` during the event pass if the near-start edge just
1353    /// triggered.
1354    fn fire_near_start(&mut self, ctx: &mut EventCtx) {
1355        if self.evaluate_near_start()
1356            && let Some(cb) = self.on_near_start.as_mut()
1357        {
1358            cb(ctx);
1359        }
1360    }
1361
1362    /// Deliver a near-start fire recorded by the paint-time fling pump on the
1363    /// next event (one event of latency — the paint pass has no `EventCtx`).
1364    fn deliver_pending_near_start(&mut self, ctx: &mut EventCtx) {
1365        if self.pending_near_start {
1366            self.pending_near_start = false;
1367            if let Some(cb) = self.on_near_start.as_mut() {
1368                cb(ctx);
1369            }
1370        }
1371    }
1372
1373    /// Edge-detect the near-end "load newer" condition, mirroring
1374    /// [`Self::evaluate_near_start`] but measured from content **end**: the
1375    /// remaining scrollable distance below the viewport (`max_offset − offset`)
1376    /// rather than the offset itself. Reads the placement for the same reason
1377    /// [`Self::evaluate_near_start`] does.
1378    fn evaluate_near_end(&mut self) -> bool {
1379        if self.on_near_end.is_none() || self.item_count == 0 {
1380            return false;
1381        }
1382        let threshold = self.near_end_threshold;
1383        let distance = self.max_offset() - self.placement_offset();
1384        if distance > 2.0 * threshold {
1385            self.near_end_armed = true;
1386        }
1387        if self.near_end_armed && distance <= threshold {
1388            self.near_end_armed = false;
1389            return true;
1390        }
1391        false
1392    }
1393
1394    /// Fire `on_near_end` during the event pass if the near-end edge just
1395    /// triggered.
1396    fn fire_near_end(&mut self, ctx: &mut EventCtx) {
1397        if self.evaluate_near_end()
1398            && let Some(cb) = self.on_near_end.as_mut()
1399        {
1400            cb(ctx);
1401        }
1402    }
1403
1404    /// Deliver a near-end fire recorded by the paint-time fling pump on the
1405    /// next event (mirrors [`Self::deliver_pending_near_start`]).
1406    fn deliver_pending_near_end(&mut self, ctx: &mut EventCtx) {
1407        if self.pending_near_end {
1408            self.pending_near_end = false;
1409            if let Some(cb) = self.on_near_end.as_mut() {
1410                cb(ctx);
1411            }
1412        }
1413    }
1414
1415    /// The current scroll offset.
1416    pub fn offset(&self) -> f64 {
1417        self.offset
1418    }
1419
1420    /// The item indices currently materialized (the visible window ± buffer).
1421    pub fn window(&self) -> &[usize] {
1422        &self.keys
1423    }
1424
1425    /// The maximum scroll offset (`extent − viewport`, never negative).
1426    pub fn max_offset(&self) -> f64 {
1427        (self.content_extent() - self.viewport.height).max(0.0)
1428    }
1429
1430    /// The content-space `y` the viewport's top edge is at this frame **for
1431    /// windowing purposes**: the committed [`ListViewWidget::offset`] plus the
1432    /// correction `layout` has measured and no rebuild has committed yet,
1433    /// through the same `[0, max_offset]` clamp every offset write takes —
1434    /// always in range, never carrying [`ListViewWidget::overscroll`].
1435    ///
1436    /// Everything that decides item indices reads this — the prefix walk, the
1437    /// window, each row's content-space `y`, the near-start/near-end edge
1438    /// triggers — so a measured correction is honored the instant it is
1439    /// recorded and committing it is visually a no-op, and a row is never
1440    /// asked to materialize for an out-of-range index. Everything that *moves*
1441    /// the scroll (drag, wheel, fling, clamp) works on the raw offset instead.
1442    /// Identical to the offset on the uniform path, which never measures and so
1443    /// never has a pending correction. See the [module docs](self)' *Measured
1444    /// anchor correction* and *Overscroll and pull-to-refresh* sections; for
1445    /// where content is actually **painted** (which does layer overscroll on
1446    /// top), see [`ListViewWidget::painted_offset`].
1447    fn placement_offset(&self) -> f64 {
1448        if self.pending_correction == 0.0 {
1449            return self.offset;
1450        }
1451        (self.offset + self.pending_correction).clamp(0.0, self.max_offset())
1452    }
1453
1454    /// The content-space `y` the viewport's top edge is actually **painted**
1455    /// at this frame: [`ListViewWidget::placement_offset`] (always in range)
1456    /// plus the current [`ListViewWidget::overscroll`] displacement (`0.0`
1457    /// outside a drag past an edge). Read by
1458    /// [`ListViewWidget::sync_child_origins`] and [`Widget::semantics`]'s
1459    /// scroll position — the only two places the out-of-range number is ever
1460    /// allowed to show, so the content edge visually displaces while no row
1461    /// is ever asked to materialize outside `[0, item_count)`. See the
1462    /// [module docs](self)' *Overscroll and pull-to-refresh* section.
1463    ///
1464    /// The displacement is layered in only under
1465    /// [`OverscrollEffect::Translate`]: [`OverscrollEffect::Stretch`] paints
1466    /// the pull as a scale about the held edge instead (see
1467    /// [`Widget::paint`]) and [`OverscrollEffect::None`] paints it not at all,
1468    /// so under both the content — and the position semantics reports for it —
1469    /// stays exactly where the in-range windowing offset puts it. `overscroll`
1470    /// itself still evolves identically under every effect; only this read
1471    /// differs (`scroll.rs`'s *Overscroll visuals*).
1472    fn painted_offset(&self) -> f64 {
1473        match self.core.effect {
1474            OverscrollEffect::Translate => self.placement_offset() + self.overscroll,
1475            OverscrollEffect::Stretch | OverscrollEffect::None => self.placement_offset(),
1476        }
1477    }
1478
1479    /// Commit the pending measured correction into [`ListViewWidget::offset`] —
1480    /// the one place it is ever committed, from [`View::rebuild`] before the
1481    /// window is planned. Returns whether the offset actually moved (a
1482    /// correction the clamp swallowed whole reports `false`: nothing changed, so
1483    /// nothing is owed a layout pass).
1484    ///
1485    /// **Withheld while a fling is live**: the pump is advancing the offset at
1486    /// paint, and a correction landing on top of that can walk it backwards or
1487    /// onto a bound that ends the fling early, so the sum keeps accumulating and
1488    /// the first rebuild after the fling stops commits all of it (see the
1489    /// [module docs](self)' *Measured anchor correction* section).
1490    fn apply_pending_correction(&mut self) -> bool {
1491        if self.pending_correction == 0.0 || self.is_flinging() {
1492            return false;
1493        }
1494        let target = self.offset + self.pending_correction;
1495        self.pending_correction = 0.0;
1496        let before = self.offset;
1497        self.set_offset(target);
1498        self.offset != before
1499    }
1500
1501    /// The whole content's extent: `item_count * item_extent` exactly on the
1502    /// uniform path, or `Σ measured + estimate × unmeasured` in variable-extent
1503    /// mode (O(1) — the sum is maintained as rows are measured). See the [module
1504    /// docs](self)' *Variable extents* section.
1505    fn content_extent(&self) -> f64 {
1506        match self.variable_estimate() {
1507            Some(estimate) => reconcile::estimated_total(
1508                self.measured_sum,
1509                self.measured.len(),
1510                self.item_count,
1511                estimate,
1512            ),
1513            None => self.item_count as f64 * self.item_extent,
1514        }
1515    }
1516
1517    /// Whether post-release motion is in flight — the legacy fling, or a
1518    /// physics-supplied [`Simulation`](crate::physics::Simulation) the generic
1519    /// driver is running (never
1520    /// both, and never either one under [`RubberBand`](crate::RubberBand)'s
1521    /// legacy-only path).
1522    pub fn is_flinging(&self) -> bool {
1523        self.core.is_flinging()
1524    }
1525
1526    fn set_offset(&mut self, value: f64) {
1527        self.offset = value.clamp(0.0, self.max_offset());
1528    }
1529
1530    fn clamp_offset(&mut self) {
1531        self.set_offset(self.offset);
1532    }
1533
1534    /// Advance a release-settle by `dt_ms`, easing
1535    /// [`ListViewWidget::overscroll`] back to `0.0` and returning whether it
1536    /// is still animating. Pure and deterministic (mirrors
1537    /// [`crate::ScrollWidget::settle_tick`]); the paint pump and the tests
1538    /// both drive it. Only ever eases `overscroll` — the windowing
1539    /// [`ListViewWidget::offset`] already sits at the exact edge (`0.0` or
1540    /// `max_offset`) the moment a drag overscrolls, so it needs no motion of
1541    /// its own here.
1542    pub fn settle_tick(&mut self, dt_ms: f64) -> bool {
1543        self.advance_settle(dt_ms)
1544    }
1545
1546    /// The `[start, end)` item range that should be materialized for the current
1547    /// offset + cached viewport, clamped to `[0, item_count]`, plus the
1548    /// content-space `y` of `start`. With a zero viewport (first build) this is
1549    /// the conservative initial window.
1550    ///
1551    /// Variable-extent mode takes the prefix-walk path instead; the uniform
1552    /// closed form below is unchanged.
1553    fn desired_window(&self) -> WindowPlan {
1554        if let Some(estimate) = self.variable_estimate() {
1555            return if self.item_count == 0 {
1556                WindowPlan::EMPTY
1557            } else {
1558                self.desired_window_variable(estimate)
1559            };
1560        }
1561        if self.item_count == 0 || self.item_extent <= 0.0 {
1562            return WindowPlan::EMPTY;
1563        }
1564        let vh = self.viewport.height;
1565        let first = (self.offset / self.item_extent).floor() as isize - BUFFER;
1566        let last = ((self.offset + vh) / self.item_extent).ceil() as isize + BUFFER;
1567        let start = first.max(0) as usize;
1568        let end = (last.max(0) as usize).min(self.item_count);
1569        let start = start.min(end);
1570        WindowPlan {
1571            start,
1572            end,
1573            y_start: start as f64 * self.item_extent,
1574        }
1575    }
1576
1577    /// Whether the materialized window fully covers `[start, end)`.
1578    fn window_covers(&self, start: usize, end: usize) -> bool {
1579        if start >= end {
1580            return true;
1581        }
1582        match (self.keys.first(), self.keys.last()) {
1583            (Some(&f), Some(&l)) => f <= start && l + 1 >= end,
1584            _ => false,
1585        }
1586    }
1587
1588    /// Place each materialized row at its content position minus the
1589    /// **painted** offset (row `i` occupies `y ∈ [i*extent, (i+1)*extent)` in
1590    /// content space) — [`ListViewWidget::painted_offset`], not the windowing
1591    /// one, so an overscrolled drag's out-of-range displacement shows on
1592    /// screen even though [`ListViewWidget::offset`] itself never leaves
1593    /// `[0, max_offset]`.
1594    ///
1595    /// Variable-extent mode places each row at its own retained content `y`
1596    /// instead — the same rule, over the prefix walk's positions rather than the
1597    /// closed form.
1598    fn sync_child_origins(&mut self) {
1599        let painted = self.painted_offset();
1600        if self.is_variable() {
1601            for (y, pod) in self.slot_y.iter().zip(self.children.iter_mut()) {
1602                pod.set_origin(Point::new(0.0, *y - painted));
1603            }
1604            return;
1605        }
1606        let extent = self.item_extent;
1607        for (index, pod) in self.keys.iter().zip(self.children.iter_mut()) {
1608            pod.set_origin(Point::new(0.0, *index as f64 * extent - painted));
1609        }
1610    }
1611
1612    // --- Variable extents (keyed lists only). See the module docs' *Variable
1613    //     extents* section; every method here is inert on the uniform path. ---
1614
1615    /// The estimate this widget is running variable-extent mode with, or `None`
1616    /// for the closed-form uniform path. Both halves are required: identity to
1617    /// cache a measurement under, and an estimate for the rows without one.
1618    fn variable_estimate(&self) -> Option<f64> {
1619        match (self.key_of.as_ref(), self.estimated_extent) {
1620            (Some(_), Some(estimate)) => Some(estimate),
1621            _ => None,
1622        }
1623    }
1624
1625    /// Whether variable-extent mode is active (see
1626    /// [`ListViewWidget::variable_estimate`]).
1627    fn is_variable(&self) -> bool {
1628        self.variable_estimate().is_some()
1629    }
1630
1631    /// The extent one *unmeasured* row contributes: the estimate in
1632    /// variable-extent mode, the uniform `item_extent` otherwise. The scroll
1633    /// anchoring correction steps by this (a prepended row is by definition
1634    /// unmeasured).
1635    fn unmeasured_extent(&self) -> f64 {
1636        self.variable_estimate().unwrap_or(self.item_extent)
1637    }
1638
1639    /// The extent item `index` contributes: its cached measurement if it has
1640    /// one, else the estimate. One `key_of` call, no builder call.
1641    fn extent_at(&self, index: usize, estimate: f64) -> f64 {
1642        let Some(key_of) = self.key_of.as_ref() else {
1643            return estimate;
1644        };
1645        if index >= self.item_count {
1646            return estimate;
1647        }
1648        self.measured
1649            .get(&key_of(index))
1650            .map(|m| m.extent)
1651            .unwrap_or(estimate)
1652    }
1653
1654    /// Walk the retained prefix anchor to the current offset, returning the
1655    /// first item the offset falls inside and that item's content-space top.
1656    ///
1657    /// O(step): a scroll/fling frame moves a few items; a jump farther than
1658    /// [`MAX_PREFIX_STEP`](reconcile::MAX_PREFIX_STEP) items resolves its bulk
1659    /// in closed form against the estimate first. Reaching item 0 re-pins
1660    /// `y = 0` exactly, erasing any floating-point drift the walk accumulated.
1661    fn walk_to_offset(&self, estimate: f64) -> (usize, f64) {
1662        let count = self.item_count;
1663        debug_assert!(count > 0, "walk_to_offset needs a non-empty list");
1664        let (index, y) = reconcile::walk_to_offset(
1665            count,
1666            self.anchor_index,
1667            self.anchor_y,
1668            self.placement_offset(),
1669            estimate,
1670            |index| self.extent_at(index, estimate),
1671        );
1672        if index == 0 { (0, 0.0) } else { (index, y) }
1673    }
1674
1675    /// The variable-extent counterpart of [`ListViewWidget::desired_window`]:
1676    /// the item the offset falls inside, widened by [`BUFFER`] above and by
1677    /// whatever it takes to cover the viewport (plus [`BUFFER`]) below.
1678    /// O(window + step).
1679    fn desired_window_variable(&self, estimate: f64) -> WindowPlan {
1680        let count = self.item_count;
1681        let (first_visible, y_visible) = self.walk_to_offset(estimate);
1682
1683        // Leading buffer: back up from the first visible item, accumulating the
1684        // same extents in reverse.
1685        let mut start = first_visible;
1686        let mut y_start = y_visible;
1687        for _ in 0..BUFFER {
1688            if start == 0 {
1689                break;
1690            }
1691            start -= 1;
1692            y_start -= self.extent_at(start, estimate);
1693        }
1694        if start == 0 {
1695            y_start = 0.0;
1696        }
1697
1698        // Forward to the viewport's bottom edge, then the trailing buffer.
1699        let bottom = self.placement_offset() + self.viewport.height;
1700        let mut end = first_visible + 1;
1701        let mut y_end = y_visible + self.extent_at(first_visible, estimate);
1702        while end < count && y_end < bottom {
1703            y_end += self.extent_at(end, estimate);
1704            end += 1;
1705        }
1706        let end = end.saturating_add(BUFFER as usize).min(count);
1707        WindowPlan {
1708            start,
1709            end: end.max(start),
1710            y_start,
1711        }
1712    }
1713
1714    /// Re-pin the prefix anchor to the window `plan` this frame materialized and
1715    /// refill the per-slot content positions from the cached extents. Layout
1716    /// refines those positions from the rows' actual measurements.
1717    fn set_window_geometry(&mut self, plan: WindowPlan, estimate: f64) {
1718        self.anchor_index = plan.start;
1719        self.anchor_y = if plan.start == 0 { 0.0 } else { plan.y_start };
1720        let keys = std::mem::take(&mut self.keys);
1721        let mut ys = std::mem::take(&mut self.slot_y);
1722        ys.clear();
1723        let mut y = self.anchor_y;
1724        for &index in &keys {
1725            ys.push(y);
1726            y += self.extent_at(index, estimate);
1727        }
1728        self.slot_y = ys;
1729        self.keys = keys;
1730    }
1731
1732    /// Cache one row's laid-out extent under its stable key, keeping the running
1733    /// sum exact.
1734    fn record_measurement(&mut self, key: ChildKey, index: usize, extent: f64) {
1735        reconcile::record_measurement(
1736            &mut self.measured,
1737            &mut self.measured_sum,
1738            key,
1739            index,
1740            extent,
1741        );
1742    }
1743
1744    /// Drop one row's measurement (its key left the data).
1745    fn forget_measured(&mut self, key: &ChildKey) {
1746        reconcile::forget_measurement(&mut self.measured, &mut self.measured_sum, key);
1747    }
1748
1749    /// Drop every measurement — a full replace, where no previous key survives.
1750    /// Any uncommitted correction goes with them: it describes rows this list no
1751    /// longer holds, so committing it into new data would be a guess.
1752    fn clear_measured(&mut self) {
1753        self.measured.clear();
1754        self.measured_sum = 0.0;
1755        self.pending_correction = 0.0;
1756    }
1757
1758    /// Drop measurements the current keying provably cannot produce any more:
1759    /// entries recorded at an index past the (just shrunk) end. A scan of the
1760    /// cache, run only on a frame whose `item_count` shrank. A stale recorded
1761    /// index can evict a still-live row early; it is re-measured on its next
1762    /// visit, which is the conservative direction.
1763    fn evict_measured_stale_indices(&mut self) {
1764        reconcile::evict_measurements_past(
1765            &mut self.measured,
1766            &mut self.measured_sum,
1767            self.item_count,
1768        );
1769    }
1770
1771    /// Drop every variable-extent artifact: the measured cache, the per-slot
1772    /// identities and positions, and the prefix anchor. Run when a list leaves
1773    /// variable-extent mode, so nothing stale can be read back if it re-enters.
1774    fn reset_variable_state(&mut self) {
1775        self.clear_measured();
1776        self.slot_keys.clear();
1777        self.slot_y.clear();
1778        self.anchor_index = 0;
1779        self.anchor_y = 0.0;
1780    }
1781
1782    /// Drop every keyed-identity artifact: the `key -> index` map plus all
1783    /// variable-extent bookkeeping (which is cached under those identities). Run
1784    /// when a positional frame rebuilds a list a keyed frame materialized (see
1785    /// [`View::rebuild`]).
1786    fn reset_keyed_state(&mut self) {
1787        self.key_index.clear();
1788        self.reset_variable_state();
1789    }
1790
1791    /// Apply a confirmed scroll-anchor shift of `items` rows: the offset absorbs
1792    /// it, and (in variable-extent mode) so does the prefix anchor, which names
1793    /// an item index that just moved by the same amount.
1794    fn apply_anchor_shift(&mut self, items: isize) {
1795        let step = self.unmeasured_extent();
1796        self.set_offset(self.offset + items as f64 * step);
1797        if self.is_variable() {
1798            let index = (self.anchor_index as isize + items).max(0) as usize;
1799            self.anchor_y = if index == 0 {
1800                0.0
1801            } else {
1802                (self.anchor_y + items as f64 * step).max(0.0)
1803            };
1804            self.anchor_index = index;
1805        }
1806    }
1807
1808    /// Lay the materialized rows out under a bounded width and **unbounded**
1809    /// height (the [`crate::ScrollView`] precedent), caching each row's chosen
1810    /// height under its key and re-deriving the window's content positions from
1811    /// those heights. Measures only pods that already exist — no builder call,
1812    /// no materialization (the wake hazard the module docs call out).
1813    ///
1814    /// It also records — never applies — the frame's anchor correction: two
1815    /// running positions are carried, one over the heights the rows actually
1816    /// chose and one over the extents this frame's window was *planned*
1817    /// against, and every row whose planned span lies wholly above the viewport
1818    /// top contributes its `measured − assumed` delta to
1819    /// [`ListViewWidget::pending_correction`]. Those are exactly the rows whose
1820    /// mis-estimate moves the anchor row, and the offset owes their sum back.
1821    /// The offset itself is never written here beyond the pre-existing range
1822    /// clamp (the viewport is only known at layout) — the correction is
1823    /// committed by the next [`View::rebuild`]. See the [module docs](self)'
1824    /// *Measured anchor correction* section.
1825    fn layout_variable(&mut self, ctx: &mut LayoutCtx, vw: f64, estimate: f64) {
1826        let child_bc = BoxConstraints::new(Size::new(vw, 0.0), Size::new(vw, f64::INFINITY));
1827        // The viewport's top edge in content space, in the geometry this frame's
1828        // window was planned against — the line that decides which rows sit
1829        // above the anchor.
1830        let top = self.placement_offset();
1831        let mut children = std::mem::take(&mut self.children);
1832        let mut ys = std::mem::take(&mut self.slot_y);
1833        ys.clear();
1834        let mut y = self.anchor_y;
1835        let mut y_assumed = self.anchor_y;
1836        let mut correction = 0.0;
1837        for (slot, pod) in children.iter_mut().enumerate() {
1838            let key = self.slot_keys.get(slot).copied();
1839            let index = self.keys.get(slot).copied();
1840            // Read the assumed extent *before* this row's own measurement is
1841            // recorded below, or the delta would always be zero.
1842            let assumed = index.map_or(estimate, |index| self.extent_at(index, estimate));
1843            let size = pod.layout_child(ctx, &child_bc);
1844            ys.push(y);
1845            if y_assumed + assumed <= top {
1846                correction += size.height - assumed;
1847            }
1848            y += size.height;
1849            y_assumed += assumed;
1850            if let (Some(key), Some(index)) = (key, index) {
1851                self.record_measurement(key, index, size.height);
1852            }
1853        }
1854        self.slot_y = ys;
1855        self.children = children;
1856        // Accumulate, never assign: a correction withheld across a live fling is
1857        // still owed. Re-measuring an unchanged row contributes a zero delta, so
1858        // this can never double-count one.
1859        self.pending_correction += correction;
1860        // The measurements just revised the content extent (the estimate still
1861        // governs everything outside the window), so re-clamp before the caller
1862        // syncs origins.
1863        self.clamp_offset();
1864    }
1865
1866    /// Advance an in-flight fling by `dt_ms`, returning whether it is still
1867    /// animating. Pure and deterministic — the paint-time pump and the tests
1868    /// both drive it (mirrors [`crate::ScrollWidget::tick`]).
1869    pub fn tick(&mut self, dt_ms: f64) -> bool {
1870        self.advance_fling(dt_ms)
1871    }
1872
1873    // --- Programmatic scrolling (the attached `ScrollController`). See the
1874    //     module docs' *Programmatic scrolling* section. ---
1875
1876    /// Apply a [`ScrollController::scroll_to_item`] request: resolve the key,
1877    /// decide `Nearest`, jump or tween to the aligned offset, and leave an
1878    /// [`ItemTarget`] for the settle loop ([`ListViewWidget::settle_item_target`]).
1879    /// An unknown key (or any key on a positional list) moves nothing and logs
1880    /// in a debug build; `Nearest` on a fully visible row moves nothing either.
1881    /// Neither is a pure no-op, though: like every item request it supersedes
1882    /// what an earlier controller command left in flight
1883    /// ([`ListViewWidget::stand_still_for_item_request`]).
1884    fn apply_scroll_to_item(&mut self, request: ScrollToItem) {
1885        let Some(index) = self.resolve_key(request.key) else {
1886            unknown_item_key(&request, self.key_of.is_some());
1887            self.stand_still_for_item_request();
1888            return;
1889        };
1890        let alignment = match request.alignment {
1891            ItemAlignment::Nearest => match self.nearest_alignment(index) {
1892                Some(alignment) => alignment,
1893                None => {
1894                    self.stand_still_for_item_request();
1895                    return;
1896                }
1897            },
1898            alignment => alignment,
1899        };
1900        let target = self.aligned_offset(index, alignment);
1901        if request.animated {
1902            let motion = self.motion;
1903            self.apply_animate_to(target, motion.item_animation, motion.reduce_motion);
1904        } else {
1905            self.apply_jump(target);
1906        }
1907        self.item_target = Some(ItemTarget {
1908            key: request.key,
1909            alignment,
1910            frames_left: ITEM_SETTLE_MAX_FRAMES,
1911        });
1912    }
1913
1914    /// An item request that resolves to no movement (an unknown key, or
1915    /// `Nearest` on a row already fully visible) still supersedes the
1916    /// controller's own work in flight, exactly as a moving one replaces it:
1917    /// an earlier request stops settling and a running controller tween — an
1918    /// `animate_to` or an animated `scroll_to_item` — stops where it
1919    /// stands. The list stays put, and user-driven motion (a fling, a
1920    /// release-settle, a live drag) is left alone: a request naming no
1921    /// position has no position to replace it with.
1922    fn stand_still_for_item_request(&mut self) {
1923        self.item_target = None;
1924        self.supersede_controller_tween();
1925    }
1926
1927    /// The item index currently carrying `key`, or `None` (an unknown key, or
1928    /// a positional list, which has no keys). The window's own `key -> index`
1929    /// map is tried first — verified against the current keying, since a
1930    /// rebuild-time drain runs before this frame's reconciliation refreshes
1931    /// it — and only a miss scans `key_of` over every item: O(item count),
1932    /// once per request or settle frame, never per frame otherwise.
1933    fn resolve_key(&self, key: ChildKey) -> Option<usize> {
1934        let key_of = self.key_of.as_ref()?;
1935        if let Some(&index) = self.key_index.get(&key)
1936            && index < self.item_count
1937            && key_of(index) == key
1938        {
1939            return Some(index);
1940        }
1941        (0..self.item_count).find(|&index| key_of(index) == key)
1942    }
1943
1944    /// Item `index`'s content-space top and extent: closed form on the
1945    /// uniform path; in variable-extent mode, walked from the prefix anchor
1946    /// over measured-or-estimated extents — the same walk, from the same
1947    /// anchor, that plans the window, so a jump to this top lands the window
1948    /// exactly on the row. O(distance from the anchor).
1949    fn item_span(&self, index: usize) -> (f64, f64) {
1950        let Some(estimate) = self.variable_estimate() else {
1951            return (index as f64 * self.item_extent, self.item_extent);
1952        };
1953        let anchor = self.anchor_index.min(self.item_count.saturating_sub(1));
1954        let mut y = if anchor == 0 { 0.0 } else { self.anchor_y };
1955        if index >= anchor {
1956            for i in anchor..index {
1957                y += self.extent_at(i, estimate);
1958            }
1959        } else {
1960            for i in index..anchor {
1961                y -= self.extent_at(i, estimate);
1962            }
1963        }
1964        let top = if index == 0 { 0.0 } else { y.max(0.0) };
1965        (top, self.extent_at(index, estimate))
1966    }
1967
1968    /// The offset that puts item `index` at `alignment` (never `Nearest`, which
1969    /// [`ListViewWidget::nearest_alignment`] resolves first), clamped to
1970    /// `[0, max_offset]`.
1971    fn aligned_offset(&self, index: usize, alignment: ItemAlignment) -> f64 {
1972        let (top, extent) = self.item_span(index);
1973        let viewport = self.viewport.height;
1974        let raw = match alignment {
1975            ItemAlignment::Start | ItemAlignment::Nearest => top,
1976            ItemAlignment::Center => top + (extent - viewport) / 2.0,
1977            ItemAlignment::End => top + extent - viewport,
1978        };
1979        raw.clamp(0.0, self.max_offset())
1980    }
1981
1982    /// What [`ItemAlignment::Nearest`] means for item `index` right now:
1983    /// `None` when the row is already fully inside the viewport,
1984    /// [`ItemAlignment::Start`] when it sits above it or is taller than it,
1985    /// [`ItemAlignment::End`] when it sits below.
1986    fn nearest_alignment(&self, index: usize) -> Option<ItemAlignment> {
1987        let (top, extent) = self.item_span(index);
1988        let viewport = self.viewport.height;
1989        let position = self.placement_offset();
1990        let tolerance = ITEM_SETTLE_TOLERANCE_PX;
1991        if top >= position - tolerance && top + extent <= position + viewport + tolerance {
1992            None
1993        } else if top < position || extent > viewport {
1994            Some(ItemAlignment::Start)
1995        } else {
1996            Some(ItemAlignment::End)
1997        }
1998    }
1999
2000    /// Whether item `index` is in the materialized window (and so, after a
2001    /// layout, measured).
2002    fn window_holds(&self, index: usize) -> bool {
2003        match (self.keys.first(), self.keys.last()) {
2004            (Some(&first), Some(&last)) => first <= index && index <= last,
2005            _ => false,
2006        }
2007    }
2008
2009    /// One settle step for a pending [`ItemTarget`], run at the end of every
2010    /// layout (the rows on screen were just measured): re-resolve the row's
2011    /// aligned offset and jump to it if the placement is off by more than
2012    /// [`ITEM_SETTLE_TOLERANCE_PX`]. The request ends once the row is
2013    /// materialized and in tolerance, when its key leaves the data, or after
2014    /// [`ITEM_SETTLE_MAX_FRAMES`] layouts. Waits, consuming nothing, while a
2015    /// tween is still carrying the list there. The `scrolling` guard below is
2016    /// defensive only: a pending request never coexists with an established
2017    /// drag in real event flow — applying the request ends the drag
2018    /// (the core's `end_live_drag`) and a later primary `Down`, wheel
2019    /// or `Cancel` clears the request — so the guard documents the invariant
2020    /// rather than a resume-after-drag path.
2021    fn settle_item_target(&mut self) {
2022        let Some(mut target) = self.item_target else {
2023            return;
2024        };
2025        if self.core.ballistic.is_some() {
2026            return;
2027        }
2028        if self.core.scrolling {
2029            return;
2030        }
2031        let Some(index) = self.resolve_key(target.key) else {
2032            self.item_target = None;
2033            return;
2034        };
2035        target.frames_left = target.frames_left.saturating_sub(1);
2036        let desired = self.aligned_offset(index, target.alignment);
2037        let off = (desired - self.placement_offset()).abs() > ITEM_SETTLE_TOLERANCE_PX;
2038        if off {
2039            self.apply_jump(desired);
2040        }
2041        let landed = !off && self.window_holds(index);
2042        self.item_target = if landed || target.frames_left == 0 {
2043            None
2044        } else {
2045            Some(target)
2046        };
2047    }
2048
2049    /// Deliver a synthetic `Cancel` to whichever child holds the capture path,
2050    /// disarming an armed `ListItem` press when the scroll drag takes over.
2051    fn cancel_children(&mut self, ctx: &mut EventCtx, pos: Point) {
2052        let cancel = InputEvent::Pointer(PointerEvent {
2053            phase: PointerPhase::Cancel,
2054            position: pos,
2055            button: PointerButton::Primary,
2056        });
2057        // A takeover, not the gesture's end: the captured row gets its
2058        // `Cancel` exactly as `route_event` would deliver it, and is then
2059        // released through the context, which also ends a contact opt-in held
2060        // inside the row so the root stops routing other fingers to it.
2061        if let Some(pod) = self.children.iter_mut().find(|pod| pod.is_active()) {
2062            pod.event_child(ctx, &cancel);
2063            ctx.release_captured_child(pod);
2064        } else {
2065            crate::authoring::route_event(&mut self.children, ctx, &cancel);
2066        }
2067    }
2068
2069    /// The event entry, parameterised on an explicit timestamp so velocity math
2070    /// is deterministic in tests; [`Widget::event`] supplies the real clock.
2071    /// Routes the event, then publishes whatever position it left behind to an
2072    /// attached [`ScrollController`] (mirrors `ScrollWidget::event_at`).
2073    fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
2074        let result = self.route_event_at(ctx, event, t_ms);
2075        self.publish_to_controller();
2076        result
2077    }
2078
2079    /// The event body behind [`ListViewWidget::event_at`]: the non-gesture
2080    /// variants are routed to the *window* of children here, the wheel and
2081    /// pointer gesture by the shared core ([`ScrollSurface::handle_wheel`],
2082    /// [`ScrollSurface::handle_pointer`]) exactly as for `ScrollView`.
2083    fn route_event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
2084        // A fling-driven near-start/near-end fire recorded at paint time is
2085        // delivered on the next event — except a Cancel, which clears it
2086        // without firing.
2087        if !matches!(
2088            event,
2089            InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
2090        ) {
2091            self.deliver_pending_scroll(ctx);
2092            self.deliver_pending_near_start(ctx);
2093            self.deliver_pending_near_end(ctx);
2094        }
2095        match event {
2096            // A broadcast reaches every realized child unconditionally and is
2097            // never consumed — `route_event` owns that contract, so this arm just
2098            // hands it over ahead of the gesture machinery. A floated surface's
2099            // own input is the second broadcast and rides the same arm: an
2100            // overlay owner in a realized row has to hear it.
2101            InputEvent::Housekeeping | InputEvent::Overlay(_) => {
2102                crate::authoring::route_event(&mut self.children, ctx, event)
2103            }
2104            // Focus-routed events — keyboard, IME, and the clipboard verbs an
2105            // `EditCommand` carries — reach the focused row through
2106            // `route_event`'s own focus branch, never a hit test.
2107            InputEvent::Key(_) | InputEvent::Ime(_) | InputEvent::EditCommand(_) => {
2108                crate::authoring::route_event(&mut self.children, ctx, event)
2109            }
2110            // Wheel scrolling stays hard-clamped, matching `ScrollView`; a
2111            // drag's past-edge pull lands in `overscroll`, never in a
2112            // windowing offset outside `[0, max_offset]` (see the module docs'
2113            // *Overscroll and pull-to-refresh*).
2114            InputEvent::Scroll { delta, .. } => self.handle_wheel(ctx, delta),
2115            InputEvent::Pointer(p) => self.handle_pointer(ctx, event, p, t_ms),
2116            // `InputEvent` grows (a scale gesture and file drops are planned);
2117            // this widget handles only the variants named above, and hands any
2118            // other to its children exactly like the broadcast arm.
2119            _ => crate::authoring::route_event(&mut self.children, ctx, event),
2120        }
2121    }
2122}
2123
2124/// The windowing owner side of the core: the clamped windowing `offset` is
2125/// the base position, `overscroll` carries the past-edge displacement, and
2126/// the physics reasons about their sum — so no row is ever asked to
2127/// materialize outside `[0, item_count)` (the module docs' *Overscroll and
2128/// pull-to-refresh*).
2129impl ScrollSurface for ListViewWidget {
2130    fn core(&self) -> &ScrollSurfaceCore {
2131        &self.core
2132    }
2133
2134    fn core_mut(&mut self) -> &mut ScrollSurfaceCore {
2135        &mut self.core
2136    }
2137
2138    /// [`ListViewWidget::max_offset`], read fresh, so a still-converging
2139    /// variable-extent content extent is always what the physics sees.
2140    fn max_scroll_extent(&self) -> f64 {
2141        self.max_offset()
2142    }
2143
2144    fn viewport_extent(&self) -> f64 {
2145        self.viewport.height
2146    }
2147
2148    fn base_offset(&self) -> f64 {
2149        self.offset
2150    }
2151
2152    fn set_base_offset(&mut self, value: f64) {
2153        self.set_offset(value);
2154    }
2155
2156    /// The **raw** windowing offset plus any live displacement — the space
2157    /// [`ListViewWidget::max_offset`] lives in. Deliberately not
2158    /// [`ListViewWidget::painted_offset`], which also folds in an uncommitted
2159    /// [`ListViewWidget::pending_correction`]: a physics answer fed back into
2160    /// raw space would double-count it (and seeding the drag accumulator from
2161    /// it at takeover would too, once `placement_offset` re-adds it).
2162    fn pixels(&self) -> f64 {
2163        self.offset + self.overscroll
2164    }
2165
2166    fn displacement(&self) -> f64 {
2167        self.overscroll
2168    }
2169
2170    /// The one thing local to this widget: because its windowing offset may
2171    /// never leave range, the position the physics allows is clamped into
2172    /// `offset` and whatever is left over lands in `overscroll` for paint
2173    /// alone. `max_offset` is read fresh every call, so a variable-extent
2174    /// list's still-converging content extent is always what the split runs
2175    /// against.
2176    fn set_allowed_position(&mut self, allowed: f64) {
2177        self.offset = allowed.clamp(0.0, self.max_offset());
2178        self.overscroll = allowed - self.offset;
2179    }
2180
2181    /// Only ever eases `overscroll` — the windowing offset already sits at the
2182    /// exact edge the moment a drag overscrolls, so it needs no motion of its
2183    /// own.
2184    fn decay_displacement(&mut self, retained: f64) {
2185        self.overscroll *= retained;
2186    }
2187
2188    fn settle_to_edge(&mut self) {
2189        self.overscroll = 0.0;
2190    }
2191
2192    fn sync_origins(&mut self) {
2193        self.sync_child_origins();
2194    }
2195
2196    /// This surface's position as a [`ScrollInfo`]: the content-space
2197    /// placement (what an app reads back after a jump), the converging
2198    /// `max_offset`, and the signed overscroll.
2199    fn scroll_info(&self) -> ScrollInfo {
2200        ScrollInfo {
2201            offset: self.placement_offset(),
2202            max_offset: self.max_offset(),
2203            overscroll: self.overscroll,
2204        }
2205    }
2206
2207    fn forward_pointer(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2208        crate::authoring::route_event(&mut self.children, ctx, event)
2209    }
2210
2211    fn take_over_from_content(&mut self, ctx: &mut EventCtx, position: Point) {
2212        self.cancel_children(ctx, position);
2213    }
2214
2215    /// User input wins over a settling `scroll_to_item`.
2216    fn on_user_input(&mut self) {
2217        self.item_target = None;
2218    }
2219
2220    fn after_input_scroll(&mut self, ctx: &mut EventCtx) {
2221        self.fire_near_start(ctx);
2222        self.fire_near_end(ctx);
2223    }
2224
2225    /// The pump moved the offset with no `EventCtx` in scope: a near-start or
2226    /// near-end edge it crossed is recorded for the next event — and, when a
2227    /// controller tween carried it there, owed the next frame's flush as a
2228    /// jump's is, since no user event follows a programmatic move. (A settle
2229    /// never moves `placement_offset()`, so this is a no-op there.)
2230    fn after_motion_frame(&mut self, tween: bool) {
2231        if self.evaluate_near_start() {
2232            self.pending_near_start = true;
2233            self.jump_notify_owed |= tween;
2234        }
2235        if self.evaluate_near_end() {
2236            self.pending_near_end = true;
2237            self.jump_notify_owed |= tween;
2238        }
2239    }
2240
2241    /// A near-start/near-end edge the jump crosses is recorded for the next
2242    /// event, like a fling frame's, and owes the flush that delivers it.
2243    fn after_jump(&mut self) {
2244        if self.evaluate_near_start() {
2245            self.pending_near_start = true;
2246            self.jump_notify_owed = true;
2247        }
2248        if self.evaluate_near_end() {
2249            self.pending_near_end = true;
2250            self.jump_notify_owed = true;
2251        }
2252    }
2253
2254    fn on_cancel(&mut self) {
2255        self.pending_near_start = false;
2256        self.pending_near_end = false;
2257    }
2258
2259    /// Any uncommitted measured correction is dropped rather than committed —
2260    /// `target` already names where the content should sit, so the placement
2261    /// becomes exactly it.
2262    fn jump_position(&mut self, target: f64) {
2263        self.overscroll = 0.0;
2264        self.pending_correction = 0.0;
2265        self.set_offset(target);
2266    }
2267
2268    fn prepare_animate(&mut self) {
2269        self.overscroll = 0.0;
2270    }
2271
2272    /// An uncommitted measured correction is committed first (the placement
2273    /// does not move), so the tween starts from where the content actually
2274    /// sits.
2275    fn tween_origin(&mut self) -> f64 {
2276        self.offset = self.placement_offset();
2277        self.pending_correction = 0.0;
2278        self.offset
2279    }
2280
2281    fn handles_item_commands(&self) -> bool {
2282        true
2283    }
2284
2285    /// A later offset command replaces a settling item request outright, like
2286    /// it replaces a tween.
2287    fn before_offset_command(&mut self) {
2288        self.item_target = None;
2289    }
2290
2291    fn apply_item_command(&mut self, request: ScrollToItem) {
2292        self.apply_scroll_to_item(request);
2293    }
2294}
2295
2296/// Report a [`ScrollController::scroll_to_item`] request no row answered — an
2297/// app bug worth hearing about in a debug build, but a no-op either way (a
2298/// recorded command has no caller left to return a `Result` to).
2299fn unknown_item_key(_request: &ScrollToItem, _keyed: bool) {
2300    #[cfg(debug_assertions)]
2301    if _keyed {
2302        eprintln!(
2303            "frust-widgets: ScrollController::scroll_to_item({:?}) ignored: no row of the \
2304             attached ListView carries that key",
2305            _request.key
2306        );
2307    } else {
2308        eprintln!(
2309            "frust-widgets: ScrollController::scroll_to_item({:?}) ignored: the attached \
2310             ListView is positional (ListView::builder) and has no row keys; build it with \
2311             ListView::builder_keyed",
2312            _request.key
2313        );
2314    }
2315}
2316
2317impl<State: 'static> View<State> for ListView<State> {
2318    type Element = ListViewWidget;
2319
2320    fn build(&self, ctx: &mut BuildCtx<'_>) -> ListViewWidget {
2321        let mut widget = ListViewWidget::new(self.item_count, self.item_extent);
2322        widget.on_near_start = self
2323            .on_near_start
2324            .as_ref()
2325            .map(crate::authoring::erase_callback);
2326        widget.near_start_threshold = self.near_start_threshold;
2327        widget.on_near_end = self
2328            .on_near_end
2329            .as_ref()
2330            .map(crate::authoring::erase_callback);
2331        widget.near_end_threshold = self.near_end_threshold;
2332        widget.core.on_refresh_release = self
2333            .on_refresh_release
2334            .as_ref()
2335            .map(crate::authoring::erase_callback);
2336        widget.core.install(self.physics.as_ref(), self.effect);
2337        widget.core.attach_controller(self.controller.as_ref());
2338        // The key function and the unmeasured-row estimate are widget state (the
2339        // window math and layout's measurement both run without the view in
2340        // scope), and must be installed before the first window is planned.
2341        widget.key_of = self.key_of.clone();
2342        widget.estimated_extent = self.variable_estimate();
2343        // Conservative initial window from a zero viewport (converges within one
2344        // extra frame via paint's continuation request — see the module docs).
2345        let plan = widget.desired_window();
2346        let variable = widget.is_variable();
2347        let mut duplicate = false;
2348        for index in plan.start..plan.end {
2349            widget
2350                .children
2351                .push(crate::authoring::build_child(&(self.builder)(index), ctx));
2352            widget.keys.push(index);
2353            if let Some(key_of) = self.key_of.as_ref() {
2354                // Seed the retained identity map so the first rebuild can already
2355                // relocate these rows by key.
2356                let key = key_of(index);
2357                duplicate |= widget.key_index.insert(key, index).is_some();
2358                if variable {
2359                    widget.slot_keys.push(key);
2360                }
2361            }
2362        }
2363        debug_assert!(!duplicate, "{}", DUPLICATE_KEY_MSG);
2364        if let Some(estimate) = widget.variable_estimate() {
2365            widget.set_window_geometry(plan, estimate);
2366        }
2367        widget.sync_child_origins();
2368        widget
2369    }
2370
2371    fn rebuild(
2372        &self,
2373        prev: &Self,
2374        element: &mut ListViewWidget,
2375        ctx: &mut BuildCtx<'_>,
2376    ) -> ChangeFlags {
2377        // Closures are not comparable — always reinstall the erased adapter.
2378        element.on_near_start = self
2379            .on_near_start
2380            .as_ref()
2381            .map(crate::authoring::erase_callback);
2382        element.near_start_threshold = self.near_start_threshold;
2383        element.on_near_end = self
2384            .on_near_end
2385            .as_ref()
2386            .map(crate::authoring::erase_callback);
2387        element.near_end_threshold = self.near_end_threshold;
2388        element.core.on_refresh_release = self
2389            .on_refresh_release
2390            .as_ref()
2391            .map(crate::authoring::erase_callback);
2392        // A `.physics(...)`-carrying view reinstalls it every rebuild, like the
2393        // erased callbacks above; a view with no opinion (`None`) leaves the
2394        // widget's currently-installed physics alone — see `ListView::physics`'s
2395        // doc for the full contract. The visual effect is plain data the view
2396        // owns and is always carried down unconditionally.
2397        element.core.install(self.physics.as_ref(), self.effect);
2398        // Closures are not comparable either — reinstall the key function the
2399        // widget's own passes key indices with (never the builder; see the
2400        // module docs' *Variable extents* section).
2401        element.key_of = self.key_of.clone();
2402
2403        let mut flags = ChangeFlags::NONE;
2404        let old_item_count = element.item_count;
2405        let mut item_count_changed = false;
2406        if element.item_count != self.item_count {
2407            element.item_count = self.item_count;
2408            item_count_changed = true;
2409            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2410        }
2411        if element.item_extent != self.item_extent {
2412            element.item_extent = self.item_extent;
2413            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2414        }
2415        let estimate = self.variable_estimate();
2416        if element.estimated_extent != estimate {
2417            // Entering, leaving, or re-scaling variable-extent mode is an
2418            // extent-affecting change like `item_extent`: every row's position
2419            // is derived from it. Leaving it also drops the bookkeeping, so a
2420            // later re-entry starts from measurements this list actually took.
2421            element.estimated_extent = estimate;
2422            if estimate.is_none() {
2423                element.reset_variable_state();
2424            }
2425            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2426        }
2427
2428        // Prepend/removal scroll anchoring — keyed lists only (positional
2429        // identity cannot distinguish a prepend from a full mutation, see the
2430        // module docs' *Row identity* section): correct the offset for the
2431        // topmost surviving keyed row of the previous window *before* this
2432        // frame's window is recomputed below, so layout and paint agree on
2433        // one corrected offset — never during paint. See the module docs'
2434        // anchoring section for the algorithm and the `offset == 0` decision.
2435        let mut prepend_correction = false;
2436        // Set when the anchor-shift hypothesis probe below misses on every key
2437        // in the previous window. NOT itself proof of a full replace — the
2438        // probe only tests two candidate index shifts and can miss on a
2439        // same-frame mutation touching both sides of the anchor (a prepend
2440        // above the viewport plus an append below it in one frame) even
2441        // though most on-screen rows are still exactly the ones they were.
2442        // Whether the measured *cache* should reset on that is answered
2443        // later, inside `reconcile_keyed`, against its own exact per-slot key
2444        // matches — see the module docs' *Cache hygiene* section. The
2445        // *pending measured-anchor correction*, in contrast, is zeroed
2446        // unconditionally the moment the probe misses (below) — a narrower,
2447        // unrelated question the cache-reset decision does not gate.
2448        let mut anchor_probe_missed = false;
2449        if let Some(key_of) = self.key_of.as_ref() {
2450            match Self::anchor_shift_items(prev, element, old_item_count, key_of) {
2451                Some(shift_items) if shift_items != 0 => {
2452                    element.apply_anchor_shift(shift_items);
2453                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2454                    prepend_correction = shift_items > 0;
2455                }
2456                Some(_) => {}
2457                None => {
2458                    anchor_probe_missed = true;
2459                    // A pending measured-anchor correction describes rows
2460                    // wholly above the viewport top *in the geometry this
2461                    // frame's now-superseded window was planned against*
2462                    // (module docs' *Measured anchor correction* section).
2463                    // The anchor probe just proved that geometry cannot be
2464                    // explained against this mutation by either hypothesis —
2465                    // committing the accumulated correction into new data
2466                    // below (`apply_pending_correction`, called after this
2467                    // match) would be a guess, not a measurement. Zero it
2468                    // here, before that commit point, regardless of whether
2469                    // the measured *cache* itself resets: that is a separate,
2470                    // narrower decision made later in `reconcile_keyed`
2471                    // against its own exact per-slot key matches (see the
2472                    // module docs' *Cache hygiene* section) — a probe miss
2473                    // with a surviving on-screen row keeps the cache but
2474                    // still owes this reset, since the correction was
2475                    // computed against the pre-mutation window regardless of
2476                    // whether any individual row's measurement survives.
2477                    element.pending_correction = 0.0;
2478                }
2479            }
2480        }
2481
2482        if item_count_changed {
2483            // Content length changed — rearm the near-start "load older" and
2484            // near-end "load newer" edges so a list that grew (older rows
2485            // loaded, or newer rows appended) can trigger again — EXCEPT: a
2486            // keyed prepend correction just proved this exact growth was the
2487            // near-start edge's own fire being answered, so forcing it back
2488            // armed here would let the very next scroll event refire it
2489            // immediately, at the exact anchored offset the correction just
2490            // placed the user at (see the module docs' anchoring section).
2491            if !prepend_correction {
2492                element.near_start_armed = true;
2493            }
2494            element.near_end_armed = true;
2495        }
2496
2497        if self.item_count < old_item_count {
2498            // Cache hygiene: a shrunk keying provably cannot produce an entry
2499            // recorded past the new end (inert on the uniform path).
2500            element.evict_measured_stale_indices();
2501        }
2502
2503        // Measured anchor correction (variable extents only; the uniform path
2504        // measures nothing and never carries one): the previous layout recorded
2505        // how much taller or shorter the content above the viewport top actually
2506        // measured than the offset was planned against. Commit it here — the one
2507        // place the offset absorbs it, before this frame's window is planned —
2508        // unless a fling is live, in which case it keeps accumulating and lands
2509        // on the first rebuild after the fling stops. Placement has honored it
2510        // since the frame it was measured, so this moves nothing on screen; it
2511        // is still a `LAYOUT` report because the offset every row's origin is
2512        // derived from just changed. See the module docs' *Measured anchor
2513        // correction* section.
2514        if element.apply_pending_correction() {
2515            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2516        }
2517
2518        let offset_before_clamp = element.offset;
2519        element.clamp_offset();
2520        if element.is_variable() && element.offset != offset_before_clamp {
2521            // Variable extents only: measurements can revise the content extent
2522            // out from under the offset, which moves every row — the same
2523            // extent-affecting rule the item-count/extent checks above follow.
2524            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2525        }
2526
2527        // Programmatic scrolling: (re)bind the handle, then apply whatever it
2528        // queued now — after the anchor shift and the measured-correction
2529        // commit, *before* the window is planned — so the frame a jump lands
2530        // on materializes the rows it lands among (see the module docs'
2531        // *Programmatic scrolling* section). Anything it cannot apply yet (no
2532        // layout has measured a viewport) still owes a frame, and a settling
2533        // `scroll_to_item` owes the layout its next correction runs in.
2534        let rebound = element.core.attach_controller(self.controller.as_ref());
2535        let reduce_motion = element.motion.reduce_motion;
2536        if element.drain_controller(reduce_motion) || element.item_target.is_some() {
2537            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
2538        }
2539        if rebound || element.core.has_pending_commands() {
2540            flags |= ChangeFlags::PAINT;
2541        }
2542
2543        let plan = element.desired_window();
2544        let delta = self.item_count as isize - old_item_count as isize;
2545        flags |= match self.key_of.as_ref() {
2546            Some(key_of) => {
2547                self.reconcile_keyed(prev, element, ctx, plan, key_of, delta, anchor_probe_missed)
2548            }
2549            None => {
2550                // A positional frame owns no key identities: drop whatever map a
2551                // previous keyed frame left (and, with it, every measurement
2552                // cached under one), so a list switched back to keyed later
2553                // cannot match against a window this path re-materialized by
2554                // index (a one-frame full re-materialization is the price of
2555                // swapping constructors mid-flight).
2556                element.reset_keyed_state();
2557                self.reconcile_positional(prev, element, ctx, plan)
2558            }
2559        };
2560        flags
2561    }
2562
2563    fn teardown(&self, element: &mut ListViewWidget, ctx: &mut BuildCtx<'_>) {
2564        for (index, pod) in element.keys.iter().zip(element.children.iter_mut()) {
2565            crate::authoring::teardown_child(&(self.builder)(*index), pod, ctx);
2566        }
2567    }
2568}
2569
2570impl Widget for ListViewWidget {
2571    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2572        let vw = if bc.max().width.is_finite() {
2573            bc.max().width
2574        } else {
2575            0.0
2576        };
2577        let vh = if bc.max().height.is_finite() {
2578            bc.max().height
2579        } else {
2580            // An unbounded height context: the list is as tall as its content.
2581            self.content_extent()
2582        };
2583        self.viewport = Size::new(vw, vh);
2584        self.clamp_offset();
2585        if let Some(estimate) = self.variable_estimate() {
2586            // Variable extents: rows size themselves and are measured here.
2587            self.layout_variable(ctx, vw, estimate);
2588        } else {
2589            // Uniform extent: every materialized row is exactly `item_extent` tall.
2590            let child_bc = BoxConstraints::tight(Size::new(vw, self.item_extent));
2591            for pod in &mut self.children {
2592                pod.layout_child(ctx, &child_bc);
2593            }
2594        }
2595        // A controller command resolves against the viewport and extents just
2596        // measured; a settling `scroll_to_item` re-resolves against them too.
2597        self.core.laid_out = true;
2598        self.motion = ControllerMotion::from_theme(Theme::from_layout_ctx(ctx));
2599        self.drain_controller(self.motion.reduce_motion);
2600        self.settle_item_target();
2601        self.sync_child_origins();
2602        self.publish_to_controller();
2603        bc.constrain(self.viewport)
2604    }
2605
2606    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2607        // Record the frame clock, apply a command recorded since layout (or
2608        // with layout skipped this frame) before the pump so a jump ends a
2609        // fling before it advances, pump post-release motion and publish —
2610        // see `ScrollSurface::paint_motion`.
2611        self.motion = ControllerMotion::from_theme(Theme::from_paint_ctx(ctx));
2612        self.paint_motion(ctx, self.motion.reduce_motion);
2613        if std::mem::take(&mut self.jump_notify_owed)
2614            && (self.pending_near_start || self.pending_near_end)
2615        {
2616            // Paint has no `&mut State`: owe the jump's (or tween's) edge fire
2617            // to the next frame's Housekeeping flush (the `ScrollWidget`
2618            // precedent).
2619            frust_core::mark_pending_result_flush();
2620            ctx.request_frame();
2621        }
2622        if self.item_target.is_some() {
2623            // A settling `scroll_to_item` corrects at layout: make sure the
2624            // next frame lays out even under the mobile intra-frame skip.
2625            ctx.request_layout();
2626        }
2627        scene.push_clip(ctx.origin(), ctx.size());
2628        self.sync_child_origins();
2629        // The stretch is PAINT-ONLY, and load-bearingly so: no layout pass
2630        // (nor the windowing math) reads `edge_pull` or the intensity derived
2631        // from it, and none may start to. A layout-affecting animation must
2632        // request a relayout on every frame of its motion or the mobile
2633        // shell's intra-frame layout skip leaves it frozen
2634        // (`docs/WIDGETS_CODE_STANDARDS.md`'s animation-pacing rule) —
2635        // keeping the stretch out of every layout read is what makes that
2636        // irrelevant here, and is why there is no `request_layout` in this
2637        // path either: the settle/ballistic pump above already asks for every
2638        // frame the decaying stretch needs. Pushed INSIDE the viewport clip
2639        // so stretched rows can never paint past the viewport's edges.
2640        // Mirrors `ScrollWidget::paint`, over the core's `edge_pull`.
2641        let stretch = self.core.stretch_transform(ctx.origin(), ctx.size());
2642        if let Some(transform) = stretch {
2643            scene.push_transform(transform);
2644        }
2645        for pod in &mut self.children {
2646            pod.paint_child(ctx, scene);
2647        }
2648        if stretch.is_some() {
2649            scene.pop_transform();
2650        }
2651        scene.pop_clip();
2652        // If the (now-known) viewport needs rows the materialized window does not
2653        // yet hold — first build, a constraint change, a fling that advanced the
2654        // offset past the buffer, or (variable extents) measurements that
2655        // revised the window out from under the plan rebuild worked from — ask
2656        // the shell for one more frame so the next rebuild re-windows. Converges
2657        // without idling under-materialized, and is the only path by which a
2658        // measurement changes what is materialized: layout and paint never build.
2659        // A correction this frame's layout recorded is committed by the *next*
2660        // rebuild, so a frame carrying one owes a continuation frame exactly
2661        // like an uncovered window does — the same one-frame convergence, and
2662        // the reason a measured correction never idles uncommitted. (While a
2663        // fling withholds it the pump above is already asking; the request here
2664        // is what covers the frame the fling stops on.)
2665        let WindowPlan { start, end, .. } = self.desired_window();
2666        let uncovered = self.item_count > 0 && !self.window_covers(start, end);
2667        if uncovered || self.pending_correction != 0.0 {
2668            ctx.request_frame();
2669        }
2670    }
2671
2672    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2673        let t = self.core.event_time_ms();
2674        self.event_at(ctx, event, t)
2675    }
2676
2677    fn semantics(&self, ctx: &mut SemanticsCtx) {
2678        // A List container exposing its total item count and vertical scroll
2679        // range; the materialized rows contribute their own child nodes. Per the
2680        // pull-based seam, only the windowed rows are present — the accessible
2681        // set matches what is rendered, which is the documented v1 behavior.
2682        // `position_in_set` is not set on children: the semantics seam threads no
2683        // item index into `semantics_child`, and the generic builder need not
2684        // produce `ListItem`s, so the container's `size_of_set` carries the count.
2685        let max_offset = self.max_offset();
2686        let count = self.item_count;
2687        // The painted position, not the windowing offset: it is where the
2688        // content actually sits, and it is the value that stays put across a
2689        // correction commit (see the module docs' *Measured anchor
2690        // correction* section) — and, like `ScrollView`'s own semantics node,
2691        // it can momentarily read outside `[0, max_offset]` mid-overscroll
2692        // (see the module docs' *Overscroll and pull-to-refresh* section).
2693        // Placement, overscroll, and the raw offset all agree once the list is
2694        // in range, which is always true on the uniform path.
2695        let offset = self.painted_offset();
2696        ctx.push_container(
2697            Role::List,
2698            move |node| {
2699                node.set_size_of_set(count);
2700                node.set_scroll_y(offset);
2701                node.set_scroll_y_min(0.0);
2702                node.set_scroll_y_max(max_offset);
2703            },
2704            |ctx| {
2705                for pod in &self.children {
2706                    pod.semantics_child(ctx);
2707                }
2708            },
2709        );
2710    }
2711
2712    crate::authoring::visit_children!(children);
2713}
2714
2715#[cfg(test)]
2716mod tests {
2717    use super::*;
2718    use crate::physics::parity::{Bouncing, Clamping, DecelerationRate, NeverScrollable};
2719    use crate::physics::rubber_band::RubberBand;
2720    use crate::scroll::ScrollWidget;
2721    use frust_core::{RenderRoot, any};
2722    use std::any::Any;
2723    use std::cell::{Cell, RefCell};
2724    use std::rc::Rc;
2725
2726    // --- A stateful row fixture: each built widget is stamped with a monotonic
2727    //     "generation" from a shared counter, so a relocated (state-preserving)
2728    //     row keeps its stamp while a rebuilt-fresh row gets a new one. ---
2729
2730    struct GenView {
2731        gens: Rc<Cell<u64>>,
2732        seen: Rc<GenLog>,
2733        index: usize,
2734    }
2735
2736    #[derive(Default)]
2737    struct GenLog {
2738        // index -> generation, last write wins (proves which pod rendered it).
2739        entries: std::cell::RefCell<std::collections::HashMap<usize, u64>>,
2740    }
2741
2742    struct GenWidget {
2743        generation: u64,
2744        seen: Rc<GenLog>,
2745        index: usize,
2746    }
2747
2748    impl View<()> for GenView {
2749        type Element = GenWidget;
2750        fn build(&self, _ctx: &mut BuildCtx<'_>) -> GenWidget {
2751            let generation = self.gens.get();
2752            self.gens.set(generation + 1);
2753            GenWidget {
2754                generation,
2755                seen: self.seen.clone(),
2756                index: self.index,
2757            }
2758        }
2759        fn rebuild(
2760            &self,
2761            _prev: &Self,
2762            element: &mut GenWidget,
2763            _ctx: &mut BuildCtx<'_>,
2764        ) -> ChangeFlags {
2765            // A same-type in-place rebuild keeps the generation (state preserved).
2766            element.index = self.index;
2767            element.seen = self.seen.clone();
2768            ChangeFlags::NONE
2769        }
2770    }
2771
2772    impl Widget for GenWidget {
2773        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2774            bc.constrain(bc.max())
2775        }
2776        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
2777            self.seen
2778                .entries
2779                .borrow_mut()
2780                .insert(self.index, self.generation);
2781        }
2782    }
2783
2784    struct NullScene;
2785    impl PaintScene for NullScene {
2786        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
2787        fn draw_text(&mut self, _o: Point, _t: &str) {}
2788    }
2789
2790    fn list_widget(root: &RenderRoot<(), ListView<()>>) -> &ListViewWidget {
2791        let id = root.root_id().expect("root built");
2792        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
2793            .downcast_ref::<ListViewWidget>()
2794            .expect("root is a ListViewWidget")
2795    }
2796
2797    /// Drive a full rebuild → layout → paint frame at `ms`, returning the flags
2798    /// the rebuild reported (what the layout-skip contract is asserted against).
2799    fn frame(
2800        root: &mut RenderRoot<(), ListView<()>>,
2801        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
2802        state: &mut (),
2803        window: Size,
2804        ms: f64,
2805    ) -> ChangeFlags {
2806        let flags = root.rebuild(logic, state);
2807        root.layout(window);
2808        let mut sink = NullScene;
2809        root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
2810        flags
2811    }
2812
2813    fn ev(phase: PointerPhase, y: f64) -> InputEvent {
2814        InputEvent::Pointer(PointerEvent {
2815            phase,
2816            position: Point::new(10.0, y),
2817            button: PointerButton::Primary,
2818        })
2819    }
2820
2821    // --- (1) Only the visible window materializes, at multiple offsets. ---
2822
2823    #[test]
2824    fn only_the_visible_window_materializes() {
2825        fn logic(_: &mut ()) -> ListView<()> {
2826            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
2827        }
2828        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
2829        let mut state = ();
2830        let window = Size::new(200.0, 200.0);
2831
2832        // First frame builds a conservative window; a second converges to the
2833        // real viewport (200 / 50 = 4 rows + 2*BUFFER).
2834        frame(&mut root, &mut logic, &mut state, window, 0.0);
2835        frame(&mut root, &mut logic, &mut state, window, 16.0);
2836
2837        let w = list_widget(&root);
2838        // window = floor(0/50)-2 .. ceil(200/50)+2 = 0 .. 6
2839        assert_eq!(w.window(), &[0, 1, 2, 3, 4, 5]);
2840        assert!(
2841            w.children.len() < 20,
2842            "only a handful of the 1000 rows are materialized"
2843        );
2844    }
2845
2846    #[test]
2847    fn window_shifts_to_the_scrolled_offset() {
2848        fn logic(_: &mut ()) -> ListView<()> {
2849            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
2850        }
2851        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
2852        let mut state = ();
2853        let window = Size::new(200.0, 200.0);
2854        frame(&mut root, &mut logic, &mut state, window, 0.0);
2855        frame(&mut root, &mut logic, &mut state, window, 16.0);
2856
2857        // Wheel to offset 500 (10 lines * 40px/line = 400... use a pixel scroll).
2858        root.event(
2859            &mut state,
2860            &InputEvent::Scroll {
2861                position: Point::new(10.0, 50.0),
2862                delta: ScrollDelta::Pixels(0.0, 500.0),
2863            },
2864        );
2865        frame(&mut root, &mut logic, &mut state, window, 32.0);
2866
2867        let w = list_widget(&root);
2868        assert_eq!(w.offset(), 500.0);
2869        // window = floor(500/50)-2 .. ceil(700/50)+2 = 8 .. 16
2870        assert_eq!(w.window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
2871    }
2872
2873    // --- (2) A window shift relocates survivors (state preserved). ---
2874
2875    #[test]
2876    fn an_overlay_broadcast_reaches_every_realized_row() {
2877        use frust_core::{OverlayEvent, OverlayEventKind, OverlayKey};
2878
2879        /// A row that counts the floated-surface broadcasts it receives — an
2880        /// overlay owner living in a realized row.
2881        struct Owner(Rc<Cell<u32>>);
2882        struct OwnerW(Rc<Cell<u32>>);
2883        impl View<()> for Owner {
2884            type Element = OwnerW;
2885            fn build(&self, _c: &mut BuildCtx<'_>) -> OwnerW {
2886                OwnerW(self.0.clone())
2887            }
2888            fn rebuild(&self, _p: &Self, e: &mut OwnerW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
2889                e.0 = self.0.clone();
2890                ChangeFlags::NONE
2891            }
2892        }
2893        impl Widget for OwnerW {
2894            fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
2895                bc.constrain(Size::new(200.0, 50.0))
2896            }
2897            fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
2898            fn event(&mut self, _ctx: &mut EventCtx, e: &InputEvent) -> EventResult {
2899                if matches!(e, InputEvent::Overlay(_)) {
2900                    self.0.set(self.0.get() + 1);
2901                    // Reporting `Handled` must not stop the next row hearing it.
2902                    return EventResult::Handled;
2903                }
2904                EventResult::Ignored
2905            }
2906        }
2907
2908        let seen = Rc::new(Cell::new(0u32));
2909        let seen_l = seen.clone();
2910        let mut logic = move |_: &mut ()| -> ListView<()> {
2911            let seen = seen_l.clone();
2912            list_view(1000, 50.0, move |_| any::<(), _>(Owner(seen.clone())))
2913        };
2914        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
2915        let mut state = ();
2916        let window = Size::new(200.0, 200.0);
2917        frame(&mut root, &mut logic, &mut state, window, 0.0);
2918        frame(&mut root, &mut logic, &mut state, window, 16.0);
2919        let realized = list_widget(&root).children.len();
2920        assert!(realized > 1, "the fixture realizes a window of rows");
2921
2922        let outcome = root.event(
2923            &mut state,
2924            &InputEvent::Overlay(OverlayEvent {
2925                key: OverlayKey::next(),
2926                kind: OverlayEventKind::OutsideDown,
2927            }),
2928        );
2929        assert_eq!(
2930            seen.get() as usize,
2931            realized,
2932            "every realized row heard it, with no first-handler-wins short-circuit"
2933        );
2934        assert!(
2935            !outcome.handled,
2936            "a broadcast is never consumed, whatever a row returned"
2937        );
2938    }
2939
2940    #[test]
2941    fn window_shift_relocates_survivors_preserving_state() {
2942        let gens = Rc::new(Cell::new(0u64));
2943        let seen = Rc::new(GenLog::default());
2944        let gens_l = gens.clone();
2945        let seen_l = seen.clone();
2946        let mut logic = move |_: &mut ()| -> ListView<()> {
2947            let gens = gens_l.clone();
2948            let seen = seen_l.clone();
2949            list_view(1000, 50.0, move |i| {
2950                any::<(), _>(GenView {
2951                    gens: gens.clone(),
2952                    seen: seen.clone(),
2953                    index: i,
2954                })
2955            })
2956        };
2957        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
2958        let mut state = ();
2959        let window = Size::new(200.0, 200.0);
2960        frame(&mut root, &mut logic, &mut state, window, 0.0);
2961        frame(&mut root, &mut logic, &mut state, window, 16.0);
2962
2963        // Record the generation stamped on a row that will survive the shift.
2964        let survivor_gen = seen.entries.borrow()[&3];
2965
2966        // Nudge the offset down by one row (50px) so the window shifts by one but
2967        // index 3 stays inside it — via wheel so no child cancel is involved.
2968        root.event(
2969            &mut state,
2970            &InputEvent::Scroll {
2971                position: Point::new(10.0, 50.0),
2972                delta: ScrollDelta::Pixels(0.0, 50.0),
2973            },
2974        );
2975        frame(&mut root, &mut logic, &mut state, window, 32.0);
2976
2977        let entries = seen.entries.borrow();
2978        assert_eq!(
2979            entries[&3], survivor_gen,
2980            "a surviving row keeps its widget (and state): relocated, not rebuilt"
2981        );
2982        // window shifted to 0..7; index 6 newly entered → a strictly newer stamp.
2983        assert!(
2984            entries[&6] > survivor_gen,
2985            "a freshly-entered row is built anew (higher generation)"
2986        );
2987    }
2988
2989    // --- (3) A fling advances the window across frames. ---
2990
2991    #[test]
2992    fn fling_advances_the_window_across_frames() {
2993        fn logic(_: &mut ()) -> ListView<()> {
2994            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
2995        }
2996        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
2997        let mut state = ();
2998        let window = Size::new(200.0, 200.0);
2999        frame(&mut root, &mut logic, &mut state, window, 0.0);
3000        frame(&mut root, &mut logic, &mut state, window, 16.0);
3001
3002        // Drag up past the slop, build velocity, release → a fling downward.
3003        root.event(&mut state, &ev(PointerPhase::Down, 180.0));
3004        frame(&mut root, &mut logic, &mut state, window, 32.0);
3005        root.event(&mut state, &ev(PointerPhase::Move, 120.0)); // takeover
3006        frame(&mut root, &mut logic, &mut state, window, 48.0);
3007        root.event(&mut state, &ev(PointerPhase::Move, 60.0)); // build velocity
3008        root.event(&mut state, &ev(PointerPhase::Up, 60.0)); // release
3009
3010        assert!(list_widget(&root).is_flinging(), "release starts a fling");
3011        let start_first = list_widget(&root).window()[0];
3012
3013        // Pump several frames: paint advances the fling offset, the next rebuild
3014        // re-windows against it.
3015        for k in 0..8 {
3016            frame(
3017                &mut root,
3018                &mut logic,
3019                &mut state,
3020                window,
3021                64.0 + 16.0 * k as f64,
3022            );
3023        }
3024
3025        assert!(
3026            list_widget(&root).window()[0] > start_first,
3027            "the fling carried the window to higher indices across frames"
3028        );
3029    }
3030
3031    // --- (4) Extent / clamp math. ---
3032
3033    #[test]
3034    fn extent_is_exact_and_offset_clamps_with_no_overscroll() {
3035        let mut w = ListViewWidget::new(100, 40.0);
3036        w.viewport = Size::new(200.0, 300.0);
3037        // extent = 100 * 40 = 4000; max_offset = 4000 - 300 = 3700.
3038        assert_eq!(w.max_offset(), 3700.0);
3039        w.set_offset(10_000.0);
3040        assert_eq!(w.offset(), 3700.0, "clamped to the end, no overscroll");
3041        w.set_offset(-50.0);
3042        assert_eq!(w.offset(), 0.0, "clamped to the top");
3043
3044        // A viewport taller than the content pins the offset at zero.
3045        let mut short = ListViewWidget::new(2, 40.0);
3046        short.viewport = Size::new(200.0, 300.0);
3047        assert_eq!(short.max_offset(), 0.0);
3048    }
3049
3050    #[test]
3051    fn empty_list_materializes_nothing() {
3052        fn logic(_: &mut ()) -> ListView<()> {
3053            list_view(0, 50.0, |i| any::<(), _>(gen_stub(i)))
3054        }
3055        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3056        let mut state = ();
3057        let window = Size::new(200.0, 200.0);
3058        frame(&mut root, &mut logic, &mut state, window, 0.0);
3059        frame(&mut root, &mut logic, &mut state, window, 16.0);
3060        assert!(list_widget(&root).window().is_empty());
3061    }
3062
3063    // A stateless stand-in row used by the window/fling/extent tests (no shared
3064    // generation bookkeeping needed there).
3065    pub(super) fn gen_stub(_index: usize) -> impl View<()> {
3066        crate::test_support::leaf(200.0, 50.0)
3067    }
3068
3069    // --- (6) Semantics tree over the materialized window. ---
3070
3071    /// A minimal `Role::ListItem`-reporting row, standing in for
3072    /// `material::list_item::ListItem` so this baseline module's own test
3073    /// suite carries no dependency on a design-system catalog (see the
3074    /// [module docs](self) — `ListView` itself takes no opinion on what a row
3075    /// is; any child view that contributes a `Role::ListItem` node exercises
3076    /// the same semantics-forwarding path).
3077    struct RoleListItemRow;
3078
3079    struct RoleListItemRowWidget;
3080
3081    impl View<()> for RoleListItemRow {
3082        type Element = RoleListItemRowWidget;
3083        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RoleListItemRowWidget {
3084            RoleListItemRowWidget
3085        }
3086        fn rebuild(
3087            &self,
3088            _prev: &Self,
3089            _element: &mut RoleListItemRowWidget,
3090            _ctx: &mut BuildCtx<'_>,
3091        ) -> ChangeFlags {
3092            ChangeFlags::NONE
3093        }
3094    }
3095
3096    impl Widget for RoleListItemRowWidget {
3097        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
3098            bc.constrain(Size::new(300.0, 56.0))
3099        }
3100        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
3101        fn semantics(&self, ctx: &mut SemanticsCtx) {
3102            ctx.push_node(frust_core::accesskit::Role::ListItem, |_node| {});
3103        }
3104    }
3105
3106    #[test]
3107    fn semantics_is_a_list_container_over_the_windowed_rows() {
3108        use frust_core::accesskit::Role;
3109        use frust_text::TextContext;
3110
3111        fn logic(_s: &mut ()) -> ListView<()> {
3112            list_view(1000, 56.0, |_i| any::<(), _>(RoleListItemRow))
3113        }
3114        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3115        let mut state = ();
3116        let window = Size::new(300.0, 200.0);
3117
3118        // Converge the window (build → real-viewport rebuild), then collect.
3119        let mut tcx = TextContext::new();
3120        for _ in 0..2 {
3121            root.rebuild(&mut logic, &mut state);
3122            root.layout_with_text(window, &mut tcx as &mut dyn Any);
3123            let mut sink = NullScene;
3124            root.paint(&mut sink, FrameTime::ZERO);
3125        }
3126
3127        let update = root.semantics();
3128        let materialized = list_widget(&root).window().len();
3129
3130        let (_, list) = update
3131            .nodes
3132            .iter()
3133            .find(|(_, n)| n.role() == Role::List)
3134            .expect("the ListView contributes a Role::List container node");
3135        assert_eq!(
3136            list.size_of_set(),
3137            Some(1000),
3138            "the container advertises the full item count, not just the window"
3139        );
3140        assert_eq!(
3141            list.children().len(),
3142            materialized,
3143            "only the materialized rows are semantics children of the list"
3144        );
3145
3146        // Every materialized row contributes its own Role::ListItem node.
3147        let list_items = update
3148            .nodes
3149            .iter()
3150            .filter(|(_, n)| n.role() == Role::ListItem)
3151            .count();
3152        assert_eq!(list_items, materialized);
3153    }
3154
3155    // --- (7) Near-start "load older" callback. ---
3156
3157    /// A state that counts near-start fires (the "load older" trigger).
3158    #[derive(Default)]
3159    struct Loads {
3160        count: u32,
3161    }
3162
3163    /// Build a widget with a near-start callback installed, over a known viewport,
3164    /// bypassing the View layer (the callback path is exercised via `event_at`).
3165    fn near_start_widget(threshold: f64) -> ListViewWidget {
3166        let mut w = ListViewWidget::new(1000, 50.0);
3167        w.viewport = Size::new(200.0, 200.0);
3168        w.near_start_threshold = threshold;
3169        let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
3170        w.on_near_start = Some(crate::authoring::erase_callback(&cb));
3171        w.near_start_armed = true;
3172        w
3173    }
3174
3175    fn wheel(px: f64) -> InputEvent {
3176        InputEvent::Scroll {
3177            position: Point::new(10.0, 50.0),
3178            delta: ScrollDelta::Pixels(0.0, px),
3179        }
3180    }
3181
3182    fn run_loads(w: &mut ListViewWidget, state: &mut Loads, e: &InputEvent) {
3183        let sa: &mut dyn Any = state;
3184        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
3185        w.event_at(&mut ctx, e, 0.0);
3186    }
3187
3188    #[test]
3189    fn on_near_start_fires_near_start_and_rearms_after_scrolling_away() {
3190        let mut w = near_start_widget(100.0);
3191        let mut state = Loads::default();
3192
3193        // Scroll away from the start (past 2×threshold = 200) → no fire.
3194        run_loads(&mut w, &mut state, &wheel(500.0));
3195        assert_eq!(w.offset(), 500.0);
3196        assert_eq!(state.count, 0, "away from the start does not fire");
3197
3198        // Scroll back within the threshold of the start → fires once.
3199        run_loads(&mut w, &mut state, &wheel(-460.0));
3200        assert_eq!(w.offset(), 40.0);
3201        assert_eq!(
3202            state.count, 1,
3203            "nearing the start fires the load-older hook"
3204        );
3205
3206        // Staying near the start does not re-fire (edge-triggered, disarmed).
3207        run_loads(&mut w, &mut state, &wheel(-20.0));
3208        assert_eq!(w.offset(), 20.0);
3209        assert_eq!(state.count, 1, "no re-fire while still near the start");
3210
3211        // Scroll away past 2×threshold to rearm, then back → fires again.
3212        run_loads(&mut w, &mut state, &wheel(500.0));
3213        assert_eq!(state.count, 1);
3214        run_loads(&mut w, &mut state, &wheel(-480.0));
3215        assert_eq!(w.offset(), 40.0);
3216        assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
3217    }
3218
3219    #[test]
3220    fn on_near_start_does_not_fire_at_the_bottom() {
3221        let mut w = near_start_widget(100.0);
3222        let mut state = Loads::default();
3223        // Scroll to the very bottom — nowhere near the start edge.
3224        run_loads(&mut w, &mut state, &wheel(1_000_000.0));
3225        assert_eq!(w.offset(), w.max_offset());
3226        assert_eq!(state.count, 0, "the bottom is not the load-older edge");
3227    }
3228
3229    #[test]
3230    fn near_start_content_growth_rearms_across_rebuild() {
3231        // A fling-free rebuild path: growing item_count (older rows loaded) rearms
3232        // the edge so a subsequent near-start approach fires again.
3233        let loads = Rc::new(Cell::new(0u32));
3234        let loads_l = loads.clone();
3235        let count = Rc::new(Cell::new(1000usize));
3236        let count_l = count.clone();
3237        let mut logic = move |_: &mut ()| -> ListView<()> {
3238            let loads = loads_l.clone();
3239            list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
3240                .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
3241        };
3242        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3243        let mut state = ();
3244        let window = Size::new(200.0, 200.0);
3245        frame(&mut root, &mut logic, &mut state, window, 0.0);
3246        frame(&mut root, &mut logic, &mut state, window, 16.0);
3247
3248        // At the top (offset 0), a wheel nudge within the threshold fires once.
3249        root.event(&mut state, &wheel(10.0));
3250        assert_eq!(loads.get(), 1, "near-start fires at the top");
3251        // Still near start: no re-fire.
3252        root.event(&mut state, &wheel(5.0));
3253        assert_eq!(loads.get(), 1);
3254
3255        // Grow the content (older rows loaded) → rebuild rearms the edge.
3256        count.set(2000);
3257        frame(&mut root, &mut logic, &mut state, window, 32.0);
3258        root.event(&mut state, &wheel(5.0));
3259        assert_eq!(loads.get(), 2, "content growth rearms the load-older edge");
3260    }
3261
3262    #[test]
3263    fn cancel_clears_a_pending_near_start_without_firing() {
3264        let mut w = near_start_widget(100.0);
3265        let mut state = Loads::default();
3266        // Simulate a fling-driven near-start recorded at paint time.
3267        w.pending_near_start = true;
3268        // A Cancel must drop it without invoking the callback.
3269        run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
3270        assert!(!w.pending_near_start);
3271        assert_eq!(state.count, 0, "Cancel never fires the callback");
3272
3273        // A pending fire is otherwise delivered on the next (non-Cancel) event.
3274        w.pending_near_start = true;
3275        run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
3276        assert!(!w.pending_near_start);
3277        assert_eq!(
3278            state.count, 1,
3279            "a pending fire is delivered on the next event"
3280        );
3281    }
3282
3283    // --- (8) Near-end "load newer" callback (mirrors near-start above). ---
3284
3285    /// Build a widget with a near-end callback installed, over a known viewport,
3286    /// starting scrolled to the very end (mirrors [`near_start_widget`], which
3287    /// starts at the top).
3288    fn near_end_widget(threshold: f64) -> ListViewWidget {
3289        let mut w = ListViewWidget::new(1000, 50.0);
3290        w.viewport = Size::new(200.0, 200.0);
3291        w.near_end_threshold = threshold;
3292        let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
3293        w.on_near_end = Some(crate::authoring::erase_callback(&cb));
3294        w.near_end_armed = true;
3295        w.offset = w.max_offset();
3296        w
3297    }
3298
3299    #[test]
3300    fn on_near_end_fires_near_end_and_rearms_after_scrolling_away() {
3301        let mut w = near_end_widget(100.0);
3302        let mut state = Loads::default();
3303        let start_offset = w.offset();
3304
3305        // Scroll away from the end (past 2×threshold = 200) → no fire.
3306        run_loads(&mut w, &mut state, &wheel(-500.0));
3307        assert_eq!(w.offset(), start_offset - 500.0);
3308        assert_eq!(state.count, 0, "away from the end does not fire");
3309
3310        // Scroll back within the threshold of the end → fires once.
3311        run_loads(&mut w, &mut state, &wheel(460.0));
3312        assert_eq!(w.offset(), start_offset - 40.0);
3313        assert_eq!(state.count, 1, "nearing the end fires the load-newer hook");
3314
3315        // Staying near the end does not re-fire (edge-triggered, disarmed).
3316        run_loads(&mut w, &mut state, &wheel(20.0));
3317        assert_eq!(w.offset(), start_offset - 20.0);
3318        assert_eq!(state.count, 1, "no re-fire while still near the end");
3319
3320        // Scroll away past 2×threshold to rearm, then back → fires again.
3321        run_loads(&mut w, &mut state, &wheel(-500.0));
3322        assert_eq!(state.count, 1);
3323        run_loads(&mut w, &mut state, &wheel(480.0));
3324        assert_eq!(w.offset(), start_offset - 40.0);
3325        assert_eq!(state.count, 2, "rearmed after scrolling away, fires again");
3326    }
3327
3328    #[test]
3329    fn on_near_end_does_not_fire_at_the_top() {
3330        let mut w = near_end_widget(100.0);
3331        let mut state = Loads::default();
3332        // Scroll all the way to the very top — nowhere near the end edge.
3333        run_loads(&mut w, &mut state, &wheel(-1_000_000.0));
3334        assert_eq!(w.offset(), 0.0);
3335        assert_eq!(state.count, 0, "the top is not the load-newer edge");
3336    }
3337
3338    #[test]
3339    fn near_end_content_growth_rearms_across_rebuild() {
3340        // Growing item_count (newer rows appended) rearms the edge so a
3341        // subsequent near-end approach fires again.
3342        let loads = Rc::new(Cell::new(0u32));
3343        let loads_l = loads.clone();
3344        let count = Rc::new(Cell::new(1000usize));
3345        let count_l = count.clone();
3346        let mut logic = move |_: &mut ()| -> ListView<()> {
3347            let loads = loads_l.clone();
3348            list_view(count_l.get(), 50.0, |i| any::<(), _>(gen_stub(i)))
3349                .on_near_end(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
3350        };
3351        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3352        let mut state = ();
3353        let window = Size::new(200.0, 200.0);
3354        frame(&mut root, &mut logic, &mut state, window, 0.0);
3355        frame(&mut root, &mut logic, &mut state, window, 16.0);
3356
3357        // Scroll to the very bottom, within the threshold of the end → fires
3358        // once.
3359        root.event(&mut state, &wheel(1_000_000.0));
3360        assert_eq!(loads.get(), 1, "near-end fires at the bottom");
3361        // Still near end: no re-fire.
3362        root.event(&mut state, &wheel(5.0));
3363        assert_eq!(loads.get(), 1);
3364
3365        // Grow the content (newer rows appended) → rebuild rearms the edge, and
3366        // the new (farther) bottom is no longer within the threshold.
3367        count.set(2000);
3368        frame(&mut root, &mut logic, &mut state, window, 32.0);
3369        root.event(&mut state, &wheel(1_000_000.0));
3370        assert_eq!(loads.get(), 2, "content growth rearms the load-newer edge");
3371    }
3372
3373    #[test]
3374    fn cancel_clears_a_pending_near_end_without_firing() {
3375        let mut w = near_end_widget(100.0);
3376        let mut state = Loads::default();
3377        // Simulate a fling-driven near-end recorded at paint time.
3378        w.pending_near_end = true;
3379        // A Cancel must drop it without invoking the callback.
3380        run_loads(&mut w, &mut state, &ev(PointerPhase::Cancel, 50.0));
3381        assert!(!w.pending_near_end);
3382        assert_eq!(state.count, 0, "Cancel never fires the callback");
3383
3384        // A pending fire is otherwise delivered on the next (non-Cancel) event.
3385        w.pending_near_end = true;
3386        run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
3387        assert!(!w.pending_near_end);
3388        assert_eq!(
3389            state.count, 1,
3390            "a pending fire is delivered on the next event"
3391        );
3392    }
3393
3394    // --- (8b) Overscroll and pull-to-refresh: `ListViewWidget::offset` (the
3395    //      windowing offset) must never leave `[0, max_offset]`, only
3396    //      `ListViewWidget::overscroll` may — mirrors `scroll.rs`'s own
3397    //      overscroll/refresh test group, see the module docs' *Overscroll
3398    //      and pull-to-refresh* section. ---
3399
3400    /// A stateless stand-in row, like [`gen_stub`], used by every test in this
3401    /// group (materialization-bounds assertions only, no per-row identity).
3402    fn overscroll_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
3403        move |_: &mut ()| list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
3404    }
3405
3406    /// [`overscroll_logic`] with the pre-seam [`RubberBand`] feel pinned
3407    /// explicitly — the fixture every *rubber-band* pin in this file builds
3408    /// from now that the widget's own default is the platform-adaptive physics
3409    /// (bouncing here, clamping on Android), each with curves of its own. A
3410    /// test whose assertions are a `0.5`-resisted displacement or a
3411    /// `SETTLE_DECAY` trace is pinning *this* physics, not the default; the
3412    /// `ScrollView` twin of this fixture is `scroll.rs`'s
3413    /// `laid_out_rubber_band`.
3414    fn rubber_band_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
3415        move |_: &mut ()| {
3416            list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i))).physics(RubberBand::new())
3417        }
3418    }
3419
3420    #[test]
3421    fn top_overscroll_resists_never_moves_the_windowing_offset_and_settles_with_no_refresh() {
3422        let refreshes = Rc::new(Cell::new(0u32));
3423        let refreshes_l = refreshes.clone();
3424        let mut logic = move |_: &mut ()| -> ListView<()> {
3425            let refreshes = refreshes_l.clone();
3426            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3427                .physics(RubberBand::new())
3428                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
3429        };
3430        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3431        let mut state = ();
3432        let window = Size::new(200.0, 200.0);
3433        frame(&mut root, &mut logic, &mut state, window, 0.0);
3434        frame(&mut root, &mut logic, &mut state, window, 16.0);
3435
3436        // Down, then a drag downward crossing the slop takes the gesture over.
3437        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3438        frame(&mut root, &mut logic, &mut state, window, 32.0);
3439        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
3440        assert!(list_widget(&root).scrolling);
3441        assert_eq!(
3442            list_widget(&root).offset(),
3443            0.0,
3444            "the takeover move does not itself scroll"
3445        );
3446        frame(&mut root, &mut logic, &mut state, window, 48.0);
3447
3448        // Drag 20px further down past the already-at-top edge → resisted
3449        // overscroll, but the windowing offset stays put and no row
3450        // materializes before index 0.
3451        root.event(&mut state, &ev(PointerPhase::Move, 110.0));
3452        let w = list_widget(&root);
3453        assert_eq!(
3454            w.offset(),
3455            0.0,
3456            "the windowing offset never leaves [0, max]"
3457        );
3458        assert_eq!(
3459            w.overscroll, -10.0,
3460            "overscroll is the raw excess (-20) * OVERSCROLL_RESISTANCE (0.5), \
3461             matching ScrollView's own resistance exactly"
3462        );
3463        assert_eq!(w.window()[0], 0, "no row materializes before index 0");
3464
3465        // Release under the trigger (|-10| < 64) → settles, never refreshes.
3466        root.event(&mut state, &ev(PointerPhase::Up, 110.0));
3467        let w = list_widget(&root);
3468        assert!(w.settling, "an overscrolled release settles, never flings");
3469        assert!(!w.is_flinging());
3470        assert_eq!(
3471            refreshes.get(),
3472            0,
3473            "release under the trigger does not refresh"
3474        );
3475
3476        // Pump frames until the settle completes.
3477        let mut ms = 64.0;
3478        for _ in 0..30 {
3479            frame(&mut root, &mut logic, &mut state, window, ms);
3480            ms += 16.0;
3481            if !list_widget(&root).settling {
3482                break;
3483            }
3484        }
3485        let w = list_widget(&root);
3486        assert!(!w.settling, "the settle terminated");
3487        assert_eq!(
3488            w.overscroll, 0.0,
3489            "the surface settles back to the clamped edge"
3490        );
3491        assert_eq!(w.offset(), 0.0);
3492    }
3493
3494    #[test]
3495    fn on_refresh_release_fires_once_past_the_trigger_and_only_on_release() {
3496        let refreshes = Rc::new(Cell::new(0u32));
3497        let refreshes_l = refreshes.clone();
3498        let mut logic = move |_: &mut ()| -> ListView<()> {
3499            let refreshes = refreshes_l.clone();
3500            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3501                .physics(RubberBand::new())
3502                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
3503        };
3504        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3505        let mut state = ();
3506        let window = Size::new(200.0, 200.0);
3507        frame(&mut root, &mut logic, &mut state, window, 0.0);
3508        frame(&mut root, &mut logic, &mut state, window, 16.0);
3509
3510        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3511        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
3512        frame(&mut root, &mut logic, &mut state, window, 32.0);
3513        root.event(&mut state, &ev(PointerPhase::Move, 290.0)); // raw -200 → -100
3514
3515        let w = list_widget(&root);
3516        assert_eq!(w.overscroll, -100.0, "past the trigger (|-100| > 64)");
3517        assert_eq!(
3518            w.offset(),
3519            0.0,
3520            "windowing offset stays 0 even far past the trigger"
3521        );
3522        assert_eq!(w.window()[0], 0, "no row materializes before index 0");
3523        assert_eq!(refreshes.get(), 0, "no fire before release");
3524
3525        root.event(&mut state, &ev(PointerPhase::Up, 290.0));
3526        assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
3527        assert!(list_widget(&root).settling);
3528    }
3529
3530    #[test]
3531    fn bottom_overscroll_displaces_and_settles_but_never_fires_refresh() {
3532        let refreshes = Rc::new(Cell::new(0u32));
3533        let refreshes_l = refreshes.clone();
3534        let mut logic = move |_: &mut ()| -> ListView<()> {
3535            let refreshes = refreshes_l.clone();
3536            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3537                .physics(RubberBand::new())
3538                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
3539        };
3540        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3541        let mut state = ();
3542        let window = Size::new(200.0, 200.0);
3543        frame(&mut root, &mut logic, &mut state, window, 0.0);
3544        frame(&mut root, &mut logic, &mut state, window, 16.0);
3545
3546        // Scroll to the very bottom first.
3547        root.event(&mut state, &wheel(1_000_000.0));
3548        frame(&mut root, &mut logic, &mut state, window, 32.0);
3549        let max_offset = list_widget(&root).max_offset();
3550        assert_eq!(list_widget(&root).offset(), max_offset);
3551
3552        // Down, then a drag *upward* crossing the slop takes the gesture over
3553        // (dragging the finger up exposes content past the bottom edge).
3554        root.event(&mut state, &ev(PointerPhase::Down, 200.0));
3555        frame(&mut root, &mut logic, &mut state, window, 48.0);
3556        root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // 40px > slop → takeover
3557        assert_eq!(
3558            list_widget(&root).offset(),
3559            max_offset,
3560            "the takeover move does not itself scroll"
3561        );
3562        frame(&mut root, &mut logic, &mut state, window, 64.0);
3563
3564        // Drag 60px further up past the bottom edge → resisted overscroll,
3565        // the windowing offset pinned at max_offset, no row past the end.
3566        root.event(&mut state, &ev(PointerPhase::Move, 100.0));
3567        let w = list_widget(&root);
3568        assert_eq!(
3569            w.offset(),
3570            max_offset,
3571            "the windowing offset stays pinned at max_offset"
3572        );
3573        assert_eq!(
3574            w.overscroll, 30.0,
3575            "overscroll is the raw excess (60) * OVERSCROLL_RESISTANCE (0.5)"
3576        );
3577        assert_eq!(
3578            *w.window().last().unwrap(),
3579            999,
3580            "no row materializes past the last item"
3581        );
3582
3583        root.event(&mut state, &ev(PointerPhase::Up, 100.0));
3584        let w = list_widget(&root);
3585        assert!(
3586            w.settling,
3587            "a bottom overscroll release settles, never flings"
3588        );
3589        assert_eq!(
3590            refreshes.get(),
3591            0,
3592            "a bottom overscroll never fires refresh"
3593        );
3594
3595        let mut ms = 80.0;
3596        for _ in 0..30 {
3597            frame(&mut root, &mut logic, &mut state, window, ms);
3598            ms += 16.0;
3599            if !list_widget(&root).settling {
3600                break;
3601            }
3602        }
3603        let w = list_widget(&root);
3604        assert!(!w.settling);
3605        assert_eq!(w.overscroll, 0.0);
3606        assert_eq!(w.offset(), max_offset);
3607        assert_eq!(refreshes.get(), 0, "still never fired");
3608    }
3609
3610    #[test]
3611    fn cancel_during_overscroll_never_fires_refresh_and_snaps_back() {
3612        let refreshes = Rc::new(Cell::new(0u32));
3613        let refreshes_l = refreshes.clone();
3614        let mut logic = move |_: &mut ()| -> ListView<()> {
3615            let refreshes = refreshes_l.clone();
3616            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3617                .physics(RubberBand::new())
3618                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
3619        };
3620        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3621        let mut state = ();
3622        let window = Size::new(200.0, 200.0);
3623        frame(&mut root, &mut logic, &mut state, window, 0.0);
3624        frame(&mut root, &mut logic, &mut state, window, 16.0);
3625
3626        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3627        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
3628        frame(&mut root, &mut logic, &mut state, window, 32.0);
3629        root.event(&mut state, &ev(PointerPhase::Move, 290.0)); // past trigger
3630        assert_eq!(list_widget(&root).overscroll, -100.0);
3631
3632        // A Cancel (gesture steal) must not fire on_refresh_release and snaps
3633        // the overscroll away with no settle animation.
3634        root.event(&mut state, &ev(PointerPhase::Cancel, 290.0));
3635        let w = list_widget(&root);
3636        assert_eq!(
3637            refreshes.get(),
3638            0,
3639            "Cancel never fires the refresh callback"
3640        );
3641        assert_eq!(
3642            w.overscroll, 0.0,
3643            "Cancel snaps the surface back into range"
3644        );
3645        assert!(!w.settling);
3646        assert_eq!(w.offset(), 0.0);
3647    }
3648
3649    #[test]
3650    fn wheel_never_overscrolls_past_either_edge_and_starts_no_settle() {
3651        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3652        let mut state = ();
3653        let window = Size::new(200.0, 200.0);
3654        let mut logic = overscroll_logic(1000);
3655        frame(&mut root, &mut logic, &mut state, window, 0.0);
3656        frame(&mut root, &mut logic, &mut state, window, 16.0);
3657
3658        // A large negative wheel delta at the top stays hard-clamped at 0.
3659        root.event(&mut state, &wheel(-5000.0));
3660        let w = list_widget(&root);
3661        assert_eq!(w.offset(), 0.0);
3662        assert_eq!(w.overscroll, 0.0, "wheel input never overscrolls the top");
3663        assert!(!w.settling, "wheel input starts no settle animation");
3664
3665        // A huge positive wheel delta at the bottom stays hard-clamped too.
3666        root.event(&mut state, &wheel(1_000_000.0));
3667        let w = list_widget(&root);
3668        assert_eq!(w.offset(), w.max_offset());
3669        assert_eq!(
3670            w.overscroll, 0.0,
3671            "wheel input never overscrolls the bottom"
3672        );
3673        assert!(!w.settling);
3674    }
3675
3676    #[test]
3677    fn near_start_and_refresh_fire_from_one_continuous_drag_sequence() {
3678        // Criterion 4: `on_near_start` fires on approach, then continuing the
3679        // same gesture past the top into overscroll and releasing fires
3680        // `on_refresh_release` too — two independent signals, one gesture.
3681        let loads = Rc::new(Cell::new(0u32));
3682        let refreshes = Rc::new(Cell::new(0u32));
3683        let (loads_l, refreshes_l) = (loads.clone(), refreshes.clone());
3684        let mut logic = move |_: &mut ()| -> ListView<()> {
3685            let (loads, refreshes) = (loads_l.clone(), refreshes_l.clone());
3686            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
3687                .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
3688                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
3689        };
3690        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3691        let mut state = ();
3692        let window = Size::new(200.0, 200.0);
3693        frame(&mut root, &mut logic, &mut state, window, 0.0);
3694        frame(&mut root, &mut logic, &mut state, window, 16.0);
3695
3696        // Start scrolled away from the top (150 > threshold, so approaching it
3697        // is a real edge crossing, not a trivial already-there state).
3698        root.event(&mut state, &wheel(150.0));
3699        frame(&mut root, &mut logic, &mut state, window, 32.0);
3700        assert_eq!(list_widget(&root).offset(), 150.0);
3701        assert_eq!(loads.get(), 0);
3702
3703        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3704        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
3705        frame(&mut root, &mut logic, &mut state, window, 48.0);
3706
3707        // Drag down 80px: offset 150 -> 70, inside the near-start threshold →
3708        // fires once, in range (no overscroll yet).
3709        root.event(&mut state, &ev(PointerPhase::Move, 170.0));
3710        let w = list_widget(&root);
3711        assert_eq!(w.offset(), 70.0);
3712        assert_eq!(w.overscroll, 0.0);
3713        assert_eq!(loads.get(), 1, "near-start fires on approach");
3714        assert_eq!(refreshes.get(), 0, "still in range, no refresh yet");
3715
3716        // Keep dragging the same gesture on down past the top, deep into
3717        // overscroll past the refresh trigger.
3718        root.event(&mut state, &ev(PointerPhase::Move, 500.0));
3719        let w = list_widget(&root);
3720        assert_eq!(w.offset(), 0.0, "windowing offset stays clamped");
3721        assert!(
3722            w.overscroll < -64.0,
3723            "well past the refresh trigger (REFRESH_TRIGGER_PX)"
3724        );
3725        assert_eq!(loads.get(), 1, "near-start does not re-fire mid-overscroll");
3726
3727        root.event(&mut state, &ev(PointerPhase::Up, 500.0));
3728        assert_eq!(
3729            refreshes.get(),
3730            1,
3731            "refresh fires on release, from the same gesture"
3732        );
3733        assert_eq!(loads.get(), 1, "and near-start's single fire still stands");
3734    }
3735
3736    // --- (8c) A converging `max_offset` during content growth (rule 5:
3737    //      overscroll resistance always reads the *current* max_offset, never
3738    //      one cached at takeover — the mechanism the *Variable extents*
3739    //      docs section relies on; exercised here at the uniform level, since
3740    //      `apply_drag_offset` reads `max_offset()` fresh regardless of
3741    //      mode). ---
3742
3743    #[test]
3744    fn overscroll_resistance_tracks_a_max_offset_that_grows_mid_drag() {
3745        // A short list (10 rows * 50px = 500 content over a 200px viewport,
3746        // max_offset = 300) scrolled to the bottom, then overscrolled past it.
3747        let mut logic = rubber_band_logic(10);
3748        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3749        let mut state = ();
3750        let window = Size::new(200.0, 200.0);
3751        frame(&mut root, &mut logic, &mut state, window, 0.0);
3752        frame(&mut root, &mut logic, &mut state, window, 16.0);
3753
3754        root.event(&mut state, &wheel(1_000_000.0));
3755        frame(&mut root, &mut logic, &mut state, window, 32.0);
3756        assert_eq!(list_widget(&root).max_offset(), 300.0);
3757        assert_eq!(list_widget(&root).offset(), 300.0);
3758
3759        root.event(&mut state, &ev(PointerPhase::Down, 200.0));
3760        root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
3761        frame(&mut root, &mut logic, &mut state, window, 48.0);
3762
3763        // Drag 60px further up past the (still 300px) bottom edge.
3764        root.event(&mut state, &ev(PointerPhase::Move, 100.0));
3765        let w = list_widget(&root);
3766        assert_eq!(w.offset(), 300.0);
3767        assert_eq!(
3768            w.overscroll, 30.0,
3769            "resisted against the old max_offset (300)"
3770        );
3771
3772        // Grow the content mid-drag (still `w.scrolling`): 20 rows now, content
3773        // 1000px, max_offset 800 — a rebuild interleaved into the same drag.
3774        logic = rubber_band_logic(20);
3775        frame(&mut root, &mut logic, &mut state, window, 64.0);
3776        assert_eq!(list_widget(&root).max_offset(), 800.0);
3777
3778        // The *same* continuing drag now reads the new, larger max_offset: 20
3779        // more px up lands the drag position back in range. The surface was
3780        // *showing* content y 330 (offset 300 + the 30px it was displaced by),
3781        // so the grown extent makes that 330 a real in-range offset and the
3782        // next 20px of finger takes it to 350 — the drag position is a
3783        // physics-mapped accumulator, so what the resistance already swallowed
3784        // is not handed back when the content grows under the finger.
3785        root.event(&mut state, &ev(PointerPhase::Move, 80.0));
3786        let w = list_widget(&root);
3787        assert_eq!(
3788            w.overscroll, 0.0,
3789            "the grown content absorbed what used to be overscroll"
3790        );
3791        assert_eq!(
3792            w.offset(),
3793            350.0,
3794            "the windowing offset advanced by exactly the finger delta, now in range"
3795        );
3796    }
3797
3798    // --- (8c) The physics seam: the default `RubberBand` install produces the
3799    //      same numbers this widget has always produced, and `edge_pull`
3800    //      tracks the displacement exactly while nothing is rejected (the
3801    //      `ScrollView` twins of these two live in `scroll.rs`). ---
3802
3803    #[test]
3804    fn rubber_band_drag_mapping_matches_legacy_math() {
3805        let mut logic = rubber_band_logic(1000);
3806        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3807        let mut state = ();
3808        let window = Size::new(200.0, 200.0);
3809        frame(&mut root, &mut logic, &mut state, window, 0.0);
3810        frame(&mut root, &mut logic, &mut state, window, 16.0);
3811
3812        // In range: the drag delta passes through the physics untouched, into
3813        // the windowing offset, with no displacement at all.
3814        root.event(&mut state, &ev(PointerPhase::Down, 200.0));
3815        root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
3816        root.event(&mut state, &ev(PointerPhase::Move, 100.0)); // 60px up
3817        let w = list_widget(&root);
3818        assert_eq!(w.offset(), 60.0, "an in-range drag maps one-for-one");
3819        assert_eq!(w.overscroll, 0.0);
3820        assert_eq!(w.edge_pull, 0.0);
3821        root.event(&mut state, &ev(PointerPhase::Cancel, 100.0));
3822
3823        // Past the top: half the raw excess shows, the windowing offset pinned.
3824        root.event(&mut state, &wheel(-1_000_000.0)); // back to the top edge
3825        frame(&mut root, &mut logic, &mut state, window, 32.0);
3826        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3827        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
3828        root.event(&mut state, &ev(PointerPhase::Move, 110.0)); // 20px past the top
3829        let w = list_widget(&root);
3830        assert_eq!(w.offset(), 0.0, "the windowing offset never leaves range");
3831        assert_eq!(w.overscroll, -10.0, "raw excess (-20) halved by resistance");
3832        root.event(&mut state, &ev(PointerPhase::Cancel, 110.0));
3833
3834        // Past the bottom: the same rule against a fresh `max_offset`.
3835        root.event(&mut state, &wheel(1_000_000.0));
3836        frame(&mut root, &mut logic, &mut state, window, 32.0);
3837        let max_offset = list_widget(&root).max_offset();
3838        root.event(&mut state, &ev(PointerPhase::Down, 200.0));
3839        root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // takeover
3840        root.event(&mut state, &ev(PointerPhase::Move, 100.0)); // 60px past the bottom
3841        let w = list_widget(&root);
3842        assert_eq!(w.offset(), max_offset);
3843        assert_eq!(w.overscroll, 30.0, "raw excess (60) halved by resistance");
3844    }
3845
3846    #[test]
3847    fn edge_pull_equals_overscroll_under_rubber_band() {
3848        let mut logic = rubber_band_logic(1000);
3849        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3850        let mut state = ();
3851        let window = Size::new(200.0, 200.0);
3852        frame(&mut root, &mut logic, &mut state, window, 0.0);
3853        frame(&mut root, &mut logic, &mut state, window, 16.0);
3854
3855        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3856        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
3857        root.event(&mut state, &ev(PointerPhase::Move, 110.0)); // 20px past the top
3858        let w = list_widget(&root);
3859        assert_eq!(w.overscroll, -10.0);
3860        assert_eq!(
3861            w.edge_pull, -10.0,
3862            "nothing rejected → the pull is the displacement, same sign"
3863        );
3864
3865        // Release, then settle: both decay together and both reach zero.
3866        root.event(&mut state, &ev(PointerPhase::Up, 110.0));
3867        assert!(list_widget(&root).settling);
3868        let mut ms = 32.0;
3869        let mut eased = false;
3870        for _ in 0..40 {
3871            frame(&mut root, &mut logic, &mut state, window, ms);
3872            ms += 16.0;
3873            let w = list_widget(&root);
3874            assert_eq!(
3875                w.edge_pull, w.overscroll,
3876                "the pull tracks the displacement through the whole settle"
3877            );
3878            if w.overscroll < 0.0 && w.overscroll > -10.0 {
3879                eased = true;
3880            }
3881            if !w.settling {
3882                break;
3883            }
3884        }
3885        assert!(eased, "the settle eased through intermediate values");
3886        let w = list_widget(&root);
3887        assert!(!w.settling, "the settle terminates");
3888        assert_eq!(w.overscroll, 0.0);
3889        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
3890    }
3891
3892    // --- The platform default (`physics::default_physics`): bouncing on this
3893    //      host, clamping on Android. The `ScrollView` twins of this group
3894    //      live in `scroll.rs`; these pin that the windowing/displacement
3895    //      split reaches the same numbers through this widget's own drag
3896    //      path. ---
3897
3898    fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
3899        assert!(
3900            (actual - expected).abs() < epsilon,
3901            "{what}: {actual} is not within {epsilon} of {expected}"
3902        );
3903    }
3904
3905    #[test]
3906    fn a_fresh_list_installs_the_platform_default() {
3907        let mut logic = overscroll_logic(1000);
3908        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3909        frame(&mut root, &mut logic, &mut (), Size::new(200.0, 200.0), 0.0);
3910        let w = list_widget(&root);
3911        assert_eq!(
3912            format!("{:?}", w.physics),
3913            format!("{:?}", crate::physics::default_physics())
3914        );
3915        assert_eq!(w.effect, crate::physics::default_overscroll_effect());
3916    }
3917
3918    #[test]
3919    fn default_drag_tension_tightens_with_depth() {
3920        let mut logic = overscroll_logic(1000);
3921        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
3922        let mut state = ();
3923        let window = Size::new(200.0, 200.0);
3924        frame(&mut root, &mut logic, &mut state, window, 0.0);
3925        frame(&mut root, &mut logic, &mut state, window, 16.0);
3926
3927        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
3928        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
3929        frame(&mut root, &mut logic, &mut state, window, 32.0);
3930
3931        // 20px past the top from zero depth: 20 · 0.52 = 10.4 displaced, and
3932        // the windowing offset still pinned at 0.
3933        root.event(&mut state, &ev(PointerPhase::Move, 110.0));
3934        let first = list_widget(&root).overscroll;
3935        assert_eq!(list_widget(&root).offset(), 0.0);
3936        assert_close(
3937            first,
3938            -20.0 * DecelerationRate::NORMAL_FRICTION,
3939            1e-12,
3940            "the first past-edge move",
3941        );
3942
3943        // 20px more, now 10.4px deep in a 200px viewport: the factor tightens
3944        // to 0.52·(1 − 0.052)² = 0.46732608, adding 9.3465216 for a total of
3945        // 19.7465216.
3946        root.event(&mut state, &ev(PointerPhase::Move, 130.0));
3947        let w = list_widget(&root);
3948        assert_close(w.overscroll, -19.746_521_6, 1e-9, "the accumulated pull");
3949        assert!(
3950            (w.overscroll - first).abs() < first.abs(),
3951            "the deeper pull displaces less per raw px"
3952        );
3953        assert_eq!(w.offset(), 0.0, "…and the windowing offset never moves");
3954        assert_eq!(w.window()[0], 0, "no row materializes before index 0");
3955    }
3956
3957    /// The signed start velocity of whatever post-release motion the last `Up`
3958    /// produced — the physics-supplied curve's own, the legacy fling's, or
3959    /// `0.0` for a release that started no motion at all. The `ScrollView`
3960    /// twin of this helper reads the same two fields.
3961    fn release_velocity(w: &ListViewWidget) -> f64 {
3962        match w.ballistic.as_ref() {
3963            Some(state) => state.sim.dx(0.0),
3964            None => w.fling.unwrap_or(0.0),
3965        }
3966    }
3967
3968    /// The `ScrollView` twin of this test lives in `scroll.rs`, beside
3969    /// `default_carried_momentum_compounds_a_refling` — the same-direction pin
3970    /// this one is the mirror image of.
3971    #[test]
3972    fn reverse_refling_keeps_the_fingers_velocity() {
3973        // A bare list (no rows materialized — this pins release velocity, not
3974        // windowing), parked mid-content so neither edge is in play.
3975        let mut w = ListViewWidget::new(1000, 50.0);
3976        w.viewport = Size::new(200.0, 200.0);
3977        dispatch_list(&mut w, &wheel(1000.0), 0.0);
3978        assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
3979
3980        // A downward fling: 50px of finger travel up over 32ms.
3981        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
3982        dispatch_list(&mut w, &ev(PointerPhase::Move, 75.0), 16.0); // takeover
3983        dispatch_list(&mut w, &ev(PointerPhase::Move, 50.0), 32.0);
3984        dispatch_list(&mut w, &ev(PointerPhase::Up, 50.0), 32.0);
3985        assert_close(release_velocity(&w), 1562.5, 1e-9, "the first release");
3986
3987        // The finger lands on that live curve and flicks back the other way,
3988        // just as fast. The interrupted motion's momentum must not be added to
3989        // a release pointing the other way — it would cancel the flick out (or
3990        // reverse it), and the list would ignore the finger entirely.
3991        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
3992        dispatch_list(&mut w, &ev(PointerPhase::Move, 125.0), 64.0); // takeover
3993        dispatch_list(&mut w, &ev(PointerPhase::Move, 150.0), 80.0);
3994        dispatch_list(&mut w, &ev(PointerPhase::Up, 150.0), 80.0);
3995        assert_close(
3996            release_velocity(&w),
3997            -1562.5,
3998            1e-9,
3999            "the reverse re-fling runs at the finger's own velocity",
4000        );
4001        assert!(
4002            w.ballistic.is_some(),
4003            "…as a real ballistic curve, not a stalled remnant"
4004        );
4005    }
4006
4007    /// The `ScrollView` twins of this pair live in `scroll.rs`
4008    /// (`momentum_retain_threshold_refuses_a_weak_refling`): the retain gate
4009    /// compares a release against the physics' **mapped** share of the
4010    /// interrupted velocity, not the raw interrupted speed. Interrupted at
4011    /// 1000 px/s, `Bouncing::new().carried_momentum(1000.0)` maps to ~649.7,
4012    /// putting the retain threshold at ~324.8 — well under the raw-carried
4013    /// threshold (500) the pre-fix gate used.
4014    #[test]
4015    fn momentum_retain_threshold_refuses_a_weak_refling() {
4016        let mut w = ListViewWidget::new(1000, 50.0);
4017        w.viewport = Size::new(200.0, 200.0);
4018        dispatch_list(&mut w, &wheel(1000.0), 0.0);
4019        assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
4020
4021        // Interrupted motion at 1000 px/s.
4022        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4023        dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
4024        dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
4025        dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
4026        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
4027
4028        let mapped = Bouncing::new().carried_momentum(1000.0);
4029        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
4030        assert!(
4031            (300.0..350.0).contains(&threshold),
4032            "the fixture's release values must straddle the threshold: {threshold}"
4033        );
4034
4035        // Same-direction re-flick at 300 px/s — under the mapped threshold.
4036        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
4037        dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
4038        dispatch_list(&mut w, &ev(PointerPhase::Move, 70.0), 148.0); // 30px / 100ms → 300 px/s
4039        dispatch_list(&mut w, &ev(PointerPhase::Up, 70.0), 148.0);
4040        assert_close(
4041            release_velocity(&w),
4042            300.0,
4043            1e-9,
4044            "a release under the mapped threshold carries nothing forward",
4045        );
4046    }
4047
4048    /// The strong-side twin of `momentum_retain_threshold_refuses_a_weak_refling`:
4049    /// a release over the same mapped threshold carries `mapped` forward
4050    /// exactly, pre-clamp.
4051    #[test]
4052    fn momentum_retain_threshold_carries_a_strong_refling() {
4053        let mut w = ListViewWidget::new(1000, 50.0);
4054        w.viewport = Size::new(200.0, 200.0);
4055        dispatch_list(&mut w, &wheel(1000.0), 0.0);
4056        assert_eq!(w.offset(), 1000.0, "the fixture parked mid-content");
4057
4058        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
4059        dispatch_list(&mut w, &ev(PointerPhase::Move, 78.0), 16.0); // takeover
4060        dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
4061        dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
4062        assert_close(release_velocity(&w), 1000.0, 1e-9, "the interrupted motion");
4063
4064        let mapped = Bouncing::new().carried_momentum(1000.0);
4065        let threshold = MOMENTUM_RETAIN_VELOCITY_THRESHOLD_FACTOR * mapped;
4066        assert!(
4067            (300.0..350.0).contains(&threshold),
4068            "the fixture's release values must straddle the threshold: {threshold}"
4069        );
4070
4071        // Same-direction re-flick at 350 px/s — over the mapped threshold.
4072        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 48.0);
4073        dispatch_list(&mut w, &ev(PointerPhase::Move, 80.0), 64.0); // takeover
4074        dispatch_list(&mut w, &ev(PointerPhase::Move, 65.0), 148.0); // 35px / 100ms → 350 px/s
4075        dispatch_list(&mut w, &ev(PointerPhase::Up, 65.0), 148.0);
4076        assert_close(
4077            release_velocity(&w),
4078            350.0 + mapped,
4079            1e-9,
4080            "a release over the mapped threshold carries `mapped` forward exactly",
4081        );
4082    }
4083
4084    // --- Pull-to-refresh under both shipped defaults — the twins of
4085    //      `scroll.rs`'s pair, over this widget's own `edge_pull`. ---
4086
4087    #[test]
4088    fn refresh_trigger_under_the_bouncing_default() {
4089        let refreshes = Rc::new(Cell::new(0u32));
4090        let refreshes_l = refreshes.clone();
4091        let mut logic = move |_: &mut ()| -> ListView<()> {
4092            let refreshes = refreshes_l.clone();
4093            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4094                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4095        };
4096        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4097        let mut state = ();
4098        let window = Size::new(200.0, 200.0);
4099        frame(&mut root, &mut logic, &mut state, window, 0.0);
4100        frame(&mut root, &mut logic, &mut state, window, 16.0);
4101
4102        // 110px of raw pull at zero depth maps to 57.2 — under the trigger.
4103        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4104        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4105        frame(&mut root, &mut logic, &mut state, window, 32.0);
4106        root.event(&mut state, &ev(PointerPhase::Move, 200.0));
4107        let w = list_widget(&root);
4108        assert_close(w.edge_pull, -57.2, 1e-9, "under the trigger");
4109        assert_eq!(
4110            w.edge_pull, w.overscroll,
4111            "a bouncing surface rejects nothing, so the pull IS the displacement"
4112        );
4113        root.event(&mut state, &ev(PointerPhase::Up, 200.0));
4114        assert_eq!(refreshes.get(), 0, "release under the trigger never fires");
4115
4116        // 150px of raw pull maps to 78.0 — past it.
4117        root.event(&mut state, &ev(PointerPhase::Cancel, 200.0));
4118        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4119        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4120        frame(&mut root, &mut logic, &mut state, window, 48.0);
4121        root.event(&mut state, &ev(PointerPhase::Move, 240.0));
4122        let w = list_widget(&root);
4123        assert_close(w.edge_pull, -78.0, 1e-9, "past the trigger");
4124        assert!(crossed_refresh_trigger(w.edge_pull));
4125        assert_eq!(refreshes.get(), 0, "no fire before release");
4126        root.event(&mut state, &ev(PointerPhase::Up, 240.0));
4127        assert_eq!(refreshes.get(), 1, "release past the trigger fires once");
4128    }
4129
4130    #[test]
4131    fn refresh_trigger_under_a_clamping_physics() {
4132        // Android's default, simulated on the host.
4133        let refreshes = Rc::new(Cell::new(0u32));
4134        let refreshes_l = refreshes.clone();
4135        let mut logic = move |_: &mut ()| -> ListView<()> {
4136            let refreshes = refreshes_l.clone();
4137            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i)))
4138                .physics(Clamping::new())
4139                .on_refresh_release(move |_: &mut ()| refreshes.set(refreshes.get() + 1))
4140        };
4141        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4142        let mut state = ();
4143        let window = Size::new(200.0, 200.0);
4144        frame(&mut root, &mut logic, &mut state, window, 0.0);
4145        frame(&mut root, &mut logic, &mut state, window, 16.0);
4146
4147        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4148        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4149        frame(&mut root, &mut logic, &mut state, window, 32.0);
4150        root.event(&mut state, &ev(PointerPhase::Move, 140.0));
4151        let w = list_widget(&root);
4152        assert_eq!(w.offset(), 0.0, "a clamping surface never displaces");
4153        assert_eq!(w.overscroll, 0.0);
4154        assert_eq!(w.edge_pull, -50.0, "…but reports the whole rejected pull");
4155        root.event(&mut state, &ev(PointerPhase::Up, 140.0));
4156        assert_eq!(refreshes.get(), 0, "50px of raw pull is under the trigger");
4157
4158        root.event(&mut state, &ev(PointerPhase::Cancel, 140.0));
4159        root.event(&mut state, &ev(PointerPhase::Down, 50.0));
4160        root.event(&mut state, &ev(PointerPhase::Move, 90.0)); // takeover
4161        frame(&mut root, &mut logic, &mut state, window, 48.0);
4162        root.event(&mut state, &ev(PointerPhase::Move, 190.0));
4163        let w = list_widget(&root);
4164        assert_eq!(w.offset(), 0.0);
4165        assert_eq!(w.overscroll, 0.0);
4166        assert_eq!(w.edge_pull, -100.0, "100px of raw pull, none of it shown");
4167        assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4168        root.event(&mut state, &ev(PointerPhase::Up, 190.0));
4169        assert_eq!(refreshes.get(), 1, "past 64px of raw pull, it fires once");
4170        assert!(
4171            list_widget(&root).settling,
4172            "the rejected pull settles rather than springs — nothing displaced"
4173        );
4174
4175        let mut ms = 64.0;
4176        for _ in 0..40 {
4177            frame(&mut root, &mut logic, &mut state, window, ms);
4178            ms += 16.0;
4179            if !list_widget(&root).settling {
4180                break;
4181            }
4182        }
4183        let w = list_widget(&root);
4184        assert!(!w.settling, "the settle terminated");
4185        assert_eq!(w.edge_pull, 0.0, "…leaving no pull for a stretch to paint");
4186    }
4187
4188    // --- (07) The public builder surface: `.physics(...)` — the ListView twin
4189    //      of `scroll.rs`'s own group of the same name. ---
4190
4191    #[test]
4192    fn physics_builder_installs_custom_physics() {
4193        let window = Size::new(200.0, 200.0);
4194
4195        // A ListView built with `.physics(NeverScrollable::new())`: a drag
4196        // past slop does not scroll.
4197        let mut never_logic = move |_: &mut ()| -> ListView<()> {
4198            list_view(1000, 50.0, |i| any::<(), _>(gen_stub(i))).physics(NeverScrollable::new())
4199        };
4200        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4201        let mut state = ();
4202        frame(&mut root, &mut never_logic, &mut state, window, 0.0);
4203        frame(&mut root, &mut never_logic, &mut state, window, 16.0);
4204        root.event(&mut state, &ev(PointerPhase::Down, 200.0));
4205        root.event(&mut state, &ev(PointerPhase::Move, 160.0)); // would cross slop
4206        root.event(&mut state, &ev(PointerPhase::Move, 100.0));
4207        let w = list_widget(&root);
4208        assert_eq!(w.offset(), 0.0, "NeverScrollable must refuse the drag");
4209        assert!(
4210            !w.scrolling,
4211            "NeverScrollable must never take the gesture over"
4212        );
4213
4214        // A default-built twin (no `.physics(...)` call) still scrolls
4215        // normally under `RubberBand` — same numbers as
4216        // `rubber_band_drag_mapping_matches_legacy_math`.
4217        let mut default_logic = overscroll_logic(1000);
4218        let mut default_root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4219        let mut default_state = ();
4220        frame(
4221            &mut default_root,
4222            &mut default_logic,
4223            &mut default_state,
4224            window,
4225            0.0,
4226        );
4227        frame(
4228            &mut default_root,
4229            &mut default_logic,
4230            &mut default_state,
4231            window,
4232            16.0,
4233        );
4234        default_root.event(&mut default_state, &ev(PointerPhase::Down, 200.0));
4235        default_root.event(&mut default_state, &ev(PointerPhase::Move, 160.0));
4236        default_root.event(&mut default_state, &ev(PointerPhase::Move, 100.0));
4237        assert_eq!(
4238            list_widget(&default_root).offset(),
4239            60.0,
4240            "the default twin scrolls normally"
4241        );
4242    }
4243
4244    // --- (8d) Variable-extent overscroll: the same bounds hold when rows
4245    //      size themselves (see the *Variable extents* module docs section
4246    //      for `estimated_item_extent`). ---
4247
4248    #[test]
4249    fn variable_extent_top_overscroll_never_materializes_before_index_zero() {
4250        let fx = VarRows::new(tall_ids(60));
4251        let mut logic = fx.logic_rubber_band();
4252        let mut root = converged_var(&mut logic, &fx);
4253        assert_eq!(list_widget(&root).window()[0], 0);
4254
4255        root.event(&mut (), &ev(PointerPhase::Down, 50.0));
4256        root.event(&mut (), &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
4257        var_frame(&mut root, &mut logic, &fx, 100.0);
4258
4259        // Drag past the top edge.
4260        root.event(&mut (), &ev(PointerPhase::Move, 150.0));
4261        let w = list_widget(&root);
4262        assert_eq!(
4263            w.offset(),
4264            0.0,
4265            "the windowing offset never leaves [0, max] in variable-extent mode either"
4266        );
4267        assert!(w.overscroll < 0.0, "a past-top drag overscrolls");
4268        assert_eq!(w.window()[0], 0, "no row materializes before index 0");
4269
4270        root.event(&mut (), &ev(PointerPhase::Up, 150.0));
4271        assert!(
4272            list_widget(&root).settling,
4273            "an overscrolled release settles in variable-extent mode too"
4274        );
4275
4276        // Pump frames until the settle completes.
4277        for n in 0..60 {
4278            var_frame(&mut root, &mut logic, &fx, 116.0 + 16.0 * n as f64);
4279            if !list_widget(&root).settling {
4280                break;
4281            }
4282        }
4283        let w = list_widget(&root);
4284        assert!(!w.settling);
4285        assert_eq!(w.overscroll, 0.0);
4286        assert_eq!(w.offset(), 0.0);
4287    }
4288
4289    // --- (8e) Nested scrolling: innermost-wins arbitration with this widget as
4290    //     the OUTER surface, over a row that owns a scroll of its own. The
4291    //     mirror case (a nested `ListView` under a `ScrollView`) lives beside
4292    //     the seam itself, in `scroll.rs`. See the module docs' *Nested
4293    //     scrolling*. ---
4294
4295    /// A bare list for the nested-scroll fixtures: `count` rows of `extent` px
4296    /// in a `viewport_h`-tall viewport, no rows materialized yet ([`wire_row`]
4297    /// installs the one that matters).
4298    fn nested_list(count: usize, extent: f64, viewport_h: f64) -> ListViewWidget {
4299        let mut w = ListViewWidget::new(count, extent);
4300        w.viewport = Size::new(200.0, viewport_h);
4301        w
4302    }
4303
4304    /// Build and lay out the nested `ScrollView` a row hosts: a
4305    /// `viewport_h`-tall viewport over 1000px of plain content. Laid out tight
4306    /// because an enclosing scroll surface hands its child unbounded height,
4307    /// which would leave this one viewport == content with nothing to scroll —
4308    /// a real row's own extent is what bounds it, and [`wire_row`] re-applies
4309    /// exactly that.
4310    fn nested_scroll(viewport_h: f64) -> ScrollWidget {
4311        let view: crate::scroll::ScrollView<()> =
4312            crate::scroll::scroll_view(crate::test_support::leaf(200.0, 1000.0));
4313        let mut counter = 0u64;
4314        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
4315        let mut lctx = LayoutCtx::new();
4316        w.layout(
4317            &mut lctx,
4318            &BoxConstraints::tight(Size::new(200.0, viewport_h)),
4319        );
4320        w
4321    }
4322
4323    /// Park a nested surface at `offset` px with a wheel scroll (hard-clamped,
4324    /// no overscroll and no gesture state) — how a real one reaches a
4325    /// mid-content position.
4326    fn park_scroll(w: &mut ScrollWidget, viewport_h: f64, offset: f64) {
4327        let mut unit = ();
4328        let sa: &mut dyn Any = &mut unit;
4329        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(200.0, viewport_h));
4330        w.event(
4331            &mut ctx,
4332            &InputEvent::Scroll {
4333                position: Point::new(10.0, 10.0),
4334                delta: ScrollDelta::Pixels(0.0, offset),
4335            },
4336        );
4337        assert_eq!(w.offset(), offset, "the fixture parked where it meant to");
4338    }
4339
4340    /// Wire `row` in as the list's single materialized row for item `index`,
4341    /// laid out tight at the list's own item extent so the pod carries the
4342    /// bounds `route_event` hit-tests against — and so the nested surface gets
4343    /// the bounded viewport a real row layout hands it.
4344    fn wire_row(outer: &mut ListViewWidget, index: usize, row: Box<dyn Widget>) {
4345        let mut pod = ChildPod::new(row);
4346        let mut lctx = LayoutCtx::new();
4347        pod.layout_child(
4348            &mut lctx,
4349            &BoxConstraints::tight(Size::new(200.0, outer.item_extent)),
4350        );
4351        outer.children = vec![pod];
4352        outer.keys = vec![index];
4353        outer.sync_child_origins();
4354    }
4355
4356    /// The nested surface [`wire_row`] installed as the list's one row.
4357    fn row_scroll(outer: &ListViewWidget) -> &ScrollWidget {
4358        (outer.children[0].widget() as &dyn Any)
4359            .downcast_ref::<ScrollWidget>()
4360            .expect("the fixture wired a ScrollWidget row")
4361    }
4362
4363    fn dispatch_list(w: &mut ListViewWidget, e: &InputEvent, t_ms: f64) {
4364        let mut unit = ();
4365        let sa: &mut dyn Any = &mut unit;
4366        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
4367        w.event_at(&mut ctx, e, t_ms);
4368    }
4369
4370    #[test]
4371    fn list_view_outer_defers_when_the_nested_surface_can_consume() {
4372        // A 200px viewport scrolled to row 2 (content y 400..600, so the row
4373        // sits exactly over the viewport), that row hosting a scroll surface
4374        // parked mid-content — at neither of its own edges, so its claim comes
4375        // from actual room rather than a displacement-allowing physics.
4376        let mut outer = nested_list(10, 200.0, 200.0);
4377        outer.offset = 400.0;
4378        let mut row = nested_scroll(200.0);
4379        park_scroll(&mut row, 200.0, 300.0);
4380        wire_row(&mut outer, 2, Box::new(row));
4381
4382        dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
4383        assert!(
4384            outer.inner_at_down.registered,
4385            "the row's scroll surface reported itself on the routed Down"
4386        );
4387        assert!(outer.inner_at_down.can_consume_up_drag);
4388
4389        // 50px of finger-up drag, past the slop: the list stands down.
4390        dispatch_list(&mut outer, &ev(PointerPhase::Move, 50.0), 16.0);
4391        assert!(outer.deferring);
4392        assert!(!outer.scrolling, "the list never took the gesture over");
4393        assert_eq!(outer.offset, 400.0);
4394        assert!(
4395            outer.children[0].is_active(),
4396            "no takeover Cancel went out — the row keeps its capture"
4397        );
4398
4399        // The rest of the drag lands in the row, not in the list.
4400        dispatch_list(&mut outer, &ev(PointerPhase::Move, 10.0), 32.0);
4401        assert_eq!(outer.offset, 400.0, "the list still has not moved");
4402        assert_eq!(outer.overscroll, 0.0);
4403        assert_eq!(
4404            row_scroll(&outer).offset(),
4405            340.0,
4406            "the nested surface consumed the 40px"
4407        );
4408    }
4409
4410    #[test]
4411    fn list_view_outer_takes_over_when_the_nested_surface_is_pinned() {
4412        let mut outer = nested_list(10, 200.0, 200.0);
4413        outer.offset = 400.0;
4414        let mut row = nested_scroll(200.0);
4415        // At its own top under a physics that rejects every past-edge
4416        // proposal: a downward drag has nothing to do there.
4417        row.physics = Rc::new(crate::physics::parity::Clamping::new());
4418        wire_row(&mut outer, 2, Box::new(row));
4419
4420        dispatch_list(&mut outer, &ev(PointerPhase::Down, 100.0), 0.0);
4421        assert!(outer.inner_at_down.registered);
4422        assert!(!outer.inner_at_down.can_consume_down_drag);
4423
4424        // 40px down, past the slop: the list takes over exactly as it always
4425        // has, cancelling the row on the way.
4426        dispatch_list(&mut outer, &ev(PointerPhase::Move, 140.0), 16.0);
4427        assert!(outer.scrolling);
4428        assert!(!outer.deferring);
4429        assert!(
4430            !outer.children[0].is_active(),
4431            "the takeover Cancel released the row's capture"
4432        );
4433
4434        dispatch_list(&mut outer, &ev(PointerPhase::Move, 200.0), 32.0);
4435        assert_eq!(outer.offset, 340.0, "the list consumed the 60px of drag");
4436        assert_eq!(outer.overscroll, 0.0, "…in range, so no displacement");
4437        assert_eq!(
4438            row_scroll(&outer).offset(),
4439            0.0,
4440            "the nested surface never moved"
4441        );
4442    }
4443
4444    // --- (9) Keyed reconciliation: row state follows the stable key. ---
4445
4446    /// Row height and viewport used by every keyed test: 200 / 50 = 4 visible
4447    /// rows + 2×[`BUFFER`], so a list of ≤ 6 rows materializes whole and a longer
4448    /// one virtualizes.
4449    const ROW_EXTENT: f64 = 50.0;
4450    const KEYED_WINDOW: Size = Size::new(200.0, 200.0);
4451
4452    /// What a keyed row reports about itself: which row id each live widget
4453    /// painted, and which rows were torn down.
4454    #[derive(Default)]
4455    struct RowLog {
4456        /// row id -> the build generation of the widget that painted it.
4457        painted: RefCell<HashMap<u64, u64>>,
4458        /// row ids whose pods were torn down, in teardown order.
4459        torn: RefCell<Vec<u64>>,
4460    }
4461
4462    impl RowLog {
4463        /// This frame's `row id -> generation` map (the paint log is cleared at
4464        /// the top of every keyed frame, so it describes exactly one frame).
4465        fn painted(&self) -> HashMap<u64, u64> {
4466            self.painted.borrow().clone()
4467        }
4468
4469        fn torn(&self) -> Vec<u64> {
4470            self.torn.borrow().clone()
4471        }
4472    }
4473
4474    /// A keyed-row fixture. The row carries a stable `id`; its widget is stamped
4475    /// with a generation from a shared counter (like [`GenView`]) so a relocated —
4476    /// i.e. state-preserving — row keeps its stamp while a rebuilt-fresh row gets
4477    /// a new one, and its teardown is recorded.
4478    ///
4479    /// This is the host-side stand-in for a hosted `Component`: the generation is
4480    /// the retained per-row state, and `View::teardown` is exactly the path a
4481    /// hosted component's owner disposal rides on (`ComponentWidget::teardown`),
4482    /// so "the removed row's pod was disposed" is observable here without pulling
4483    /// the reactive runtime into this signal-free crate.
4484    struct RowView {
4485        id: u64,
4486        gens: Rc<Cell<u64>>,
4487        log: Rc<RowLog>,
4488    }
4489
4490    struct RowWidget {
4491        id: u64,
4492        generation: u64,
4493        log: Rc<RowLog>,
4494    }
4495
4496    impl View<()> for RowView {
4497        type Element = RowWidget;
4498
4499        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowWidget {
4500            let generation = self.gens.get();
4501            self.gens.set(generation + 1);
4502            RowWidget {
4503                id: self.id,
4504                generation,
4505                log: self.log.clone(),
4506            }
4507        }
4508
4509        fn rebuild(
4510            &self,
4511            _prev: &Self,
4512            element: &mut RowWidget,
4513            _ctx: &mut BuildCtx<'_>,
4514        ) -> ChangeFlags {
4515            // A same-type in-place rebuild keeps the generation (state preserved);
4516            // only the content this row renders is updated.
4517            element.id = self.id;
4518            ChangeFlags::NONE
4519        }
4520
4521        fn teardown(&self, element: &mut RowWidget, _ctx: &mut BuildCtx<'_>) {
4522            element.log.torn.borrow_mut().push(element.id);
4523        }
4524    }
4525
4526    impl Widget for RowWidget {
4527        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
4528            bc.constrain(bc.max())
4529        }
4530        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
4531            self.log
4532                .painted
4533                .borrow_mut()
4534                .insert(self.id, self.generation);
4535        }
4536    }
4537
4538    /// A mutable backing vec of row ids plus the shared generation counter and
4539    /// log — the whole fixture one keyed test drives.
4540    struct KeyedRows {
4541        rows: Rc<RefCell<Vec<u64>>>,
4542        gens: Rc<Cell<u64>>,
4543        log: Rc<RowLog>,
4544    }
4545
4546    impl KeyedRows {
4547        fn new(ids: Vec<u64>) -> Self {
4548            Self {
4549                rows: Rc::new(RefCell::new(ids)),
4550                gens: Rc::new(Cell::new(0)),
4551                log: Rc::new(RowLog::default()),
4552            }
4553        }
4554
4555        /// The app-logic closure for a **keyed** list. Each frame captures a fresh
4556        /// *snapshot* of the row ids into both `key_of` and `builder` — the real
4557        /// app shape, and the one the reconciler depends on: the retained view of
4558        /// the previous frame must still reconstruct the *previous* frame's rows
4559        /// (`prev.builder(prev_index)`), which a shared live handle would not.
4560        fn keyed_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
4561            let rows = self.rows.clone();
4562            let gens = self.gens.clone();
4563            let log = self.log.clone();
4564            move |_: &mut ()| {
4565                let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
4566                let (keys, items) = (snapshot.clone(), snapshot.clone());
4567                let (gens, log) = (gens.clone(), log.clone());
4568                ListView::builder_keyed(
4569                    snapshot.len(),
4570                    ROW_EXTENT,
4571                    move |i| ChildKey::new(keys[i]),
4572                    move |i| {
4573                        any::<(), _>(RowView {
4574                            id: items[i],
4575                            gens: gens.clone(),
4576                            log: log.clone(),
4577                        })
4578                    },
4579                )
4580            }
4581        }
4582
4583        /// The same list built **positionally** — the contrast case that pins the
4584        /// documented index-identity failure the keyed path fixes.
4585        fn positional_logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
4586            let rows = self.rows.clone();
4587            let gens = self.gens.clone();
4588            let log = self.log.clone();
4589            move |_: &mut ()| {
4590                let items: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
4591                let (gens, log) = (gens.clone(), log.clone());
4592                ListView::builder(items.len(), ROW_EXTENT, move |i| {
4593                    any::<(), _>(RowView {
4594                        id: items[i],
4595                        gens: gens.clone(),
4596                        log: log.clone(),
4597                    })
4598                })
4599            }
4600        }
4601    }
4602
4603    /// Drive one frame with the log cleared first, so it describes exactly this
4604    /// frame's materialized rows and this frame's teardowns.
4605    fn keyed_frame(
4606        root: &mut RenderRoot<(), ListView<()>>,
4607        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
4608        log: &RowLog,
4609        ms: f64,
4610    ) -> ChangeFlags {
4611        log.painted.borrow_mut().clear();
4612        log.torn.borrow_mut().clear();
4613        frame(root, logic, &mut (), KEYED_WINDOW, ms)
4614    }
4615
4616    /// Build a root and converge its window (build frame + real-viewport frame).
4617    fn converged(
4618        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
4619        log: &RowLog,
4620    ) -> RenderRoot<(), ListView<()>> {
4621        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
4622        keyed_frame(&mut root, logic, log, 0.0);
4623        keyed_frame(&mut root, logic, log, 16.0);
4624        root
4625    }
4626
4627    #[test]
4628    fn keyed_mid_list_insert_keeps_each_row_with_its_key() {
4629        let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
4630        let mut logic = fx.keyed_logic();
4631        let mut root = converged(&mut logic, &fx.log);
4632        let before = fx.log.painted();
4633        assert_eq!(before.len(), 5, "all five rows fit the viewport");
4634
4635        // Insert in the MIDDLE: every row after it shifts one index down.
4636        fx.rows.borrow_mut().insert(2, 25);
4637        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4638
4639        let after = fx.log.painted();
4640        for id in [10, 20, 30, 40, 50] {
4641            assert_eq!(
4642                after[&id], before[&id],
4643                "row {id} kept its own widget (and its state) across the insert"
4644            );
4645        }
4646        let newest = before.values().copied().max().expect("rows painted");
4647        assert!(
4648            after[&25] > newest,
4649            "the inserted row is built fresh, not handed a survivor's widget"
4650        );
4651        assert!(
4652            fx.log.torn().is_empty(),
4653            "no key left the window, so nothing is torn down"
4654        );
4655        assert!(
4656            flags.contains(ChangeFlags::LAYOUT),
4657            "a frame that materialized a new pod owes a layout pass"
4658        );
4659    }
4660
4661    #[test]
4662    fn positional_mid_list_insert_still_misattaches_row_state() {
4663        // The documented index-identity failure `builder_keyed` exists to fix,
4664        // pinned so "the positional path is unchanged" is a test, not a claim.
4665        let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
4666        let mut logic = fx.positional_logic();
4667        let mut root = converged(&mut logic, &fx.log);
4668        let before = fx.log.painted();
4669
4670        fx.rows.borrow_mut().insert(2, 25);
4671        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4672
4673        let after = fx.log.painted();
4674        assert_ne!(
4675            after[&30], before[&30],
4676            "positional identity leaves row 30's state behind at index 2"
4677        );
4678        assert_eq!(
4679            after[&25], before[&30],
4680            "and hands it to whatever content now occupies that index"
4681        );
4682    }
4683
4684    #[test]
4685    fn keyed_mid_list_remove_tears_down_only_the_removed_row() {
4686        let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
4687        let mut logic = fx.keyed_logic();
4688        let mut root = converged(&mut logic, &fx.log);
4689        let before = fx.log.painted();
4690
4691        fx.rows.borrow_mut().remove(2); // drop row 30 from the middle
4692        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4693
4694        let after = fx.log.painted();
4695        assert_eq!(
4696            fx.log.torn(),
4697            vec![30],
4698            "exactly the removed row's pod is torn down (its owner disposed)"
4699        );
4700        assert!(!after.contains_key(&30), "row 30 no longer paints");
4701        for id in [10, 20, 40, 50] {
4702            assert_eq!(
4703                after[&id], before[&id],
4704                "row {id} kept its own widget across the removal"
4705            );
4706        }
4707        assert!(
4708            flags.contains(ChangeFlags::LAYOUT),
4709            "a frame that tore a pod down owes a layout pass"
4710        );
4711    }
4712
4713    #[test]
4714    fn keyed_reorder_relocates_rows_and_reports_layout() {
4715        let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
4716        let mut logic = fx.keyed_logic();
4717        let mut root = converged(&mut logic, &fx.log);
4718        let before = fx.log.painted();
4719
4720        fx.rows.borrow_mut().reverse();
4721        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4722
4723        let after = fx.log.painted();
4724        for id in [10, 20, 30, 40, 50] {
4725            assert_eq!(
4726                after[&id], before[&id],
4727                "row {id} moved slot but kept its own widget"
4728            );
4729        }
4730        assert!(
4731            fx.log.torn().is_empty(),
4732            "a reorder builds and tears down nothing"
4733        );
4734        assert!(
4735            flags.contains(ChangeFlags::LAYOUT),
4736            "relocated pods sit at new origins, so the frame owes a layout pass"
4737        );
4738    }
4739
4740    #[test]
4741    fn an_unchanged_keyed_frame_reports_no_layout() {
4742        // The layout-skip contract in the other direction: a frame that neither
4743        // built, tore down, relocated, nor re-ranged a pod must NOT force a
4744        // layout pass, or the idiom degenerates into "relayout every frame".
4745        let fx = KeyedRows::new(vec![10, 20, 30, 40, 50]);
4746        let mut logic = fx.keyed_logic();
4747        let mut root = converged(&mut logic, &fx.log);
4748
4749        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4750        assert!(
4751            flags.is_empty(),
4752            "an identical keyed frame is a no-op reconciliation, got {flags:?}"
4753        );
4754    }
4755
4756    #[test]
4757    fn keyed_window_shift_relocates_survivors_and_tears_down_the_leaver() {
4758        // A pure scroll over 1000 keyed rows: virtualization still works, and the
4759        // retained key map tracks the window it materialized.
4760        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4761        let mut logic = fx.keyed_logic();
4762        let mut root = converged(&mut logic, &fx.log);
4763        let before = fx.log.painted();
4764        assert_eq!(list_widget(&root).window(), &[0, 1, 2, 3, 4, 5]);
4765
4766        // Scroll 150px: the window becomes 1..9 — row 0 leaves, rows 6..8 enter.
4767        root.event(&mut (), &wheel(150.0));
4768        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4769
4770        let after = fx.log.painted();
4771        assert_eq!(list_widget(&root).window(), &[1, 2, 3, 4, 5, 6, 7, 8]);
4772        for id in [10, 20, 30, 40, 50] {
4773            assert_eq!(
4774                after[&id], before[&id],
4775                "row {id} stayed in the window and kept its widget"
4776            );
4777        }
4778        assert_eq!(
4779            fx.log.torn(),
4780            vec![0],
4781            "the row that scrolled out is torn down"
4782        );
4783        assert!(
4784            flags.contains(ChangeFlags::LAYOUT),
4785            "a shifted window owes a layout pass"
4786        );
4787    }
4788
4789    #[test]
4790    fn keyed_insert_under_a_scrolled_window_keeps_state_with_the_rows() {
4791        // The hard case: a mid-list insert *while* the list is virtualized, so
4792        // every row's index shifts under a window that would otherwise stay
4793        // put — except this insert is a prepend relative to the scrolled
4794        // viewport (index 0 sits above it), so scroll anchoring (see the
4795        // module docs' anchoring section) shifts the *offset* to compensate,
4796        // keeping the SAME rows on screen rather than sliding new content in.
4797        // Positional identity has no answer here (see the contrast test
4798        // above); keys carry each row's state with its index, and anchoring
4799        // carries the viewport with it too.
4800        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4801        let mut logic = fx.keyed_logic();
4802        let mut root = converged(&mut logic, &fx.log);
4803
4804        root.event(&mut (), &wheel(500.0));
4805        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4806        let before = fx.log.painted();
4807        assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
4808        // Rows 80..=150 (ids), one per materialized slot.
4809        assert_eq!(before.len(), 8);
4810        let offset_before = list_widget(&root).offset();
4811
4812        // Insert at the FRONT: every row slides one index down; the anchor
4813        // (row 80, topmost of the previous window) shifts the offset by
4814        // exactly one row's extent to hold it in place.
4815        fx.rows.borrow_mut().insert(0, 5);
4816        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
4817
4818        let after = fx.log.painted();
4819        assert_eq!(
4820            list_widget(&root).offset(),
4821            offset_before + ROW_EXTENT,
4822            "anchored: the offset absorbs the shift instead of the window sliding"
4823        );
4824        for id in [80, 90, 100, 110, 120, 130, 140, 150] {
4825            assert_eq!(
4826                after[&id], before[&id],
4827                "row {id} kept its widget — anchoring shows the SAME rows, not new ones"
4828            );
4829        }
4830        assert!(
4831            fx.log.torn().is_empty(),
4832            "anchoring keeps the same window of rows visible: nothing leaves it"
4833        );
4834        assert!(flags.contains(ChangeFlags::LAYOUT));
4835    }
4836
4837    // --- (10) Prepend/removal scroll anchoring (keyed lists only). ---
4838
4839    /// The pixel `y` a materialized row paints at (its `ChildPod` origin),
4840    /// looked up by the item index it currently occupies — the literal paint
4841    /// position the anchoring assertions below are stated against.
4842    fn painted_y(w: &ListViewWidget, item_index: usize) -> f64 {
4843        let slot = w
4844            .keys
4845            .iter()
4846            .position(|&k| k == item_index)
4847            .expect("index is materialized");
4848        w.children[slot].origin().y
4849    }
4850
4851    #[test]
4852    fn keyed_prepend_while_scrolled_anchors_the_visible_rows() {
4853        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4854        let mut logic = fx.keyed_logic();
4855        let mut root = converged(&mut logic, &fx.log);
4856
4857        root.event(&mut (), &wheel(500.0));
4858        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4859        let before = fx.log.painted();
4860        assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
4861        let offset_before = list_widget(&root).offset();
4862        let y_before = painted_y(list_widget(&root), 8); // row id 80's pixel position
4863
4864        // Prepend 5 rows above the viewport.
4865        fx.rows
4866            .borrow_mut()
4867            .splice(0..0, [9990, 9980, 9970, 9960, 9950]);
4868        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
4869
4870        let after = fx.log.painted();
4871        let w = list_widget(&root);
4872        assert_eq!(
4873            w.offset(),
4874            offset_before + 5.0 * ROW_EXTENT,
4875            "the offset shifts by exactly K * item_extent"
4876        );
4877        assert_eq!(
4878            painted_y(w, 13), // row id 80 slid from index 8 to index 13
4879            y_before,
4880            "the anchor row paints at the exact same pixel position"
4881        );
4882        for id in [80, 90, 100, 110, 120, 130, 140, 150] {
4883            assert_eq!(
4884                after[&id], before[&id],
4885                "row {id} kept its widget across the prepend — anchoring, not a rebuild"
4886            );
4887        }
4888        assert!(
4889            fx.log.torn().is_empty(),
4890            "anchoring keeps the same rows visible — nothing leaves the window"
4891        );
4892        assert!(flags.contains(ChangeFlags::LAYOUT));
4893    }
4894
4895    #[test]
4896    fn keyed_removal_above_the_viewport_anchors_in_reverse() {
4897        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4898        let mut logic = fx.keyed_logic();
4899        let mut root = converged(&mut logic, &fx.log);
4900
4901        root.event(&mut (), &wheel(500.0));
4902        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4903        let before = fx.log.painted();
4904        assert_eq!(list_widget(&root).window(), &[8, 9, 10, 11, 12, 13, 14, 15]);
4905        let offset_before = list_widget(&root).offset();
4906        let y_before = painted_y(list_widget(&root), 8);
4907
4908        // Remove the first 3 rows — all strictly above the viewport.
4909        fx.rows.borrow_mut().drain(0..3);
4910        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
4911
4912        let after = fx.log.painted();
4913        let w = list_widget(&root);
4914        assert_eq!(
4915            w.offset(),
4916            offset_before - 3.0 * ROW_EXTENT,
4917            "removal above shifts the offset back by exactly the removed extent"
4918        );
4919        assert_eq!(
4920            painted_y(w, 5), // row id 80 slid from index 8 to index 5
4921            y_before,
4922            "the anchor row paints at the exact same pixel position"
4923        );
4924        for id in [80, 90, 100, 110, 120, 130, 140, 150] {
4925            assert_eq!(
4926                after[&id], before[&id],
4927                "row {id} kept its widget across the removal"
4928            );
4929        }
4930        assert!(fx.log.torn().is_empty());
4931        assert!(flags.contains(ChangeFlags::LAYOUT));
4932    }
4933
4934    #[test]
4935    fn keyed_removal_above_anchors_correctly_at_max_offset() {
4936        // Scripted at a third scroll position (0, mid, max_offset) per the
4937        // task's testing note: the closed-form correction must still clamp
4938        // correctly when scrolled all the way to the bottom.
4939        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4940        let mut logic = fx.keyed_logic();
4941        let mut root = converged(&mut logic, &fx.log);
4942
4943        root.event(&mut (), &wheel(1_000_000.0)); // scroll to the very bottom
4944        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4945        let before = fx.log.painted();
4946        let offset_before = list_widget(&root).offset();
4947        assert_eq!(offset_before, list_widget(&root).max_offset());
4948
4949        // Remove the first 3 rows, strictly above the (bottom-scrolled) viewport.
4950        fx.rows.borrow_mut().drain(0..3);
4951        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
4952
4953        let after = fx.log.painted();
4954        let w = list_widget(&root);
4955        assert_eq!(
4956            w.offset(),
4957            offset_before - 3.0 * ROW_EXTENT,
4958            "anchors correctly even scrolled to the very bottom"
4959        );
4960        assert_eq!(
4961            w.offset(),
4962            w.max_offset(),
4963            "still pinned exactly at the new (smaller) bottom"
4964        );
4965        for (&id, &generation) in before.iter() {
4966            assert_eq!(
4967                after.get(&id),
4968                Some(&generation),
4969                "row {id} kept its widget"
4970            );
4971        }
4972        assert!(flags.contains(ChangeFlags::LAYOUT));
4973    }
4974
4975    #[test]
4976    fn keyed_mutation_below_the_viewport_leaves_the_offset_alone() {
4977        // The third documented case: a below-viewport mutation is a no-op for
4978        // anchoring (the index the anchor occupies doesn't move at all).
4979        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
4980        let mut logic = fx.keyed_logic();
4981        let mut root = converged(&mut logic, &fx.log);
4982
4983        root.event(&mut (), &wheel(500.0));
4984        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
4985        let offset_before = list_widget(&root).offset();
4986
4987        fx.rows.borrow_mut().truncate(900); // drop the last 100 rows
4988
4989        keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
4990
4991        assert_eq!(
4992            list_widget(&root).offset(),
4993            offset_before,
4994            "a below-viewport mutation leaves an already-scrolled offset untouched"
4995        );
4996    }
4997
4998    #[test]
4999    fn keyed_prepend_at_the_very_top_still_anchors_off_zero() {
5000        // The module docs' anchor-always decision: even starting at
5001        // offset == 0 the offset shifts, revealing the new content above
5002        // rather than staying pinned at the literal top.
5003        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5004        let mut logic = fx.keyed_logic();
5005        let mut root = converged(&mut logic, &fx.log);
5006        assert_eq!(list_widget(&root).offset(), 0.0);
5007
5008        fx.rows.borrow_mut().splice(0..0, [9990, 9980, 9970]);
5009        let flags = keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5010
5011        assert_eq!(
5012            list_widget(&root).offset(),
5013            3.0 * ROW_EXTENT,
5014            "the offset shifts away from zero rather than staying pinned"
5015        );
5016        assert!(flags.contains(ChangeFlags::LAYOUT));
5017    }
5018
5019    #[test]
5020    fn keyed_prepend_near_top_does_not_immediately_refire_near_start() {
5021        // The edge-latch interaction: a correction must not make the very
5022        // next event refire an edge that just fired (and whose callback is
5023        // presumably what triggered the prepend).
5024        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5025        let loads = Rc::new(Cell::new(0u32));
5026        let loads_l = loads.clone();
5027        let rows = fx.rows.clone();
5028        let gens = fx.gens.clone();
5029        let log = fx.log.clone();
5030        let mut logic = move |_: &mut ()| -> ListView<()> {
5031            let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
5032            let (keys, items) = (snapshot.clone(), snapshot.clone());
5033            let (gens, log) = (gens.clone(), log.clone());
5034            let loads = loads_l.clone();
5035            ListView::builder_keyed(
5036                snapshot.len(),
5037                ROW_EXTENT,
5038                move |i| ChildKey::new(keys[i]),
5039                move |i| {
5040                    any::<(), _>(RowView {
5041                        id: items[i],
5042                        gens: gens.clone(),
5043                        log: log.clone(),
5044                    })
5045                },
5046            )
5047            .on_near_start(move |_: &mut ()| loads.set(loads.get() + 1), 100.0)
5048        };
5049
5050        let mut root = converged(&mut logic, &fx.log);
5051        // A real near-start approach at the top fires once — the trigger a
5052        // real "load older" flow answers by prepending.
5053        root.event(&mut (), &wheel(0.0));
5054        assert_eq!(loads.get(), 1, "near-start fires once at the top");
5055
5056        // Prepend a single row — the anchor shifts the offset to 1 * extent,
5057        // well within the 100px threshold: exactly the case that would
5058        // spuriously refire without the correction's rearm suppression.
5059        fx.rows.borrow_mut().splice(0..0, [9999]);
5060        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5061        assert_eq!(
5062            list_widget(&root).offset(),
5063            ROW_EXTENT,
5064            "anchored one row's worth off zero"
5065        );
5066
5067        // The very next event, still near the (corrected) start, must NOT
5068        // refire.
5069        root.event(&mut (), &wheel(0.0));
5070        assert_eq!(
5071            loads.get(),
5072            1,
5073            "the corrected offset does not immediately re-fire the same edge"
5074        );
5075
5076        // Scrolling away past 2x the threshold and back still rearms normally.
5077        root.event(&mut (), &wheel(500.0));
5078        root.event(&mut (), &wheel(-450.0));
5079        assert_eq!(
5080            loads.get(),
5081            2,
5082            "the edge still rearms after genuinely scrolling away and back"
5083        );
5084    }
5085
5086    #[test]
5087    fn positional_prepend_does_not_anchor_the_offset() {
5088        // Criterion 4: positional identity cannot distinguish a prepend from
5089        // a full mutation, so the positional path must never apply the
5090        // anchoring correction — the offset moves only from user scrolling.
5091        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5092        let mut logic = fx.positional_logic();
5093        let mut root = converged(&mut logic, &fx.log);
5094
5095        root.event(&mut (), &wheel(500.0));
5096        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5097        let offset_before = list_widget(&root).offset();
5098
5099        fx.rows.borrow_mut().insert(0, 12345);
5100        keyed_frame(&mut root, &mut logic, &fx.log, 48.0);
5101
5102        assert_eq!(
5103            list_widget(&root).offset(),
5104            offset_before,
5105            "the positional path never corrects the offset"
5106        );
5107    }
5108
5109    // --- (11) Variable extents: estimate + measured-by-key cache. ---
5110
5111    /// The estimate every variable-extent test declares. Deliberately unequal to
5112    /// any row's true height, so an unmeasured region is visibly *estimated* and
5113    /// a measured one visibly is not.
5114    const ESTIMATE: f64 = 60.0;
5115
5116    /// Deterministic row height by id: 40/60/80/100/120 px, cycling.
5117    fn var_height(id: u64) -> f64 {
5118        40.0 + (id % 5) as f64 * 20.0
5119    }
5120
5121    /// Which pass the harness is currently driving — the invocation-context
5122    /// probe's alphabet.
5123    #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
5124    enum Pass {
5125        #[default]
5126        Idle,
5127        Rebuild,
5128        Layout,
5129        Paint,
5130    }
5131
5132    /// The invocation-context probe: the app's builder closure reports the pass
5133    /// it was called in, so "the builder never runs from layout or paint" (the
5134    /// wake hazard the module docs call out) is a test, not a claim.
5135    #[derive(Default)]
5136    struct BuildProbe {
5137        pass: Cell<Pass>,
5138        calls: Cell<usize>,
5139        violations: RefCell<Vec<Pass>>,
5140    }
5141
5142    impl BuildProbe {
5143        fn note(&self) {
5144            self.calls.set(self.calls.get() + 1);
5145            let pass = self.pass.get();
5146            if pass != Pass::Rebuild {
5147                self.violations.borrow_mut().push(pass);
5148            }
5149        }
5150    }
5151
5152    /// A keyed row of a *variable* height (`var_height(id)`), reporting the same
5153    /// generation/paint/teardown log as [`RowView`]. The height is intrinsic: the
5154    /// row honors the list's unbounded-height constraint by choosing its own
5155    /// height, exactly like a real self-sizing row.
5156    struct VarRowView {
5157        id: u64,
5158        gens: Rc<Cell<u64>>,
5159        log: Rc<RowLog>,
5160    }
5161
5162    struct VarRowWidget {
5163        id: u64,
5164        generation: u64,
5165        log: Rc<RowLog>,
5166    }
5167
5168    impl View<()> for VarRowView {
5169        type Element = VarRowWidget;
5170
5171        fn build(&self, _ctx: &mut BuildCtx<'_>) -> VarRowWidget {
5172            let generation = self.gens.get();
5173            self.gens.set(generation + 1);
5174            VarRowWidget {
5175                id: self.id,
5176                generation,
5177                log: self.log.clone(),
5178            }
5179        }
5180
5181        fn rebuild(
5182            &self,
5183            _prev: &Self,
5184            element: &mut VarRowWidget,
5185            _ctx: &mut BuildCtx<'_>,
5186        ) -> ChangeFlags {
5187            element.id = self.id;
5188            ChangeFlags::NONE
5189        }
5190
5191        fn teardown(&self, element: &mut VarRowWidget, _ctx: &mut BuildCtx<'_>) {
5192            element.log.torn.borrow_mut().push(element.id);
5193        }
5194    }
5195
5196    impl Widget for VarRowWidget {
5197        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
5198            bc.constrain(Size::new(bc.max().width, var_height(self.id)))
5199        }
5200        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
5201            self.log
5202                .painted
5203                .borrow_mut()
5204                .insert(self.id, self.generation);
5205        }
5206    }
5207
5208    /// The variable-extent counterpart of [`KeyedRows`]: a mutable backing vec of
5209    /// ids whose rows size themselves, plus the invocation probe.
5210    struct VarRows {
5211        rows: Rc<RefCell<Vec<u64>>>,
5212        gens: Rc<Cell<u64>>,
5213        log: Rc<RowLog>,
5214        probe: Rc<BuildProbe>,
5215    }
5216
5217    impl VarRows {
5218        fn new(ids: Vec<u64>) -> Self {
5219            Self {
5220                rows: Rc::new(RefCell::new(ids)),
5221                gens: Rc::new(Cell::new(0)),
5222                log: Rc::new(RowLog::default()),
5223                probe: Rc::new(BuildProbe::default()),
5224            }
5225        }
5226
5227        /// The app-logic closure: a keyed list in variable-extent mode, whose
5228        /// builder reports every invocation to the probe.
5229        fn logic(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
5230            let rows = self.rows.clone();
5231            let gens = self.gens.clone();
5232            let log = self.log.clone();
5233            let probe = self.probe.clone();
5234            move |_: &mut ()| {
5235                let snapshot: Rc<Vec<u64>> = Rc::new(rows.borrow().clone());
5236                let (keys, items) = (snapshot.clone(), snapshot.clone());
5237                let (gens, log, probe) = (gens.clone(), log.clone(), probe.clone());
5238                ListView::builder_keyed(
5239                    snapshot.len(),
5240                    ESTIMATE,
5241                    move |i| ChildKey::new(keys[i]),
5242                    move |i| {
5243                        probe.note();
5244                        any::<(), _>(VarRowView {
5245                            id: items[i],
5246                            gens: gens.clone(),
5247                            log: log.clone(),
5248                        })
5249                    },
5250                )
5251                .estimated_item_extent(ESTIMATE)
5252            }
5253        }
5254
5255        /// [`VarRows::logic`] with the pre-seam [`RubberBand`] feel pinned
5256        /// explicitly, for the same reason [`rubber_band_logic`] exists.
5257        fn logic_rubber_band(&self) -> impl FnMut(&mut ()) -> ListView<()> + use<> {
5258            let mut inner = self.logic();
5259            move |state: &mut ()| inner(state).physics(RubberBand::new())
5260        }
5261
5262        /// The true total content height of the current data.
5263        fn true_extent(&self) -> f64 {
5264            self.rows.borrow().iter().copied().map(var_height).sum()
5265        }
5266    }
5267
5268    /// Drive one frame with the probe told which pass is running, returning the
5269    /// rebuild flags and paint's continuation request.
5270    fn var_frame(
5271        root: &mut RenderRoot<(), ListView<()>>,
5272        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
5273        fx: &VarRows,
5274        ms: f64,
5275    ) -> (ChangeFlags, frust_core::PaintOutcome) {
5276        fx.log.painted.borrow_mut().clear();
5277        fx.log.torn.borrow_mut().clear();
5278        fx.probe.pass.set(Pass::Rebuild);
5279        let flags = root.rebuild(logic, &mut ());
5280        fx.probe.pass.set(Pass::Layout);
5281        root.layout(KEYED_WINDOW);
5282        fx.probe.pass.set(Pass::Paint);
5283        let mut sink = NullScene;
5284        let outcome = root.paint(&mut sink, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
5285        fx.probe.pass.set(Pass::Idle);
5286        (flags, outcome)
5287    }
5288
5289    /// Drive frames until paint stops asking for another one, returning how many
5290    /// it took. Panics past `limit` — the convergence-frame contract is "a
5291    /// measurement that changes the window costs *frames*", never a loop.
5292    fn settle(
5293        root: &mut RenderRoot<(), ListView<()>>,
5294        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
5295        fx: &VarRows,
5296        first_ms: f64,
5297        limit: usize,
5298    ) -> usize {
5299        for n in 0..limit {
5300            let (_, outcome) = var_frame(root, logic, fx, first_ms + 16.0 * n as f64);
5301            if !outcome.needs_frame {
5302                return n + 1;
5303            }
5304        }
5305        panic!("a variable-extent list did not settle within {limit} frames");
5306    }
5307
5308    /// Build a root over a variable-extent list and settle its first window.
5309    fn converged_var(
5310        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
5311        fx: &VarRows,
5312    ) -> RenderRoot<(), ListView<()>> {
5313        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
5314        settle(&mut root, logic, fx, 0.0, 6);
5315        root
5316    }
5317
5318    /// Assert the materialized rows tile the content with no gap and no overlap,
5319    /// and that they cover the whole viewport — the "cumulative extents minus
5320    /// offset" property, read off the actual pod origins/sizes.
5321    fn assert_tiles_and_covers(w: &ListViewWidget) {
5322        assert!(!w.children.is_empty(), "something must be materialized");
5323        for slot in 0..w.children.len() {
5324            let pod = &w.children[slot];
5325            let id = w.keys[slot] as u64;
5326            assert_eq!(
5327                pod.size().height,
5328                var_height(id),
5329                "row {id} laid out at its own intrinsic height"
5330            );
5331            assert_eq!(
5332                pod.origin().y,
5333                w.slot_y[slot] - w.offset(),
5334                "row {id} paints at its content position minus the offset"
5335            );
5336            if slot + 1 < w.children.len() {
5337                assert_eq!(
5338                    w.children[slot + 1].origin().y,
5339                    pod.origin().y + pod.size().height,
5340                    "rows tile: no gap, no overlap between slots {slot} and {}",
5341                    slot + 1
5342                );
5343            }
5344        }
5345        let first = w.children[0].origin().y;
5346        let last = w.children.last().expect("non-empty");
5347        let bottom = last.origin().y + last.size().height;
5348        assert!(
5349            first <= 0.0,
5350            "the first materialized row starts at or above the viewport top (got {first})"
5351        );
5352        assert!(
5353            bottom >= w.viewport.height,
5354            "the materialized window reaches the viewport bottom (got {bottom})"
5355        );
5356    }
5357
5358    #[test]
5359    fn variable_rows_tile_at_their_own_measured_heights() {
5360        let fx = VarRows::new((0..60).collect());
5361        let mut logic = fx.logic();
5362        let mut root = converged_var(&mut logic, &fx);
5363        assert_tiles_and_covers(list_widget(&root));
5364
5365        // Scroll into a region no row has been measured in yet, settle, and the
5366        // same tiling property must hold there.
5367        root.event(&mut (), &wheel(700.0));
5368        settle(&mut root, &mut logic, &fx, 100.0, 4);
5369        assert_tiles_and_covers(list_widget(&root));
5370
5371        // ...and back up over now-measured territory.
5372        root.event(&mut (), &wheel(-300.0));
5373        settle(&mut root, &mut logic, &fx, 200.0, 4);
5374        assert_tiles_and_covers(list_widget(&root));
5375    }
5376
5377    #[test]
5378    fn variable_scroll_into_unmeasured_territory_converges_in_one_frame() {
5379        let fx = VarRows::new((0..200).collect());
5380        let mut logic = fx.logic();
5381        let mut root = converged_var(&mut logic, &fx);
5382
5383        // A wheel jump far past anything measured: the next rebuild windows from
5384        // the estimate, layout measures what it materialized, and at most one
5385        // convergence frame re-windows against the revised extents.
5386        root.event(&mut (), &wheel(2_000.0));
5387        let frames = settle(&mut root, &mut logic, &fx, 100.0, 2);
5388        assert!(
5389            frames <= 2,
5390            "a scroll into unmeasured territory converges within one extra frame (took {frames})"
5391        );
5392        assert_tiles_and_covers(list_widget(&root));
5393        assert!(
5394            fx.probe.violations.borrow().is_empty(),
5395            "the builder ran outside rebuild: {:?}",
5396            fx.probe.violations.borrow()
5397        );
5398    }
5399
5400    #[test]
5401    fn the_builder_never_runs_outside_rebuild_even_under_a_fling() {
5402        // The same probe across the hardest path: a drag/fling that advances the
5403        // offset *at paint* across unmeasured rows. Paint must materialize
5404        // nothing — it may only ask for another frame.
5405        let fx = VarRows::new((0..400).collect());
5406        let mut logic = fx.logic();
5407        let mut root = converged_var(&mut logic, &fx);
5408
5409        root.event(&mut (), &ev(PointerPhase::Down, 180.0));
5410        var_frame(&mut root, &mut logic, &fx, 100.0);
5411        root.event(&mut (), &ev(PointerPhase::Move, 120.0)); // takeover
5412        var_frame(&mut root, &mut logic, &fx, 116.0);
5413        root.event(&mut (), &ev(PointerPhase::Move, 40.0)); // build velocity
5414        root.event(&mut (), &ev(PointerPhase::Up, 40.0)); // release → fling
5415        assert!(list_widget(&root).is_flinging());
5416
5417        for k in 0..12 {
5418            var_frame(&mut root, &mut logic, &fx, 132.0 + 16.0 * k as f64);
5419        }
5420
5421        assert!(fx.probe.calls.get() > 0, "the probe saw the builder at all");
5422        assert!(
5423            fx.probe.violations.borrow().is_empty(),
5424            "the builder ran outside rebuild: {:?}",
5425            fx.probe.violations.borrow()
5426        );
5427        // The fling crossed unmeasured rows without panicking or stalling.
5428        assert!(list_widget(&root).offset() > 0.0);
5429    }
5430
5431    #[test]
5432    fn uniform_mode_takes_the_closed_form_path_and_measures_nothing() {
5433        // Criterion 3: without `estimated_item_extent` nothing about the extent
5434        // model changes — no measurement is cached, no per-slot geometry is
5435        // retained, and rows sit at exactly `index * item_extent`.
5436        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
5437        let mut logic = fx.keyed_logic();
5438        let mut root = converged(&mut logic, &fx.log);
5439        root.event(&mut (), &wheel(500.0));
5440        keyed_frame(&mut root, &mut logic, &fx.log, 32.0);
5441
5442        let w = list_widget(&root);
5443        assert!(w.estimated_extent.is_none(), "no estimate was declared");
5444        assert!(!w.is_variable(), "the uniform path is taken");
5445        assert!(w.measured.is_empty(), "the measured cache stays empty");
5446        assert_eq!(w.measured_sum, 0.0);
5447        assert!(w.slot_keys.is_empty() && w.slot_y.is_empty());
5448        assert_eq!(
5449            w.max_offset(),
5450            1000.0 * ROW_EXTENT - w.viewport.height,
5451            "the closed-form scroll extent is unchanged"
5452        );
5453        assert_eq!(
5454            painted_y(w, 10),
5455            10.0 * ROW_EXTENT - w.offset(),
5456            "rows sit at the closed-form position"
5457        );
5458    }
5459
5460    #[test]
5461    fn variable_total_extent_converges_to_the_true_sum() {
5462        use frust_core::accesskit::Role;
5463
5464        let fx = VarRows::new((0..30).collect());
5465        let mut logic = fx.logic();
5466        let mut root = converged_var(&mut logic, &fx);
5467
5468        // Before anything is visited the extent is mostly estimated.
5469        let estimated_total = list_widget(&root).content_extent();
5470        assert!(
5471            (estimated_total - fx.true_extent()).abs() > 1.0,
5472            "the estimate and the truth differ to begin with"
5473        );
5474
5475        // Walk the whole list a viewport at a time, so every row is materialized
5476        // (and therefore measured) at least once.
5477        for step in 0..40 {
5478            root.event(&mut (), &wheel(100.0));
5479            var_frame(&mut root, &mut logic, &fx, 100.0 + 16.0 * step as f64);
5480        }
5481
5482        let w = list_widget(&root);
5483        assert_eq!(w.measured.len(), 30, "every row was visited and measured");
5484        assert!(
5485            (w.content_extent() - fx.true_extent()).abs() < 1e-6,
5486            "the total extent converged to Σ true extents: {} vs {}",
5487            w.content_extent(),
5488            fx.true_extent()
5489        );
5490        assert!(
5491            (w.max_offset() - (fx.true_extent() - w.viewport.height)).abs() < 1e-6,
5492            "max_offset tracks the converged extent"
5493        );
5494
5495        let update = root.semantics();
5496        let (_, list) = update
5497            .nodes
5498            .iter()
5499            .find(|(_, n)| n.role() == Role::List)
5500            .expect("the ListView contributes a Role::List container node");
5501        assert_eq!(
5502            list.scroll_y_max(),
5503            Some(fx.true_extent() - KEYED_WINDOW.height),
5504            "the semantics scroll range tracks it too"
5505        );
5506    }
5507
5508    #[test]
5509    fn variable_cache_evicts_a_removed_row_but_keeps_a_scrolled_away_one() {
5510        let fx = VarRows::new((0..40).collect());
5511        let mut logic = fx.logic();
5512        let mut root = converged_var(&mut logic, &fx);
5513        assert!(
5514            list_widget(&root)
5515                .measured
5516                .contains_key(&ChildKey::new(0u64)),
5517            "row 0 was measured while it was on screen"
5518        );
5519
5520        // Scroll it out of the window: it left the *window*, not the data, so its
5521        // measurement must survive (this is what makes the extent converge).
5522        root.event(&mut (), &wheel(400.0));
5523        settle(&mut root, &mut logic, &fx, 100.0, 4);
5524        let w = list_widget(&root);
5525        assert!(!w.window().contains(&0), "row 0 is no longer materialized");
5526        assert!(
5527            w.measured.contains_key(&ChildKey::new(0u64)),
5528            "a row that only left the window keeps its measurement"
5529        );
5530
5531        // Now remove a row that IS in the window: its key left the data, so its
5532        // measurement is evicted and the running sum drops by exactly its height.
5533        let victim = list_widget(&root).window()[3] as u64;
5534        let sum_before = list_widget(&root).measured_sum;
5535        let count_before = list_widget(&root).measured.len();
5536        fx.rows.borrow_mut().retain(|&id| id != victim);
5537        root.rebuild(&mut logic, &mut ()); // rebuild alone: no re-measurement yet
5538
5539        let w = list_widget(&root);
5540        assert!(
5541            !w.measured.contains_key(&ChildKey::new(victim)),
5542            "the removed row's measurement is evicted"
5543        );
5544        assert_eq!(w.measured.len(), count_before - 1);
5545        assert!(
5546            (w.measured_sum - (sum_before - var_height(victim))).abs() < 1e-9,
5547            "the running sum drops by exactly the evicted extent"
5548        );
5549    }
5550
5551    #[test]
5552    fn variable_full_replace_clears_the_measured_cache() {
5553        let fx = VarRows::new((0..40).collect());
5554        let mut logic = fx.logic();
5555        let mut root = converged_var(&mut logic, &fx);
5556        assert!(!list_widget(&root).measured.is_empty());
5557
5558        // Every id replaced: no previous-window key survives either hypothesis,
5559        // which is reset semantics for the cache as well as for anchoring.
5560        *fx.rows.borrow_mut() = (0..40).map(|i| 10_000 + i).collect();
5561        root.rebuild(&mut logic, &mut ());
5562
5563        let w = list_widget(&root);
5564        assert!(w.measured.is_empty(), "a full replace clears the cache");
5565        assert_eq!(w.measured_sum, 0.0);
5566    }
5567
5568    #[test]
5569    fn same_frame_prepend_and_append_leaves_the_measured_cache_intact() {
5570        // A same-frame mutation on BOTH sides of the anchor (a prepend above
5571        // the viewport plus an append below it, in one keyed frame) is a
5572        // false negative for the rebuild-time anchor-shift probe: the net
5573        // `item_count` delta is +4 (3 prepended, 1 appended) but the true
5574        // per-row shift for every already-existing row is +3 (the append
5575        // never shifts an existing index), so the probe's two hypotheses
5576        // (unchanged position, or shifted by the net delta) both miss.
5577        //
5578        // Before this fix that bare miss also wiped the whole measured
5579        // cache (`ListView::rebuild`'s `None` arm called `clear_measured`
5580        // unconditionally). This pins the fix: the scroll-position
5581        // correction itself is knowingly still absent this frame (a visible
5582        // jump — the exact per-key anchor fix is a deferred follow-up, see
5583        // the module docs' *Cache hygiene* section), but the wholesale clear
5584        // no longer fires, because `reconcile_keyed` still matches *some*
5585        // on-screen rows into this frame's window by *exact* key, not by the
5586        // probe's hypotheses (`any_survivor`).
5587        //
5588        // Not every previously on-screen row is such a survivor, though: the
5589        // window itself is recomputed against the same (uncorrected, since
5590        // the probe missed) offset over now-shifted content, so a row whose
5591        // slot falls outside the recomputed window becomes an orphan the
5592        // main reconcile loop never claims. The departing-row eviction loop
5593        // (`ListView::reconcile_keyed`'s *Cache hygiene* section) tests that
5594        // orphan against the same two hypotheses the probe already found
5595        // unreliable this frame, and — per the module docs' *acknowledged
5596        // gaps* — conservatively evicts it rather than leaving it stale
5597        // forever: a merely-window-departed row costs one re-measurement on
5598        // its next visit; a genuinely data-departed row (a distinct
5599        // scenario pinned by
5600        // `probe_miss_with_survivor_evicts_a_row_that_left_the_data`) would
5601        // otherwise leak permanently. Both outcomes are asserted below,
5602        // derived from which rows the reconciled window actually still
5603        // names — not hardcoded — so this test tracks the real reconciliation
5604        // rather than one snapshot of its internals.
5605        let fx = VarRows::new((0..60).collect());
5606        let mut logic = fx.logic();
5607        let mut root = converged_var(&mut logic, &fx);
5608
5609        // Scroll to a middle window so there is real content both above and
5610        // below what is materialized.
5611        root.event(&mut (), &wheel(400.0));
5612        settle(&mut root, &mut logic, &fx, 100.0, 4);
5613
5614        let w = list_widget(&root);
5615        let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
5616        assert!(!on_screen_ids.is_empty(), "the window materialized rows");
5617        let measured_before: HashMap<u64, f64> = on_screen_ids
5618            .iter()
5619            .map(|&id| {
5620                let m = w.measured[&ChildKey::new(id)];
5621                (id, m.extent)
5622            })
5623            .collect();
5624        assert_eq!(
5625            measured_before.len(),
5626            on_screen_ids.len(),
5627            "every on-screen row was already measured, laid out once by `converged_var`"
5628        );
5629
5630        // Prepend 3 above and append 1 below in one borrow_mut batch, so both
5631        // mutations land in the same keyed rebuild.
5632        {
5633            let mut rows = fx.rows.borrow_mut();
5634            rows.splice(0..0, [9_001u64, 9_002, 9_003]);
5635            rows.push(9_050);
5636        }
5637        root.rebuild(&mut logic, &mut ()); // rebuild alone: pins cache state, not paint/layout
5638
5639        let w = list_widget(&root);
5640        let new_window_ids: std::collections::HashSet<u64> =
5641            w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
5642        assert!(
5643            on_screen_ids.iter().any(|id| new_window_ids.contains(id)),
5644            "at least one previously on-screen row is still reconciled into this \
5645             frame's window by exact key — `any_survivor`, the precondition that \
5646             keeps the wholesale clear from firing"
5647        );
5648
5649        let mut survivors = 0;
5650        let mut departed = 0;
5651        for (id, extent_before) in &measured_before {
5652            let entry = w.measured.get(&ChildKey::new(*id));
5653            if new_window_ids.contains(id) {
5654                survivors += 1;
5655                assert!(
5656                    entry.is_some(),
5657                    "row {id} is still named by this frame's reconciled window \
5658                     (an exact key match), so its measurement must survive"
5659                );
5660                assert_eq!(
5661                    entry.unwrap().extent,
5662                    *extent_before,
5663                    "row {id}'s measured height is unchanged"
5664                );
5665            } else {
5666                departed += 1;
5667                assert!(
5668                    entry.is_none(),
5669                    "row {id} left this frame's reconciled window and neither \
5670                     departing-row hypothesis re-explains its new position — the \
5671                     conservative eviction this fix restores (re-measured on its \
5672                     next visit, never a permanent leak)"
5673                );
5674            }
5675        }
5676        assert!(survivors > 0, "the survivor assertion above is exercised");
5677        assert!(
5678            departed > 0,
5679            "the window-departure assertion above is exercised — if this ever \
5680             stops firing (e.g. a wider window swallows the whole shift), widen \
5681             the mutation so the same-frame-both-sides mismatch still orphans a \
5682             row and this test keeps covering both outcomes"
5683        );
5684    }
5685
5686    #[test]
5687    fn probe_miss_with_survivor_evicts_a_row_that_left_the_data() {
5688        // A same-frame mutation on both sides of the anchor (prepend above
5689        // the viewport, remove the topmost on-screen row itself, append
5690        // below) is the same false-negative shape
5691        // `same_frame_prepend_and_append_leaves_the_measured_cache_intact`
5692        // pins for the anchor-shift probe: removing the *first* on-screen
5693        // row means every remaining on-screen row's actual shift (+2: +3
5694        // from the prepend, −1 from the row removed ahead of it) differs
5695        // from the frame's net `item_count` delta (+3: +3 prepended, −1
5696        // removed, +1 appended), so the probe's two hypotheses (unchanged
5697        // position, or shifted by the net delta) miss for every key in the
5698        // previous window — `anchor_probe_missed`.
5699        //
5700        // Unlike that test, `reconcile_keyed`'s own exact per-slot lookup
5701        // still finds *some* of those rows in the new window
5702        // (`any_survivor`) — the precondition this test targets: a
5703        // probe-miss frame that is *not* a wholesale replace, so the
5704        // measured cache's own wholesale reset stays off. But the removed
5705        // row genuinely left the *data*, not merely the window, and must
5706        // still be evicted by the per-row eviction loop this fix restores —
5707        // left alone it would leak forever, since `record_measurement`
5708        // never revives a dead key and this frame's *growing* `item_count`
5709        // never triggers the shrink-only eviction rule either.
5710        let fx = VarRows::new((0..60).collect());
5711        let mut logic = fx.logic();
5712        let mut root = converged_var(&mut logic, &fx);
5713        root.event(&mut (), &wheel(400.0));
5714        settle(&mut root, &mut logic, &fx, 100.0, 4);
5715
5716        let w = list_widget(&root);
5717        let on_screen_ids: Vec<u64> = w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
5718        assert!(
5719            on_screen_ids.len() > 1,
5720            "the window materialized more than the victim alone"
5721        );
5722        let victim = on_screen_ids[0];
5723        let offset_before = w.offset();
5724        let count_before = w.measured.len();
5725        let sum_before = w.measured_sum;
5726        assert!(
5727            w.measured.contains_key(&ChildKey::new(victim)),
5728            "the victim was already measured, laid out once by `converged_var`"
5729        );
5730
5731        // Prepend 3 above the viewport, remove the topmost on-screen row
5732        // itself, and append 1 below — all in one keyed rebuild.
5733        {
5734            let mut rows = fx.rows.borrow_mut();
5735            rows.splice(0..0, [9_001u64, 9_002, 9_003]);
5736            rows.retain(|&id| id != victim);
5737            rows.push(9_050);
5738        }
5739        root.rebuild(&mut logic, &mut ()); // rebuild alone: pins cache state, not paint/layout
5740
5741        let w = list_widget(&root);
5742        assert_eq!(
5743            w.offset(),
5744            offset_before,
5745            "the anchor probe missed, so no anchor-shift correction ran this \
5746             frame — the offset a genuine prepend/removal would anchor is \
5747             untouched here"
5748        );
5749
5750        let new_window_ids: std::collections::HashSet<u64> =
5751            w.window().iter().map(|&i| fx.rows.borrow()[i]).collect();
5752        assert!(
5753            on_screen_ids[1..]
5754                .iter()
5755                .any(|id| new_window_ids.contains(id)),
5756            "at least one other previously on-screen row is still reconciled \
5757             into this frame's window by exact key — `any_survivor`, the \
5758             precondition that keeps the wholesale clear from firing"
5759        );
5760        for id in &on_screen_ids[1..] {
5761            if new_window_ids.contains(id) {
5762                assert!(
5763                    w.measured.contains_key(&ChildKey::new(*id)),
5764                    "row {id} is still named by this frame's reconciled \
5765                     window, so its measurement must survive"
5766                );
5767            }
5768        }
5769
5770        assert!(
5771            !w.measured.contains_key(&ChildKey::new(victim)),
5772            "the removed row's measurement is evicted that same frame, even \
5773             though the anchor probe missed — this fix's whole point"
5774        );
5775        assert!(
5776            w.measured.len() <= count_before,
5777            "the cache never grows across a rebuild-only pass over a removal"
5778        );
5779        assert!(
5780            (w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
5781            "the running sum still matches the surviving entries — \
5782             `measured_sum` and `measured` stay consistent, no leak"
5783        );
5784        assert!(
5785            w.measured_sum < sum_before,
5786            "the sum strictly dropped (at least the victim's own extent left it)"
5787        );
5788    }
5789
5790    #[test]
5791    fn variable_truncation_evicts_measurements_past_the_new_end() {
5792        let fx = VarRows::new((0..40).collect());
5793        let mut logic = fx.logic();
5794        let mut root = converged_var(&mut logic, &fx);
5795
5796        // Visit the far end so those rows carry measurements, then drop them from
5797        // the data while the viewport is nowhere near them.
5798        root.event(&mut (), &wheel(2_000.0));
5799        settle(&mut root, &mut logic, &fx, 100.0, 4);
5800        assert!(list_widget(&root).measured.len() > 10);
5801
5802        fx.rows.borrow_mut().truncate(5);
5803        root.rebuild(&mut logic, &mut ());
5804
5805        let w = list_widget(&root);
5806        assert!(
5807            w.measured.values().all(|m| m.index < 5),
5808            "every entry the shrunk keying cannot produce is gone"
5809        );
5810        assert!(
5811            (w.measured_sum - w.measured.values().map(|m| m.extent).sum::<f64>()).abs() < 1e-9,
5812            "the running sum still matches the surviving entries"
5813        );
5814    }
5815
5816    #[test]
5817    fn variable_prepend_anchors_by_the_estimate() {
5818        // Anchoring (the previous step) still holds in variable-extent mode: the
5819        // prepended rows are unmeasured by definition, so the correction — and
5820        // the prefix anchor it moves with it — steps by the estimate.
5821        let fx = VarRows::new((0..200).collect());
5822        let mut logic = fx.logic();
5823        let mut root = converged_var(&mut logic, &fx);
5824
5825        root.event(&mut (), &wheel(600.0));
5826        settle(&mut root, &mut logic, &fx, 100.0, 4);
5827        let anchor_id = list_widget(&root).window()[0] as u64;
5828        let offset_before = list_widget(&root).offset();
5829        let y_before = list_widget(&root).children[0].origin().y;
5830        let painted_before = fx.log.painted();
5831
5832        fx.rows.borrow_mut().splice(0..0, [9001, 9002, 9003]);
5833        let (flags, _) = var_frame(&mut root, &mut logic, &fx, 200.0);
5834
5835        let w = list_widget(&root);
5836        assert_eq!(
5837            w.offset(),
5838            offset_before + 3.0 * ESTIMATE,
5839            "the offset absorbs three unmeasured rows' worth of prepend"
5840        );
5841        assert_eq!(
5842            w.children[0].origin().y,
5843            y_before,
5844            "the anchor row paints at the same pixel position"
5845        );
5846        assert_eq!(w.keys[0] as u64, anchor_id + 3, "shifted by three indices");
5847        assert_eq!(
5848            fx.log.painted()[&anchor_id],
5849            painted_before[&anchor_id],
5850            "and kept its own widget"
5851        );
5852        assert!(flags.contains(ChangeFlags::LAYOUT));
5853    }
5854
5855    #[test]
5856    fn an_unchanged_variable_frame_reports_no_layout_once_settled() {
5857        // The layout-skip contract survives the extent model: once measurements
5858        // have settled, an identical frame must be a no-op reconciliation, or
5859        // variable extents would degenerate into "relayout every frame".
5860        //
5861        // Paint's convergence check asks for another frame only while the
5862        // materialized window fails to *cover* the viewport, so a window that
5863        // measured out *wider* than it needs to be is trimmed by the next
5864        // rebuild instead — one more structural frame after `settle` returns,
5865        // then steady. That trim frame is the one allowance here.
5866        let fx = VarRows::new((0..60).collect());
5867        let mut logic = fx.logic();
5868        let mut root = converged_var(&mut logic, &fx);
5869        root.event(&mut (), &wheel(500.0));
5870        settle(&mut root, &mut logic, &fx, 100.0, 4);
5871        var_frame(&mut root, &mut logic, &fx, 200.0); // the trim frame
5872
5873        for k in 0..4 {
5874            let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 216.0 + 16.0 * k as f64);
5875            assert!(
5876                flags.is_empty(),
5877                "identical variable-extent frame {k} is a no-op reconciliation, got {flags:?}"
5878            );
5879            assert!(
5880                !outcome.needs_frame,
5881                "and it does not keep asking for another frame"
5882            );
5883        }
5884    }
5885
5886    #[test]
5887    fn variable_short_and_empty_lists_are_well_behaved() {
5888        // Content shorter than the viewport pins the offset at zero and still
5889        // tiles from measured heights; an empty list materializes nothing and
5890        // never walks a prefix at all.
5891        let fx = VarRows::new(vec![0, 1, 2]); // 40 + 60 + 80 = 180 < 200
5892        let mut logic = fx.logic();
5893        let mut root = converged_var(&mut logic, &fx);
5894
5895        let w = list_widget(&root);
5896        assert_eq!(w.window(), &[0, 1, 2]);
5897        assert_eq!(
5898            w.max_offset(),
5899            0.0,
5900            "content shorter than the viewport does not scroll"
5901        );
5902        assert_eq!(w.children[0].origin().y, 0.0);
5903        assert_eq!(
5904            w.children[2].origin().y,
5905            100.0,
5906            "rows 0 and 1 (40 + 60) stack above row 2"
5907        );
5908
5909        fx.rows.borrow_mut().clear();
5910        settle(&mut root, &mut logic, &fx, 100.0, 3);
5911        assert!(list_widget(&root).window().is_empty());
5912        assert_eq!(list_widget(&root).max_offset(), 0.0);
5913        assert!(list_widget(&root).measured.is_empty());
5914    }
5915
5916    #[cfg(debug_assertions)]
5917    #[test]
5918    #[should_panic(expected = "requires ListView::builder_keyed")]
5919    fn estimated_item_extent_needs_a_keyed_list() {
5920        // Variable extents cache a measurement under row identity; a positional
5921        // list has none. Debug trips the tripwire; release ignores the estimate
5922        // and stays uniform (documented on the builder method).
5923        let _ = list_view(10, ROW_EXTENT, |i| any::<(), _>(gen_stub(i)))
5924            .estimated_item_extent(ROW_EXTENT);
5925    }
5926
5927    // Duplicate keys are ambiguous: the debug tripwire is the contract (release
5928    // carries on with first-claim-wins, documented on `builder_keyed`).
5929
5930    // --- (12) Measured anchor correction + the fling's accumulate-then-apply. ---
5931
5932    /// A row whose id is `≡ 3 (mod 5)` measures [`TALL`] — well above
5933    /// [`ESTIMATE`] — and one `≡ 0 (mod 5)` measures [`SHORT`], well below it
5934    /// ([`var_height`] cycles 40/60/80/100/120 by `id % 5`). Stepping ids by 5
5935    /// therefore builds a list of one known height, in either direction away
5936    /// from the estimate.
5937    const TALL: f64 = 100.0;
5938    const SHORT: f64 = 40.0;
5939
5940    fn tall_ids(n: usize) -> Vec<u64> {
5941        (0..n).map(|i| i as u64 * 5 + 3).collect()
5942    }
5943
5944    fn short_ids(n: usize) -> Vec<u64> {
5945        (0..n).map(|i| i as u64 * 5).collect()
5946    }
5947
5948    /// The topmost row the viewport actually shows — the anchor a correction
5949    /// holds still — as `(row id, painted y)`, read straight off the live pods.
5950    fn anchor_row(w: &ListViewWidget, fx: &VarRows) -> (u64, f64) {
5951        let rows = fx.rows.borrow();
5952        for (slot, pod) in w.children.iter().enumerate() {
5953            if pod.origin().y + pod.size().height > 0.0 {
5954                return (rows[w.keys[slot]], pod.origin().y);
5955            }
5956        }
5957        panic!("no materialized row reaches the viewport top");
5958    }
5959
5960    #[test]
5961    fn measured_correction_holds_the_anchor_when_rows_measure_taller() {
5962        // Criterion 1, upward: a jump lands in a region whose rows all measure
5963        // 100 against a 60px estimate, so the two rows the window buffers above
5964        // the viewport top measure taller than the offset was planned against.
5965        let fx = VarRows::new(tall_ids(300));
5966        let mut logic = fx.logic();
5967        let mut root = converged_var(&mut logic, &fx);
5968
5969        root.event(&mut (), &wheel(1_500.0));
5970        var_frame(&mut root, &mut logic, &fx, 100.0);
5971
5972        let w = list_widget(&root);
5973        let pending = w.pending_correction;
5974        let offset_before = w.offset();
5975        let (anchor_id, anchor_y) = anchor_row(w, &fx);
5976        assert!(
5977            pending > 0.0,
5978            "the rows above the viewport top measured taller than assumed"
5979        );
5980
5981        // The convergence frame commits it, and the anchor row is painted at the
5982        // pixel position it already occupied — before *and* after.
5983        let (flags, outcome) = var_frame(&mut root, &mut logic, &fx, 116.0);
5984        let w = list_widget(&root);
5985        assert_eq!(w.pending_correction, 0.0, "the correction was committed");
5986        assert!(
5987            (w.offset() - (offset_before + pending)).abs() < 1e-9,
5988            "the offset absorbed exactly the measured delta"
5989        );
5990        let (id_after, y_after) = anchor_row(w, &fx);
5991        assert_eq!(id_after, anchor_id, "the same row is still at the top");
5992        assert!(
5993            (y_after - anchor_y).abs() < 1e-9,
5994            "the anchor row's painted y is unchanged across the correction \
5995             ({anchor_y} -> {y_after})"
5996        );
5997        assert!(flags.contains(ChangeFlags::LAYOUT));
5998        assert!(!outcome.needs_frame, "one convergence frame, then settled");
5999    }
6000
6001    #[test]
6002    fn measured_correction_holds_the_anchor_when_rows_measure_shorter() {
6003        // Criterion 1, the other direction: rows measure 40 against the same
6004        // 60px estimate, so the correction is negative and the offset moves
6005        // *back* to hold the anchor still.
6006        let fx = VarRows::new(short_ids(300));
6007        let mut logic = fx.logic();
6008        let mut root = converged_var(&mut logic, &fx);
6009
6010        root.event(&mut (), &wheel(1_500.0));
6011        var_frame(&mut root, &mut logic, &fx, 100.0);
6012
6013        let w = list_widget(&root);
6014        let pending = w.pending_correction;
6015        let offset_before = w.offset();
6016        let (anchor_id, anchor_y) = anchor_row(w, &fx);
6017        assert_eq!(
6018            w.children[0].size().height,
6019            SHORT,
6020            "the fixture's rows really do measure shorter than the estimate"
6021        );
6022        assert!(
6023            pending < 0.0,
6024            "the rows above the viewport top measured shorter than assumed"
6025        );
6026
6027        let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
6028        let w = list_widget(&root);
6029        assert_eq!(w.pending_correction, 0.0);
6030        assert!((w.offset() - (offset_before + pending)).abs() < 1e-9);
6031        let (id_after, y_after) = anchor_row(w, &fx);
6032        assert_eq!(id_after, anchor_id);
6033        assert!(
6034            (y_after - anchor_y).abs() < 1e-9,
6035            "the anchor row's painted y is unchanged across the correction \
6036             ({anchor_y} -> {y_after})"
6037        );
6038        assert!(flags.contains(ChangeFlags::LAYOUT));
6039    }
6040
6041    #[test]
6042    fn full_replace_after_a_pending_correction_discards_it_instead_of_committing_it() {
6043        // Wheel-jump into unmeasured tall rows so a real frame leaves
6044        // `pending_correction > 0` — the same idiom
6045        // `measured_correction_holds_the_anchor_when_rows_measure_taller` and
6046        // `drag_takeover_seeds_from_raw_offset_not_the_pending_corrected_painted_offset`
6047        // use to produce a genuine (not synthetic) correction, rather than
6048        // committing it on the very next ordinary frame.
6049        let fx = VarRows::new(tall_ids(300));
6050        let mut logic = fx.logic();
6051        let mut root = converged_var(&mut logic, &fx);
6052
6053        root.event(&mut (), &wheel(1_500.0));
6054        var_frame(&mut root, &mut logic, &fx, 100.0);
6055
6056        let w = list_widget(&root);
6057        let pending = w.pending_correction;
6058        let offset_before = w.offset();
6059        assert!(
6060            pending > 0.0,
6061            "the rows above the viewport top measured taller than assumed"
6062        );
6063
6064        // Instead of an ordinary next frame (which would commit `pending`),
6065        // replace every id: no previous-window key survives either
6066        // hypothesis, a genuine full replace exactly like
6067        // `variable_full_replace_clears_the_measured_cache` — reset
6068        // semantics for the measured cache *and* for anchoring, on a frame
6069        // that also happens to be carrying a stale, now-unexplainable
6070        // correction computed against geometry this list no longer holds.
6071        *fx.rows.borrow_mut() = (0..300).map(|i| 10_000 + i).collect();
6072        root.rebuild(&mut logic, &mut ()); // rebuild alone: pins offset/cache state
6073
6074        let w = list_widget(&root);
6075        assert_eq!(
6076            w.pending_correction, 0.0,
6077            "the stale correction is discarded, not left pending"
6078        );
6079        assert_eq!(
6080            w.offset(),
6081            offset_before,
6082            "the offset did NOT absorb the stale correction — `item_count` is \
6083             unchanged (300 before and after) and {offset_before} is already \
6084             far inside `max_offset` either way, so the only way this could \
6085             differ is the bug this pins: committing `pending` (giving \
6086             {}) into geometry the full replace made unexplainable",
6087            offset_before + pending
6088        );
6089        assert!(
6090            w.measured.is_empty(),
6091            "a full replace clears the measured cache too (the round-0 fix, \
6092             still intact — `reconcile_keyed`'s wholesale-clear branch)"
6093        );
6094        assert_eq!(w.measured_sum, 0.0);
6095    }
6096
6097    #[test]
6098    fn drag_takeover_seeds_from_raw_offset_not_the_pending_corrected_painted_offset() {
6099        // Wheel-jump into unmeasured tall rows so a real frame leaves
6100        // `pending_correction > 0` — mirrors
6101        // `measured_correction_holds_the_anchor_when_rows_measure_taller`'s
6102        // idiom for producing a genuine (not synthetic) pending correction.
6103        let fx = VarRows::new(tall_ids(300));
6104        let mut logic = fx.logic();
6105        let mut root = converged_var(&mut logic, &fx);
6106
6107        root.event(&mut (), &wheel(1_500.0));
6108        var_frame(&mut root, &mut logic, &fx, 100.0);
6109
6110        let w = list_widget(&root);
6111        let pending = w.pending_correction;
6112        assert!(
6113            pending > 0.0,
6114            "the rows above the viewport top measured taller than assumed"
6115        );
6116        let painted_before = w.painted_offset();
6117
6118        // WITHOUT another rebuild: Down -> Move past TOUCH_SLOP (takeover) ->
6119        // a second Move with a known finger delta `dy`.
6120        let down_y = 100.0;
6121        root.event(&mut (), &ev(PointerPhase::Down, down_y));
6122        let takeover_y = down_y + TOUCH_SLOP + 1.0;
6123        root.event(&mut (), &ev(PointerPhase::Move, takeover_y)); // takeover
6124        assert!(
6125            list_widget(&root).scrolling,
6126            "the slop crossing took the gesture over"
6127        );
6128        assert_eq!(
6129            list_widget(&root).pending_correction,
6130            pending,
6131            "takeover itself never touches pending_correction"
6132        );
6133
6134        let dy = 30.0;
6135        root.event(&mut (), &ev(PointerPhase::Move, takeover_y + dy));
6136
6137        let w = list_widget(&root);
6138        assert!(
6139            (w.painted_offset() - (painted_before - dy)).abs() < 1e-9,
6140            "painted offset after the second Move equals the pre-takeover \
6141             painted offset minus exactly the finger delta \
6142             ({painted_before} - {dy} = {}, got {})",
6143            painted_before - dy,
6144            w.painted_offset()
6145        );
6146        assert_eq!(
6147            w.pending_correction, pending,
6148            "the drag path never commits or otherwise touches pending_correction \
6149             (only a rebuild does — see `ListViewWidget::apply_pending_correction`)"
6150        );
6151    }
6152
6153    #[test]
6154    fn variable_prepend_refines_from_the_estimate_to_the_measurement() {
6155        // Criterion 2's two steps, in consecutive frames: reconciliation shifts
6156        // by the estimate (a prepended row is unmeasured by definition), then
6157        // the frame that measures those rows refines the shift to the truth —
6158        // with the anchor row painting at the same y at every step.
6159        let fx = VarRows::new(tall_ids(60));
6160        let mut logic = fx.logic();
6161        let mut root = converged_var(&mut logic, &fx);
6162
6163        // Half a row down: the viewport top sits inside row 0, so a prepend
6164        // lands *inside* the materialized window and is measurable at all (a
6165        // prepend far above the window can only ever be estimated — see the
6166        // module docs' *What stays estimated*).
6167        root.event(&mut (), &wheel(TALL / 2.0));
6168        settle(&mut root, &mut logic, &fx, 100.0, 4);
6169        let w = list_widget(&root);
6170        let (anchor_id, anchor_y) = anchor_row(w, &fx);
6171        let offset_before = w.offset();
6172
6173        // Step one: the estimate-based shift.
6174        fx.rows.borrow_mut().splice(0..0, [5_003u64, 5_008u64]);
6175        var_frame(&mut root, &mut logic, &fx, 200.0);
6176        let w = list_widget(&root);
6177        assert!(
6178            (w.offset() - (offset_before + 2.0 * ESTIMATE)).abs() < 1e-9,
6179            "reconciliation shifts by the estimate first"
6180        );
6181        assert!(
6182            (w.pending_correction - 2.0 * (TALL - ESTIMATE)).abs() < 1e-9,
6183            "and layout records what the two prepended rows actually measured"
6184        );
6185        let (id_mid, y_mid) = anchor_row(w, &fx);
6186        assert_eq!(id_mid, anchor_id);
6187        assert!(
6188            (y_mid - anchor_y).abs() < 1e-9,
6189            "the anchor row did not move on the reconciling frame"
6190        );
6191
6192        // Step two: the measured refinement.
6193        let (flags, _) = var_frame(&mut root, &mut logic, &fx, 216.0);
6194        let w = list_widget(&root);
6195        assert_eq!(w.pending_correction, 0.0);
6196        assert!(
6197            (w.offset() - (offset_before + 2.0 * TALL)).abs() < 1e-9,
6198            "the offset ends up shifted by the prepended rows' *true* extent"
6199        );
6200        let (id_after, y_after) = anchor_row(w, &fx);
6201        assert_eq!(id_after, anchor_id, "still the same row at the top");
6202        assert!(
6203            (y_after - anchor_y).abs() < 1e-9,
6204            "and it never moved across either step"
6205        );
6206        assert!(flags.contains(ChangeFlags::LAYOUT));
6207    }
6208
6209    /// Fling **upward** (finger dragging down) over rows nothing has measured,
6210    /// pumping frames until the fling comes to rest. Returns one `(offset, still
6211    /// flinging, pending correction)` sample per frame, starting at the release.
6212    ///
6213    /// Upward is the interesting direction: rows enter the window *above* the
6214    /// viewport top, so every frame measures rows that correct the offset —
6215    /// against the fling when they measure taller than the estimate.
6216    fn fling_up_and_pump(
6217        root: &mut RenderRoot<(), ListView<()>>,
6218        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
6219        fx: &VarRows,
6220        first_ms: f64,
6221    ) -> Vec<(f64, bool, f64)> {
6222        root.event(&mut (), &ev(PointerPhase::Down, 20.0));
6223        var_frame(root, logic, fx, first_ms);
6224        root.event(&mut (), &ev(PointerPhase::Move, 90.0)); // takeover
6225        var_frame(root, logic, fx, first_ms + 16.0);
6226        root.event(&mut (), &ev(PointerPhase::Move, 190.0)); // build velocity
6227        root.event(&mut (), &ev(PointerPhase::Up, 190.0)); // release
6228        assert!(
6229            list_widget(root).is_flinging(),
6230            "the release started a fling"
6231        );
6232
6233        let w = list_widget(root);
6234        let mut trace = vec![(w.offset(), true, w.pending_correction)];
6235        for k in 0..300 {
6236            var_frame(root, logic, fx, first_ms + 32.0 + 16.0 * k as f64);
6237            let w = list_widget(root);
6238            trace.push((w.offset(), w.is_flinging(), w.pending_correction));
6239            if !w.is_flinging() {
6240                return trace;
6241            }
6242        }
6243        panic!("the fling never came to rest");
6244    }
6245
6246    /// Assert a fling trajectory only ever moved in the fling's own direction —
6247    /// the monotonicity a per-frame correction against a paint-advancing offset
6248    /// would break.
6249    fn assert_monotonic_upward(trace: &[(f64, bool, f64)]) {
6250        for pair in trace.windows(2) {
6251            let (before, _, _) = pair[0];
6252            let (after, _, _) = pair[1];
6253            assert!(
6254                after <= before,
6255                "the offset stepped backwards during an upward fling \
6256                 ({before} -> {after})"
6257            );
6258        }
6259    }
6260
6261    #[test]
6262    fn an_upward_fling_accumulates_the_correction_and_applies_it_at_settle() {
6263        // Criterion 3. Every frame of this fling pulls unmeasured rows in above
6264        // the viewport top and measures them *taller*, i.e. a correction
6265        // pointing against the fling: withheld, the trajectory is the fling's
6266        // alone; applied per frame it would step backwards once the decaying
6267        // fling advance drops under the per-frame correction.
6268        let fx = VarRows::new(tall_ids(300));
6269        let mut logic = fx.logic();
6270        let mut root = converged_var(&mut logic, &fx);
6271
6272        // Jump deep into never-measured territory to fling back up through.
6273        root.event(&mut (), &wheel(8_000.0));
6274        settle(&mut root, &mut logic, &fx, 100.0, 4);
6275
6276        let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
6277        assert_monotonic_upward(&trace);
6278        assert!(
6279            trace.iter().any(|&(_, _, pending)| pending > 0.0),
6280            "the fling crossed rows measuring taller than the estimate"
6281        );
6282        let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
6283        assert!(
6284            owed > 0.0,
6285            "the whole accumulated sum is still owed when the fling stops"
6286        );
6287
6288        // The first rebuild after it stops commits the sum — invisibly.
6289        let w = list_widget(&root);
6290        let (anchor_id, anchor_y) = anchor_row(w, &fx);
6291        let (flags, _) = var_frame(&mut root, &mut logic, &fx, 6_000.0);
6292        let w = list_widget(&root);
6293        assert!(
6294            (w.offset() - (offset_at_rest + owed)).abs() < 1e-9,
6295            "settling committed the accumulated correction in full"
6296        );
6297        let (id_after, y_after) = anchor_row(w, &fx);
6298        assert_eq!(id_after, anchor_id, "the same row is still at the top");
6299        assert!(
6300            (y_after - anchor_y).abs() < 1e-9,
6301            "committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
6302        );
6303        assert!(flags.contains(ChangeFlags::LAYOUT));
6304
6305        // ...and the list comes to rest instead of oscillating.
6306        let mut settled = None;
6307        for k in 0..6 {
6308            let before = list_widget(&root).offset();
6309            var_frame(&mut root, &mut logic, &fx, 6_016.0 + 16.0 * k as f64);
6310            let w = list_widget(&root);
6311            if w.pending_correction == 0.0 && w.offset() == before {
6312                settled = Some(before);
6313                break;
6314            }
6315        }
6316        let settled = settled.expect("the list settles after the fling");
6317        for k in 0..3 {
6318            var_frame(&mut root, &mut logic, &fx, 6_112.0 + 16.0 * k as f64);
6319            assert_eq!(
6320                list_widget(&root).offset(),
6321                settled,
6322                "the offset is at rest, not oscillating"
6323            );
6324        }
6325    }
6326
6327    #[test]
6328    fn an_upward_fling_over_shorter_rows_accumulates_the_other_way() {
6329        // The mirror: rows measure 40 against the same 60px estimate, so the
6330        // withheld sum is negative — the direction that, applied per frame,
6331        // over-travels the fling rather than reversing it. Same contract.
6332        let fx = VarRows::new(short_ids(400));
6333        let mut logic = fx.logic();
6334        let mut root = converged_var(&mut logic, &fx);
6335
6336        root.event(&mut (), &wheel(8_000.0));
6337        settle(&mut root, &mut logic, &fx, 100.0, 4);
6338
6339        let trace = fling_up_and_pump(&mut root, &mut logic, &fx, 200.0);
6340        assert_monotonic_upward(&trace);
6341        let (offset_at_rest, _, owed) = *trace.last().expect("the fling stopped");
6342        assert!(owed < 0.0, "shorter-than-estimated rows owe a negative sum");
6343
6344        let w = list_widget(&root);
6345        let (anchor_id, anchor_y) = anchor_row(w, &fx);
6346        var_frame(&mut root, &mut logic, &fx, 6_000.0);
6347        let w = list_widget(&root);
6348        assert!((w.offset() - (offset_at_rest + owed)).abs() < 1e-9);
6349        let (id_after, y_after) = anchor_row(w, &fx);
6350        assert_eq!(id_after, anchor_id);
6351        assert!(
6352            (y_after - anchor_y).abs() < 1e-9,
6353            "committing an accumulated correction is invisible ({anchor_y} -> {y_after})"
6354        );
6355    }
6356
6357    #[test]
6358    fn layout_and_paint_never_move_the_offset_while_a_correction_is_pending() {
6359        // Criterion 4, pass by pass: layout may measure a correction but never
6360        // apply one, paint touches the offset only through the fling pump (inert
6361        // here), and rebuild is the one pass that commits.
6362        let fx = VarRows::new(tall_ids(200));
6363        let mut logic = fx.logic();
6364        let mut root = converged_var(&mut logic, &fx);
6365
6366        root.event(&mut (), &wheel(1_500.0));
6367        fx.probe.pass.set(Pass::Rebuild);
6368        root.rebuild(&mut logic, &mut ());
6369        let after_rebuild = list_widget(&root).offset();
6370
6371        fx.probe.pass.set(Pass::Layout);
6372        root.layout(KEYED_WINDOW);
6373        let w = list_widget(&root);
6374        assert!(
6375            w.pending_correction != 0.0,
6376            "layout measured a correction to record"
6377        );
6378        assert_eq!(
6379            w.offset(),
6380            after_rebuild,
6381            "...and did not apply it to the offset"
6382        );
6383
6384        fx.probe.pass.set(Pass::Paint);
6385        let mut sink = NullScene;
6386        let outcome = root.paint(&mut sink, FrameTime::from_nanos(100_000_000));
6387        fx.probe.pass.set(Pass::Idle);
6388        let w = list_widget(&root);
6389        assert!(
6390            !w.is_flinging(),
6391            "no fling is in flight, so the pump is inert"
6392        );
6393        assert_eq!(
6394            w.offset(),
6395            after_rebuild,
6396            "paint moves the offset only via the fling pump"
6397        );
6398        assert!(w.pending_correction != 0.0, "the correction is still owed");
6399        assert!(
6400            outcome.needs_frame,
6401            "and paint asked for the frame that commits it"
6402        );
6403
6404        let (flags, _) = var_frame(&mut root, &mut logic, &fx, 116.0);
6405        let w = list_widget(&root);
6406        assert_eq!(w.pending_correction, 0.0);
6407        assert!(
6408            w.offset() > after_rebuild,
6409            "rebuild is the pass that commits it"
6410        );
6411        assert!(flags.contains(ChangeFlags::LAYOUT));
6412    }
6413
6414    #[test]
6415    fn uniform_and_positional_modes_never_carry_a_correction() {
6416        // Criterion 5: this is variable-extent-only machinery. A uniform keyed
6417        // list and a positional list measure nothing, so neither can ever owe a
6418        // correction — and their placement is literally their offset.
6419        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
6420        let mut keyed = fx.keyed_logic();
6421        let mut root = converged(&mut keyed, &fx.log);
6422        root.event(&mut (), &wheel(500.0));
6423        keyed_frame(&mut root, &mut keyed, &fx.log, 32.0);
6424        fx.rows.borrow_mut().splice(0..0, [9990, 9980]);
6425        keyed_frame(&mut root, &mut keyed, &fx.log, 48.0);
6426        let w = list_widget(&root);
6427        assert_eq!(
6428            w.pending_correction, 0.0,
6429            "a uniform keyed list measures nothing to correct from"
6430        );
6431        assert_eq!(w.placement_offset(), w.offset());
6432
6433        let fx = KeyedRows::new((0..1000).map(|i| i as u64 * 10).collect());
6434        let mut positional = fx.positional_logic();
6435        let mut root = converged(&mut positional, &fx.log);
6436        root.event(&mut (), &wheel(500.0));
6437        keyed_frame(&mut root, &mut positional, &fx.log, 32.0);
6438        fx.rows.borrow_mut().insert(0, 12_345);
6439        keyed_frame(&mut root, &mut positional, &fx.log, 48.0);
6440        let w = list_widget(&root);
6441        assert_eq!(
6442            w.pending_correction, 0.0,
6443            "a positional list measures nothing to correct from"
6444        );
6445        assert_eq!(w.placement_offset(), w.offset());
6446    }
6447
6448    /// A hand-built variable-extent widget carrying an uncommitted correction and
6449    /// a near-start callback — the deterministic shape of "an uncommitted
6450    /// correction decides no edge" (the [`near_start_widget`] idiom).
6451    fn pending_correction_widget(pending: f64) -> ListViewWidget {
6452        let mut w = ListViewWidget::new(1000, ESTIMATE);
6453        w.key_of = Some(Rc::new(|i: usize| ChildKey::new(i as u64)));
6454        w.estimated_extent = Some(ESTIMATE);
6455        w.viewport = Size::new(200.0, 200.0);
6456        w.near_start_threshold = 100.0;
6457        let cb: Rc<dyn Fn(&mut Loads)> = Rc::new(|s: &mut Loads| s.count += 1);
6458        w.on_near_start = Some(crate::authoring::erase_callback(&cb));
6459        w.near_start_armed = true;
6460        // The raw offset sits inside the near-start threshold; the content it
6461        // describes does not.
6462        w.offset = 50.0;
6463        w.pending_correction = pending;
6464        w
6465    }
6466
6467    #[test]
6468    fn an_uncommitted_correction_decides_no_edge() {
6469        // The edge-latch half: edges read the placement, so an uncommitted
6470        // correction neither fires one on the raw offset's say-so nor re-fires
6471        // one when it commits (the placement is exactly what a commit leaves
6472        // unchanged).
6473        let mut w = pending_correction_widget(500.0);
6474        let mut state = Loads::default();
6475        run_loads(&mut w, &mut state, &wheel(0.0));
6476        assert_eq!(
6477            state.count, 0,
6478            "at a placement of 550 the list is nowhere near the start"
6479        );
6480
6481        assert!(w.apply_pending_correction(), "the commit applies");
6482        assert_eq!(w.offset(), 550.0);
6483        assert_eq!(w.pending_correction, 0.0);
6484        run_loads(&mut w, &mut state, &wheel(0.0));
6485        assert_eq!(
6486            state.count, 0,
6487            "committing a correction is bookkeeping, not scroll motion"
6488        );
6489
6490        // A genuine approach still fires it, exactly once.
6491        run_loads(&mut w, &mut state, &wheel(-500.0));
6492        assert_eq!(w.offset(), 50.0);
6493        assert_eq!(state.count, 1, "a real approach fires the load-older edge");
6494        run_loads(&mut w, &mut state, &wheel(-20.0));
6495        assert_eq!(state.count, 1, "and it stays edge-triggered");
6496    }
6497
6498    #[test]
6499    fn a_live_fling_withholds_the_correction_until_it_stops() {
6500        // The accumulate-then-apply rule at its own granularity, over both ways
6501        // a fling ends: decaying under FLING_STOP, and a pointer taking over.
6502        let mut w = pending_correction_widget(500.0);
6503        w.fling = Some(-40.0);
6504        assert!(
6505            !w.apply_pending_correction(),
6506            "a live fling withholds the commit"
6507        );
6508        assert_eq!(w.pending_correction, 500.0, "the sum keeps accumulating");
6509        for _ in 0..12 {
6510            w.tick(16.0);
6511        }
6512        assert!(!w.is_flinging(), "the fling decayed under FLING_STOP");
6513        assert!(w.apply_pending_correction(), "and the next rebuild commits");
6514        assert_eq!(w.pending_correction, 0.0);
6515
6516        // Pointer-down takeover: same commit, one event earlier.
6517        let mut w = pending_correction_widget(500.0);
6518        let mut state = Loads::default();
6519        w.fling = Some(-4_000.0);
6520        assert!(!w.apply_pending_correction());
6521        run_loads(&mut w, &mut state, &ev(PointerPhase::Down, 50.0));
6522        assert!(!w.is_flinging(), "a pointer down takes the gesture over");
6523        assert!(w.apply_pending_correction());
6524        assert_eq!(w.pending_correction, 0.0);
6525    }
6526
6527    #[cfg(debug_assertions)]
6528    #[test]
6529    #[should_panic(expected = "duplicate key")]
6530    fn duplicate_keys_trip_the_debug_assert_on_build() {
6531        let mut logic = |_: &mut ()| {
6532            ListView::builder_keyed(
6533                5,
6534                ROW_EXTENT,
6535                |_| ChildKey::new(7u32),
6536                |i| any::<(), _>(gen_stub(i)),
6537            )
6538        };
6539        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
6540        frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
6541    }
6542
6543    #[cfg(debug_assertions)]
6544    #[test]
6545    #[should_panic(expected = "duplicate key")]
6546    fn duplicate_keys_trip_the_debug_assert_on_rebuild() {
6547        let collide = Rc::new(Cell::new(false));
6548        let collide_l = collide.clone();
6549        let mut logic = move |_: &mut ()| {
6550            let collide = collide_l.clone();
6551            ListView::builder_keyed(
6552                5,
6553                ROW_EXTENT,
6554                move |i| ChildKey::new(if collide.get() { 7 } else { i }),
6555                |i| any::<(), _>(gen_stub(i)),
6556            )
6557        };
6558        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
6559        frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 0.0);
6560        collide.set(true);
6561        frame(&mut root, &mut logic, &mut (), KEYED_WINDOW, 16.0);
6562    }
6563
6564    // --- (13) The M3E stretch effect, the twin of `scroll.rs`'s own group:
6565    //      rows stay where the windowing offset puts them and the pull is
6566    //      painted as an affine about the held edge instead. ---
6567
6568    /// [`rubber_band_logic`] with [`OverscrollEffect::Stretch`] selected on the
6569    /// view, so the effect reaches the widget through the real build/rebuild
6570    /// path rather than being poked onto the element. Pinned to `RubberBand`
6571    /// like the rest of the feel fixtures — the pull *values* the stretch is
6572    /// read from are this physics' (the effect itself is physics-agnostic, and
6573    /// `stretch_under_boundary_rejection_uses_edge_pull` covers the clamping
6574    /// pairing the Android default ships).
6575    fn stretch_logic(item_count: usize) -> impl FnMut(&mut ()) -> ListView<()> {
6576        move |_: &mut ()| {
6577            let mut view = list_view(item_count, 50.0, |i| any::<(), _>(gen_stub(i)))
6578                .physics(RubberBand::new());
6579            view.effect = OverscrollEffect::Stretch;
6580            view
6581        }
6582    }
6583
6584    /// Drive the shared past-top drag: `Down`, a 40px past-slop takeover, then
6585    /// 20px further past the already-at-top edge (`scroll.rs`'s fixture
6586    /// gesture), painting a frame between each so the tree stays live.
6587    fn drag_20px_past_top(
6588        root: &mut RenderRoot<(), ListView<()>>,
6589        logic: &mut impl FnMut(&mut ()) -> ListView<()>,
6590        state: &mut (),
6591        window: Size,
6592    ) {
6593        frame(root, logic, state, window, 0.0);
6594        frame(root, logic, state, window, 16.0);
6595        root.event(state, &ev(PointerPhase::Down, 50.0));
6596        frame(root, logic, state, window, 32.0);
6597        root.event(state, &ev(PointerPhase::Move, 90.0)); // 40px > slop → takeover
6598        frame(root, logic, state, window, 48.0);
6599        root.event(state, &ev(PointerPhase::Move, 110.0));
6600    }
6601
6602    /// Paint the live tree into a recording scene and hand back the transforms
6603    /// the stretch pushed (mirrors `scroll.rs`'s `painted_stretch` observable).
6604    fn painted_transforms(root: &mut RenderRoot<(), ListView<()>>, ms: f64) -> Vec<kurbo::Affine> {
6605        let mut scene = crate::test_support::RecordingScene::default();
6606        root.paint(&mut scene, FrameTime::from_nanos((ms * 1_000_000.0) as u64));
6607        assert_eq!(
6608            scene.transforms.len(),
6609            scene.transform_pops as usize,
6610            "every pushed transform must be popped in the same paint"
6611        );
6612        scene.transforms
6613    }
6614
6615    #[test]
6616    fn stretch_keeps_row_origins_fixed() {
6617        let window = Size::new(200.0, 200.0);
6618
6619        // The same drag under each effect: the physics' answer is identical,
6620        // only what paint does with it differs.
6621        let mut translated: RenderRoot<(), ListView<()>> = RenderRoot::new();
6622        drag_20px_past_top(
6623            &mut translated,
6624            &mut rubber_band_logic(1000),
6625            &mut (),
6626            window,
6627        );
6628        let mut stretched: RenderRoot<(), ListView<()>> = RenderRoot::new();
6629        drag_20px_past_top(&mut stretched, &mut stretch_logic(1000), &mut (), window);
6630
6631        let translate = list_widget(&translated);
6632        let stretch = list_widget(&stretched);
6633        assert_eq!(stretch.offset(), 0.0, "the windowing offset never moves");
6634        assert_eq!(
6635            stretch.overscroll, -10.0,
6636            "the resisted displacement is the physics' answer, effect or not"
6637        );
6638        assert_eq!(translate.overscroll, stretch.overscroll);
6639        assert_eq!(translate.edge_pull, stretch.edge_pull);
6640
6641        // Row 0 sits at content y = 0: Translate paints it 10px down (the
6642        // displacement), Stretch leaves it at the viewport's top edge.
6643        assert_eq!(translate.children[0].origin().y, 10.0);
6644        assert_eq!(stretch.children[0].origin().y, 0.0);
6645        assert_eq!(
6646            translate.children[0].origin().y - stretch.children[0].origin().y,
6647            -stretch.overscroll,
6648            "the two fixtures differ by exactly the overscroll displacement"
6649        );
6650
6651        // …and only the stretched one paints a transform for the pull, about
6652        // the pulled (top) edge: `[1, 0, 0, s, 0, anchor·(1 − s)]`.
6653        assert!(painted_transforms(&mut translated, 64.0).is_empty());
6654        let transforms = painted_transforms(&mut stretched, 64.0);
6655        assert_eq!(
6656            transforms.len(),
6657            1,
6658            "one stretch transform for the viewport"
6659        );
6660        let [a, b, c, d, e, f] = transforms[0].as_coeffs();
6661        assert_eq!([a, b, c, e], [1.0, 0.0, 0.0, 0.0], "scroll-axis-only scale");
6662        assert!(
6663            (d - (1.0 + crate::scroll::stretch_intensity(-10.0, 200.0))).abs() < 1e-12,
6664            "scale is 1 + the shared curve's intensity: {d}"
6665        );
6666        assert!(
6667            f.abs() < 1e-9,
6668            "a top pull scales about the viewport's top edge: {f}"
6669        );
6670    }
6671
6672    #[test]
6673    fn stretch_settles_back_to_identity() {
6674        let window = Size::new(200.0, 200.0);
6675        let mut logic = stretch_logic(1000);
6676        let mut root: RenderRoot<(), ListView<()>> = RenderRoot::new();
6677        let mut state = ();
6678        drag_20px_past_top(&mut root, &mut logic, &mut state, window);
6679        assert_eq!(painted_transforms(&mut root, 64.0).len(), 1);
6680
6681        // Release under the refresh trigger → the settle decays the pull, and
6682        // the stretch with it (no `request_layout` anywhere: the pump's own
6683        // continuation frames are what keep it animating).
6684        root.event(&mut state, &ev(PointerPhase::Up, 110.0));
6685        assert!(list_widget(&root).settling);
6686        let mut ms = 80.0;
6687        for _ in 0..30 {
6688            frame(&mut root, &mut logic, &mut state, window, ms);
6689            ms += 16.0;
6690            if !list_widget(&root).settling {
6691                break;
6692            }
6693        }
6694        let w = list_widget(&root);
6695        assert!(!w.settling, "the settle terminated");
6696        assert_eq!(w.edge_pull, 0.0, "a completed settle leaves no pull");
6697        assert_eq!(w.overscroll, 0.0);
6698        assert_eq!(w.children[0].origin().y, 0.0, "the rows never moved");
6699        assert!(
6700            painted_transforms(&mut root, ms).is_empty(),
6701            "…so paint pushes no transform at all — back to identity"
6702        );
6703    }
6704
6705    /// The one transform a bare fixture's own `paint` pushes for the stretch,
6706    /// as `(anchor_y, scale_y)` off the
6707    /// `translate(anchor)·scale(1, s)·translate(−anchor)` sandwich — the twin
6708    /// of `scroll.rs`'s `painted_stretch`, over a widget with no materialized
6709    /// rows so the stretch is the whole scene. `None` when paint pushed none.
6710    ///
6711    /// **At rest only**: `paint` pumps the animation clock, so calling this
6712    /// mid-flight would reseed it against this context's zero frame time.
6713    fn painted_stretch_at_rest(w: &mut ListViewWidget) -> Option<(f64, f64)> {
6714        let mut ctx = PaintCtx::new(Point::ZERO, w.viewport);
6715        let mut scene = crate::test_support::RecordingScene::default();
6716        w.paint(&mut ctx, &mut scene);
6717        assert_eq!(
6718            scene.transforms.len(),
6719            scene.transform_pops as usize,
6720            "every pushed transform must be popped in the same paint"
6721        );
6722        let [a, b, c, d, e, f] = scene.transforms.first()?.as_coeffs();
6723        assert_eq!(
6724            [a, b, c, e],
6725            [1.0, 0.0, 0.0, 0.0],
6726            "a scroll-axis-only scale: no x scale, no skew, no x translation"
6727        );
6728        Some((f / (1.0 - d), d))
6729    }
6730
6731    /// The `ScrollView` twin of this test lives in `scroll.rs`, as
6732    /// `clamping_fling_into_the_edge_settles_the_stretch`.
6733    #[test]
6734    fn clamping_fling_into_the_edge_settles_the_stretch() {
6735        use crate::physics::Tolerance;
6736        use crate::physics::simulation::ClampingScrollSimulation;
6737
6738        // The shipped Android pairing, run on the host: parked 100px short of
6739        // the bottom, released at 1000 px/s straight into it. The clamping
6740        // curve is unbounded, so the driver pins the offset and routes the
6741        // whole overshoot into `edge_pull` — the release must not leave that
6742        // pull, and the stretch it paints, standing. A bare fixture (no rows
6743        // materialized): this pins the release/settle arithmetic, not
6744        // windowing.
6745        let mut w = ListViewWidget::new(1000, 50.0);
6746        w.viewport = Size::new(200.0, 200.0);
6747        w.physics = Rc::new(Clamping::new());
6748        w.effect = OverscrollEffect::Stretch;
6749        let bottom = w.max_offset();
6750        dispatch_list(&mut w, &wheel(bottom - 100.0), 0.0);
6751        assert_eq!(w.offset(), bottom - 100.0, "parked 100px short of the end");
6752
6753        dispatch_list(&mut w, &ev(PointerPhase::Down, 100.0), 0.0);
6754        dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 16.0); // 32px > slop
6755        dispatch_list(&mut w, &ev(PointerPhase::Move, 68.0), 32.0);
6756        dispatch_list(&mut w, &ev(PointerPhase::Up, 68.0), 32.0);
6757        assert_close(release_velocity(&w), 1000.0, 1e-9, "the release velocity");
6758        assert!(w.ballistic.is_some(), "…as a real ballistic curve");
6759
6760        let curve = ClampingScrollSimulation::new(
6761            bottom - 100.0,
6762            1000.0,
6763            ClampingScrollSimulation::DEFAULT_FRICTION,
6764            Tolerance::for_device_pixel_ratio(METRICS_FALLBACK_DPR),
6765        );
6766        assert!(
6767            curve.final_x() > bottom + 100.0,
6768            "the spline must overshoot the extent by >100px: {}",
6769            curve.final_x()
6770        );
6771        let spline_frames = (curve.duration() * 1000.0 / 16.0).ceil();
6772
6773        let mut ms = 100.0;
6774        let mut frames = 0.0;
6775        let mut ballistic_frames = 0.0;
6776        let mut peak = 0.0f64;
6777        loop {
6778            let mut ctx = PaintCtx::for_test(
6779                Point::ZERO,
6780                w.viewport,
6781                FrameTime::from_nanos((ms * 1_000_000.0) as u64),
6782            );
6783            w.pump_fling(&mut ctx);
6784            peak = peak.max(w.edge_pull.abs());
6785            if w.ballistic.is_some() {
6786                ballistic_frames += 1.0;
6787            }
6788            ms += 16.0;
6789            frames += 1.0;
6790            assert!(
6791                frames < 2_000.0,
6792                "the release never came to rest: edge_pull {}",
6793                w.edge_pull
6794            );
6795            if !ctx.needs_frame() {
6796                break;
6797            }
6798        }
6799
6800        assert!(
6801            peak > 1.0,
6802            "the fling must actually reach the edge for this to mean anything: {peak}"
6803        );
6804        assert_close(
6805            w.offset(),
6806            bottom,
6807            1e-9,
6808            "the offset ends pinned at the end",
6809        );
6810        assert_eq!(
6811            w.overscroll, 0.0,
6812            "the windowing offset carries no leftover"
6813        );
6814        assert_eq!(w.edge_pull, 0.0, "a finished fling leaves no pull standing");
6815        assert_eq!(
6816            painted_stretch_at_rest(&mut w),
6817            None,
6818            "…so paint pushes no transform at all — back to identity"
6819        );
6820        assert!(
6821            ballistic_frames < spline_frames / 2.0,
6822            "the pinned curve ran {ballistic_frames} frames of a {spline_frames}-frame spline"
6823        );
6824    }
6825
6826    /// The windowing list's mirror of `ScrollWidget`'s focus bypass: a clipboard
6827    /// verb reaches the focused *row* through `route_event`'s focus branch, not
6828    /// the gesture machinery and not a hit test.
6829    #[test]
6830    fn an_edit_command_reaches_the_focused_row() {
6831        use crate::text_input;
6832        use frust_core::EditCommand;
6833
6834        struct Field {
6835            value: String,
6836        }
6837        fn logic(state: &mut Field) -> ListView<Field> {
6838            let value = state.value.clone();
6839            list_view(3, 50.0, move |_| {
6840                let value = value.clone();
6841                any::<Field, _>(text_input(value, |s: &mut Field, v: String| {
6842                    s.value = v;
6843                }))
6844            })
6845        }
6846
6847        let mut state = Field {
6848            value: "hello".to_string(),
6849        };
6850        let mut root: RenderRoot<Field, ListView<Field>> = RenderRoot::new();
6851        root.rebuild(&mut logic, &mut state);
6852        root.layout(Size::new(200.0, 200.0));
6853
6854        // Tap the first row so it holds the recorded focus path.
6855        root.event(&mut state, &ev(PointerPhase::Down, 10.0));
6856        root.event(&mut state, &ev(PointerPhase::Up, 10.0));
6857        assert!(root.is_focus_active(), "the tap focused the row's field");
6858
6859        root.event(&mut state, &InputEvent::EditCommand(EditCommand::SelectAll));
6860        root.event(&mut state, &InputEvent::EditCommand(EditCommand::Copy));
6861
6862        assert_eq!(
6863            root.take_clipboard_write().as_deref(),
6864            Some("hello"),
6865            "the copy was answered by the focused row, through this router"
6866        );
6867        assert_eq!(state.value, "hello", "a copy edits nothing");
6868    }
6869}
6870
6871/// A takeover from a captured row ends a contact opt-in held inside it.
6872#[cfg(test)]
6873mod contact_release_tests {
6874    use super::*;
6875    use std::any::Any;
6876
6877    /// A row that captures every `Down`, opting into the gesture's other
6878    /// contacts when `opt_in` is set.
6879    struct Grab {
6880        opt_in: bool,
6881    }
6882    impl Widget for Grab {
6883        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
6884            bc.max()
6885        }
6886        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
6887        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
6888            if let InputEvent::Pointer(p) = event
6889                && p.phase == PointerPhase::Down
6890            {
6891                ctx.capture_pointer();
6892                if self.opt_in {
6893                    ctx.capture_contacts();
6894                }
6895            }
6896            EventResult::Handled
6897        }
6898    }
6899
6900    /// A 200 px list of ten 200 px rows whose one materialized row is a
6901    /// [`Grab`].
6902    fn list(opt_in: bool) -> ListViewWidget {
6903        let mut w = ListViewWidget::new(10, 200.0);
6904        w.viewport = Size::new(200.0, 200.0);
6905        let mut pod = ChildPod::new(Box::new(Grab { opt_in }));
6906        pod.layout_child(
6907            &mut LayoutCtx::new(),
6908            &BoxConstraints::tight(Size::new(200.0, 200.0)),
6909        );
6910        w.children = vec![pod];
6911        w.keys = vec![0];
6912        w.sync_child_origins();
6913        w
6914    }
6915
6916    fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
6917        InputEvent::Pointer(PointerEvent {
6918            phase,
6919            position: Point::new(10.0, y),
6920            button: PointerButton::Primary,
6921        })
6922    }
6923
6924    /// Dispatch into the list; whether a capture release bubbled out of it.
6925    fn released(w: &mut ListViewWidget, event: &InputEvent, t_ms: f64) -> bool {
6926        let mut unit = ();
6927        let sa: &mut dyn Any = &mut unit;
6928        let mut ctx = EventCtx::new(sa, Point::ZERO, w.viewport);
6929        w.event_at(&mut ctx, event, t_ms);
6930        ctx.is_capture_released()
6931    }
6932
6933    #[test]
6934    fn a_takeover_releases_the_row_and_signals_only_an_opt_in() {
6935        for opt_in in [true, false] {
6936            let mut w = list(opt_in);
6937            assert!(!released(&mut w, &pointer(PointerPhase::Down, 100.0), 0.0));
6938            assert!(w.children[0].is_active(), "the row captured");
6939            let signalled = released(&mut w, &pointer(PointerPhase::Move, 60.0), 16.0);
6940            assert!(w.scrolling, "the list took the drag over");
6941            assert!(!w.children[0].is_active(), "and released the row");
6942            assert_eq!(signalled, opt_in, "signalled only when the row opted in");
6943        }
6944    }
6945}
6946
6947#[cfg(test)]
6948mod contact_tests {
6949    use super::*;
6950    use crate::{PanZoomTransform, pan_zoom, pinch_detector};
6951    use frust_core::RenderRoot;
6952    use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
6953    use std::any::Any;
6954    use std::cell::RefCell;
6955
6956    #[derive(Default)]
6957    #[allow(dead_code)]
6958    struct App {
6959        transforms: Vec<PanZoomTransform>,
6960        scales: Vec<ScaleEvent>,
6961    }
6962
6963    type Seen = Rc<RefCell<Vec<(PointerId, PointerPhase)>>>;
6964
6965    /// A 200-px-tall row content. Logs every pointer event it receives (into
6966    /// a shared log, never app state, so its `Cancel` arm stays state-free).
6967    /// With `grabs` it captures every primary `Down` and handles it — opting
6968    /// into the gesture's other contacts too with `opt_in` — and otherwise
6969    /// ignores pointers. With `consumes` it reports `Handled` for a `Scale`
6970    /// and for a broadcast; otherwise it ignores both.
6971    #[derive(Clone)]
6972    #[allow(dead_code)]
6973    struct RowContent {
6974        seen: Seen,
6975        grabs: bool,
6976        opt_in: bool,
6977        consumes: bool,
6978    }
6979    #[allow(dead_code)]
6980    struct RowContentWidget(RowContent);
6981    impl View<App> for RowContent {
6982        type Element = RowContentWidget;
6983        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RowContentWidget {
6984            RowContentWidget(self.clone())
6985        }
6986        fn rebuild(
6987            &self,
6988            _p: &Self,
6989            _e: &mut RowContentWidget,
6990            _c: &mut BuildCtx<'_>,
6991        ) -> ChangeFlags {
6992            ChangeFlags::NONE
6993        }
6994    }
6995    impl Widget for RowContentWidget {
6996        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
6997            bc.constrain(Size::new(400.0, 200.0))
6998        }
6999        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
7000        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
7001            match event {
7002                InputEvent::Pointer(p) => {
7003                    self.0.seen.borrow_mut().push((ctx.pointer_id(), p.phase));
7004                    if !self.0.grabs {
7005                        return EventResult::Ignored;
7006                    }
7007                    if p.phase == PointerPhase::Down {
7008                        ctx.capture_pointer();
7009                        if self.0.opt_in {
7010                            ctx.capture_contacts();
7011                        }
7012                    }
7013                    EventResult::Handled
7014                }
7015                InputEvent::Scale(_) | InputEvent::Housekeeping if self.0.consumes => {
7016                    EventResult::Handled
7017                }
7018                _ => EventResult::Ignored,
7019            }
7020        }
7021    }
7022
7023    #[allow(dead_code)]
7024    fn row_content(grabs: bool, opt_in: bool, consumes: bool) -> (RowContent, Seen) {
7025        let seen = Seen::default();
7026        let view = RowContent {
7027            seen: seen.clone(),
7028            grabs,
7029            opt_in,
7030            consumes,
7031        };
7032        (view, seen)
7033    }
7034
7035    #[allow(dead_code)]
7036    struct NullScene;
7037    impl PaintScene for NullScene {
7038        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
7039        fn draw_text(&mut self, _o: Point, _t: &str) {}
7040    }
7041
7042    #[allow(dead_code)]
7043    fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
7044        InputEvent::PointerContact {
7045            pointer_id: PointerId::touch(slot),
7046            event: PointerEvent {
7047                phase,
7048                position: Point::new(x, y),
7049                button: PointerButton::Primary,
7050            },
7051        }
7052    }
7053
7054    /// A root over `logic`, laid out in a 400 × 300 window with a 10-item
7055    /// uniform-extent list where each item is 200 px tall.
7056    #[allow(dead_code)]
7057    fn root_over<V: View<App>>(logic: impl Fn() -> V + 'static) -> (RenderRoot<App, V>, App) {
7058        let mut root: RenderRoot<App, V> = RenderRoot::new();
7059        let mut state = App::default();
7060        root.rebuild(&mut move |_: &mut App| logic(), &mut state);
7061        root.layout(Size::new(400.0, 300.0));
7062        (root, state)
7063    }
7064
7065    #[allow(dead_code)]
7066    fn viewport(root: &RenderRoot<App, ListView<App>>) -> &ListViewWidget {
7067        let id = root.root_id().expect("root built");
7068        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
7069            .downcast_ref::<ListViewWidget>()
7070            .expect("root is a ListViewWidget")
7071    }
7072
7073    #[test]
7074    fn a_pinch_survives_the_claimants_own_travel_past_slop() {
7075        use PointerPhase::{Down, Move};
7076        let (mut root, mut state) = root_over(|| {
7077            let (content, _seen) = row_content(false, false, false);
7078            ListView::builder(10, 200.0, move |_| {
7079                pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7080            })
7081        });
7082        let mut sink = NullScene;
7083        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
7084        root.paint(&mut sink, ms(0));
7085        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7086        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7087        root.paint(&mut sink, ms(16));
7088        // The CLAIMANT's own finger spreads the pair vertically, past the
7089        // list's `TOUCH_SLOP` — a list reading only the `Down`-time
7090        // claim snapshot (always unregistered here; `pinch_detector` is not a
7091        // nested scrollable) would steal this as an ordinary scroll drag
7092        // (the counterexample this card fixes).
7093        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7094        root.paint(&mut sink, ms(32));
7095        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7096
7097        let phases: Vec<ScalePhase> = state.scales.iter().map(|e| e.phase).collect();
7098        assert_eq!(
7099            phases,
7100            [ScalePhase::Begin, ScalePhase::Update],
7101            "the pinch recognizer saw the whole spread"
7102        );
7103        let list = viewport(&root);
7104        assert_eq!(list.offset(), 0.0, "the list never scrolled");
7105        assert!(!list.scrolling, "and never took the gesture over");
7106        assert_eq!(root.pointer_capture_claimant(), Some(PointerId::touch(0)));
7107        assert!(
7108            root.pointer_capture_contacts(),
7109            "the list never cancelled/released the detector's opt-in"
7110        );
7111    }
7112
7113    #[test]
7114    fn a_pinch_over_a_child_owned_press_survives_the_claimants_own_travel() {
7115        use PointerPhase::{Down, Move};
7116        // `grabs = true`: the content captures the primary `Down` itself, so
7117        // `PanZoomWidget::begin_gesture` takes the child-owned branch, which
7118        // publishes nothing into the nested-scroll claim.
7119        let (mut root, mut state) = root_over(|| {
7120            let (content, _seen) = row_content(true, false, false);
7121            ListView::builder(10, 200.0, move |_| {
7122                pan_zoom(content.clone()).on_transform(|s: &mut App, t| s.transforms.push(t))
7123            })
7124        });
7125        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7126        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7127        // The claimant's finger travels well past TOUCH_SLOP vertically while
7128        // a second contact is tracked — exactly the counterexample this card
7129        // fixes for `PanZoomView`'s child-owned-press branch in a list.
7130        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7131        root.event(&mut state, &touch(1, Move, 180.0, 40.0));
7132
7133        let list = viewport(&root);
7134        assert_eq!(list.offset(), 0.0, "the list never scrolled");
7135        assert!(!list.scrolling, "and never took the gesture over");
7136        assert!(
7137            !state.transforms.is_empty(),
7138            "pan_zoom's own pinch zoomed the view instead"
7139        );
7140    }
7141
7142    #[test]
7143    fn a_single_finger_drag_still_scrolls_through_a_pinch_detector_with_no_second_finger() {
7144        use PointerPhase::{Down, Move};
7145        let (mut root, mut state) = root_over(|| {
7146            let (content, _seen) = row_content(false, false, false);
7147            ListView::builder(10, 200.0, move |_| {
7148                pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7149            })
7150        });
7151        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7152        root.event(&mut state, &touch(0, Move, 100.0, 40.0));
7153        assert!(
7154            viewport(&root).scrolling,
7155            "no second finger ever arrived to veto the takeover"
7156        );
7157        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7158        assert_eq!(viewport(&root).offset(), 30.0);
7159        assert!(state.scales.is_empty(), "never a pinch with one finger");
7160    }
7161
7162    #[test]
7163    fn releasing_the_second_finger_clears_the_veto_and_scrolling_resumes() {
7164        use PointerPhase::{Down, Move, Up};
7165        let (mut root, mut state) = root_over(|| {
7166            let (content, _seen) = row_content(true, false, false);
7167            ListView::builder(10, 200.0, move |_| {
7168                pinch_detector(content.clone()).on_scale(|s: &mut App, e| s.scales.push(e))
7169            })
7170        });
7171        let mut sink = NullScene;
7172        let ms = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
7173        root.paint(&mut sink, ms(0));
7174        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
7175        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
7176        root.paint(&mut sink, ms(16));
7177        root.event(&mut state, &touch(0, Move, 100.0, 40.0)); // past slop while paired: no takeover
7178        assert!(
7179            !viewport(&root).scrolling,
7180            "the pinch still owns the gesture"
7181        );
7182
7183        root.event(&mut state, &touch(1, Up, 120.0, 40.0)); // the second finger lifts: pinch ends
7184
7185        // The claimant's very next `Move` is measured against its original
7186        // `down_start` as usual (the veto does not replay the suppressed slop
7187        // check) — already well past `TOUCH_SLOP`, so the list takes the
7188        // drag over immediately once the veto clears, per the existing
7189        // single-finger scroll contract.
7190        root.event(&mut state, &touch(0, Move, 100.0, 10.0));
7191        assert!(
7192            viewport(&root).scrolling,
7193            "the list resumed scrolling once the pinch ended"
7194        );
7195    }
7196}
7197
7198#[cfg(test)]
7199mod controller_tests {
7200    //! The [`ScrollController`] seam on a list: offset commands mirrored from
7201    //! `ScrollView`, and `scroll_to_item`'s resolve → jump/tween →
7202    //! settle-and-correct loop over measured-or-estimated extents.
7203
7204    use std::any::Any;
7205    use std::cell::RefCell;
7206    use std::rc::Rc;
7207
7208    use frust_core::{Curve, PaintOutcome, RenderRoot, any};
7209
7210    use super::*;
7211    use crate::physics::parity::Bouncing;
7212    use crate::physics::rubber_band::RubberBand;
7213    use crate::test_support::{RecordingScene, leaf};
7214
7215    /// The list's viewport (the root window).
7216    const VIEWPORT: Size = Size::new(200.0, 200.0);
7217    /// The estimate every variable-extent fixture declares — unequal to every
7218    /// true row height, so an estimated region is visibly estimated.
7219    const ESTIMATE: f64 = 60.0;
7220    /// The uniform fixture's row extent.
7221    const ROW: f64 = 50.0;
7222
7223    /// Deterministic self-sized row height by id: 40/60/80/100/120, cycling.
7224    fn height_of(id: u64) -> f64 {
7225        40.0 + (id % 5) as f64 * 20.0
7226    }
7227
7228    type Logic = Box<dyn FnMut(&mut ()) -> ListView<()>>;
7229
7230    /// A keyed, variable-extent list of `count` rows keyed by their id (the
7231    /// item index) and sized by [`height_of`], with `controller` attached.
7232    fn variable_logic(count: usize, controller: &ScrollController) -> Logic {
7233        let controller = controller.clone();
7234        Box::new(move |_: &mut ()| {
7235            ListView::builder_keyed(
7236                count,
7237                ESTIMATE,
7238                |i| ChildKey::new(i as u64),
7239                |i| any::<(), _>(leaf(VIEWPORT.width, height_of(i as u64))),
7240            )
7241            .estimated_item_extent(ESTIMATE)
7242            .controller(controller.clone())
7243        })
7244    }
7245
7246    /// A keyed, uniform-extent list of `count` [`ROW`]-tall rows.
7247    fn uniform_logic(count: usize, controller: &ScrollController) -> Logic {
7248        let controller = controller.clone();
7249        Box::new(move |_: &mut ()| {
7250            ListView::builder_keyed(
7251                count,
7252                ROW,
7253                |i| ChildKey::new(i as u64),
7254                |_| any::<(), _>(leaf(VIEWPORT.width, ROW)),
7255            )
7256            .controller(controller.clone())
7257        })
7258    }
7259
7260    fn list(root: &RenderRoot<(), ListView<()>>) -> &ListViewWidget {
7261        let id = root.root_id().expect("root built");
7262        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
7263            .downcast_ref::<ListViewWidget>()
7264            .expect("root is a ListViewWidget")
7265    }
7266
7267    /// One rebuild → layout → paint frame at `ms`.
7268    fn frame(root: &mut RenderRoot<(), ListView<()>>, logic: &mut Logic, ms: f64) -> PaintOutcome {
7269        root.rebuild(logic, &mut ());
7270        root.layout(VIEWPORT);
7271        root.paint(
7272            &mut RecordingScene::default(),
7273            FrameTime::from_nanos((ms * 1_000_000.0) as u64),
7274        )
7275    }
7276
7277    /// Drive frames from `start_ms` until paint asks for neither another frame
7278    /// nor a layout, returning how many it took; panics past `limit`.
7279    fn settle(
7280        root: &mut RenderRoot<(), ListView<()>>,
7281        logic: &mut Logic,
7282        start_ms: f64,
7283        limit: usize,
7284    ) -> usize {
7285        for n in 0..limit {
7286            let outcome = frame(root, logic, start_ms + 16.0 * n as f64);
7287            if !outcome.needs_frame && !outcome.needs_layout {
7288                return n + 1;
7289            }
7290        }
7291        panic!("the list did not settle within {limit} frames");
7292    }
7293
7294    fn converged(logic: &mut Logic) -> RenderRoot<(), ListView<()>> {
7295        let mut root = RenderRoot::new();
7296        settle(&mut root, logic, 0.0, 8);
7297        root
7298    }
7299
7300    /// Item `index`'s painted top and height in viewport coordinates, if it is
7301    /// materialized.
7302    fn row_rect(w: &ListViewWidget, index: usize) -> Option<(f64, f64)> {
7303        let slot = w.keys.iter().position(|&i| i == index)?;
7304        let pod = &w.children[slot];
7305        Some((pod.origin().y, pod.size().height))
7306    }
7307
7308    fn assert_near(actual: f64, expected: f64, what: &str) {
7309        assert!(
7310            (actual - expected).abs() <= ITEM_SETTLE_TOLERANCE_PX,
7311            "{what}: expected {expected}, got {actual}"
7312        );
7313    }
7314
7315    fn down(y: f64) -> InputEvent {
7316        InputEvent::Pointer(PointerEvent {
7317            phase: PointerPhase::Down,
7318            position: Point::new(10.0, y),
7319            button: PointerButton::Primary,
7320        })
7321    }
7322
7323    #[test]
7324    fn jump_to_and_animate_to_drive_a_list_like_a_scroll_view() {
7325        let controller = ScrollController::new();
7326        let mut logic = uniform_logic(100, &controller);
7327        let mut root = converged(&mut logic);
7328        assert!(controller.is_attached());
7329        assert_eq!(controller.max_offset(), 100.0 * ROW - VIEWPORT.height);
7330        assert_eq!(controller.viewport_extent(), VIEWPORT.height);
7331
7332        controller.jump_to(1234.0);
7333        settle(&mut root, &mut logic, 100.0, 4);
7334        assert_eq!(list(&root).offset(), 1234.0);
7335        assert_eq!(controller.offset(), 1234.0);
7336        let (top, _) = row_rect(list(&root), 24).expect("the landing row is materialized");
7337        assert_eq!(top, 24.0 * ROW - 1234.0, "the window re-materialized there");
7338
7339        controller.animate_to(
7340            234.0,
7341            AnimateTo {
7342                duration_ms: 200.0,
7343                curve: Curve::Linear,
7344            },
7345        );
7346        frame(&mut root, &mut logic, 1000.0);
7347        assert!(controller.is_animating());
7348        frame(&mut root, &mut logic, 1100.0);
7349        assert_near(controller.offset(), 734.0, "halfway through a linear tween");
7350        settle(&mut root, &mut logic, 1200.0, 6);
7351        assert_eq!(controller.offset(), 234.0);
7352        assert!(!controller.is_animating());
7353    }
7354
7355    #[test]
7356    fn scroll_to_item_on_a_uniform_list_lands_exactly() {
7357        let controller = ScrollController::new();
7358        let mut logic = uniform_logic(100, &controller);
7359        let mut root = converged(&mut logic);
7360        controller.scroll_to_item(ChildKey::new(40u64), ItemAlignment::Start, false);
7361        let frames = settle(&mut root, &mut logic, 100.0, 4);
7362        assert!(
7363            frames <= 2,
7364            "a closed-form position needs no correction ({frames} frames)"
7365        );
7366        assert_eq!(controller.offset(), 40.0 * ROW);
7367        assert_eq!(row_rect(list(&root), 40).map(|r| r.0), Some(0.0));
7368        assert!(list(&root).item_target.is_none());
7369    }
7370
7371    #[test]
7372    fn scroll_to_item_lands_an_unmeasured_row_at_start_after_settling() {
7373        let controller = ScrollController::new();
7374        let mut logic = variable_logic(300, &controller);
7375        let mut root = converged(&mut logic);
7376        let target = 250usize;
7377        assert!(
7378            !list(&root).measured.contains_key(&ChildKey::new(240u64)),
7379            "the rows ahead of the target were never measured"
7380        );
7381
7382        controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Start, false);
7383        let frames = settle(&mut root, &mut logic, 100.0, 8);
7384        assert!(
7385            frames <= usize::from(ITEM_SETTLE_MAX_FRAMES) + 1,
7386            "the settle loop is bounded ({frames} frames)"
7387        );
7388        let w = list(&root);
7389        assert!(w.item_target.is_none(), "the request finished settling");
7390        let (top, height) = row_rect(w, target).expect("the target row is materialized");
7391        assert_eq!(
7392            height,
7393            height_of(target as u64),
7394            "and measured at its own height"
7395        );
7396        assert_near(top, 0.0, "the row's top edge sits at the viewport top");
7397        assert_near(
7398            controller.offset(),
7399            w.placement_offset(),
7400            "the published offset",
7401        );
7402    }
7403
7404    #[test]
7405    fn scroll_to_item_center_and_end_alignments_correct_the_estimated_extent() {
7406        let controller = ScrollController::new();
7407        let mut logic = variable_logic(300, &controller);
7408        let mut root = converged(&mut logic);
7409
7410        // Row 154 is 120px tall against the 60px estimate, so both alignments
7411        // are wrong until the row itself has been measured.
7412        let target = 154usize;
7413        assert_eq!(height_of(target as u64), 120.0);
7414        controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Center, false);
7415        settle(&mut root, &mut logic, 100.0, 8);
7416        let (top, height) = row_rect(list(&root), target).expect("materialized");
7417        assert_near(
7418            top + height / 2.0,
7419            VIEWPORT.height / 2.0,
7420            "the row's center at the viewport center",
7421        );
7422
7423        let target = 209usize;
7424        assert_eq!(height_of(target as u64), 120.0);
7425        controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::End, false);
7426        settle(&mut root, &mut logic, 400.0, 8);
7427        let (top, height) = row_rect(list(&root), target).expect("materialized");
7428        assert_near(
7429            top + height,
7430            VIEWPORT.height,
7431            "the row's bottom at the viewport bottom",
7432        );
7433        assert!(list(&root).item_target.is_none());
7434    }
7435
7436    #[test]
7437    fn nearest_does_nothing_for_a_visible_row_and_the_least_otherwise() {
7438        let controller = ScrollController::new();
7439        let mut logic = variable_logic(300, &controller);
7440        let mut root = converged(&mut logic);
7441        // Rows 0..=3 are 40+60+80+100 = 280px; row 1 spans [40, 100), visible.
7442        controller.scroll_to_item(ChildKey::new(1u64), ItemAlignment::Nearest, false);
7443        settle(&mut root, &mut logic, 100.0, 4);
7444        assert_eq!(
7445            controller.offset(),
7446            0.0,
7447            "a fully visible row moves nothing"
7448        );
7449        assert!(list(&root).item_target.is_none());
7450
7451        // A row below the viewport aligns its bottom edge (`End`).
7452        controller.scroll_to_item(ChildKey::new(12u64), ItemAlignment::Nearest, false);
7453        settle(&mut root, &mut logic, 300.0, 8);
7454        let (top, height) = row_rect(list(&root), 12).expect("materialized");
7455        assert_near(
7456            top + height,
7457            VIEWPORT.height,
7458            "a row below lands at the bottom",
7459        );
7460
7461        // A row above the viewport aligns its top edge (`Start`).
7462        controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Nearest, false);
7463        settle(&mut root, &mut logic, 600.0, 8);
7464        let (top, _) = row_rect(list(&root), 3).expect("materialized");
7465        assert_near(top, 0.0, "a row above lands at the top");
7466    }
7467
7468    #[test]
7469    fn an_animated_scroll_to_item_eases_there_and_a_down_interrupts_it() {
7470        let controller = ScrollController::new();
7471        let mut logic = variable_logic(300, &controller);
7472        let mut root = converged(&mut logic);
7473
7474        controller.scroll_to_item(ChildKey::new(120u64), ItemAlignment::Start, true);
7475        frame(&mut root, &mut logic, 100.0); // drains and seeds the tween
7476        assert!(controller.is_animating());
7477        frame(&mut root, &mut logic, 200.0);
7478        let mid = controller.offset();
7479        assert!(mid > 0.0, "the tween is under way (offset {mid})");
7480        assert!(controller.is_animating());
7481
7482        root.event(&mut (), &down(50.0));
7483        assert!(!controller.is_animating(), "the Down ends the tween");
7484        assert!(list(&root).item_target.is_none(), "and the settle loop");
7485        assert!(list(&root).ballistic.is_none(), "the tween is really gone");
7486        // The first frame after the Down re-windows around where the tween
7487        // stopped and measures those rows (the measured anchor correction may
7488        // re-express the placement then, holding the on-screen rows still);
7489        // from there on nothing moves the content — the tween is not paused,
7490        // and no settle correction is left to run.
7491        frame(&mut root, &mut logic, 216.0);
7492        let frozen = list(&root).placement_offset();
7493        frame(&mut root, &mut logic, 600.0);
7494        frame(&mut root, &mut logic, 616.0);
7495        assert_near(
7496            list(&root).placement_offset(),
7497            frozen,
7498            "nothing moves the content afterwards",
7499        );
7500        assert!(!controller.is_animating());
7501    }
7502
7503    #[test]
7504    fn an_animated_scroll_to_item_lands_aligned_when_left_alone() {
7505        let controller = ScrollController::new();
7506        let mut logic = variable_logic(300, &controller);
7507        let mut root = converged(&mut logic);
7508        controller.scroll_to_item(ChildKey::new(120u64), ItemAlignment::Start, true);
7509        // The neutral theme's slow duration is 400ms; settle runs past it.
7510        settle(&mut root, &mut logic, 100.0, 40);
7511        assert!(!controller.is_animating());
7512        let (top, _) = row_rect(list(&root), 120).expect("materialized");
7513        assert_near(top, 0.0, "the row ends at the top after the tween + settle");
7514    }
7515
7516    #[test]
7517    fn on_change_publishes_the_settled_scroll_info() {
7518        let controller = ScrollController::new();
7519        let seen = Rc::new(RefCell::new(Vec::<ScrollInfo>::new()));
7520        let sink = Rc::clone(&seen);
7521        let _subscription = controller.on_change(move |info| sink.borrow_mut().push(info));
7522        let mut logic = variable_logic(300, &controller);
7523        let mut root = converged(&mut logic);
7524        seen.borrow_mut().clear();
7525
7526        controller.scroll_to_item(ChildKey::new(200u64), ItemAlignment::Start, false);
7527        settle(&mut root, &mut logic, 100.0, 8);
7528        let w = list(&root);
7529        let last = *seen.borrow().last().expect("the jump published");
7530        assert_eq!(
7531            last,
7532            ScrollInfo {
7533                offset: w.placement_offset(),
7534                max_offset: w.max_offset(),
7535                overscroll: 0.0,
7536            },
7537            "the last published info is the settled position"
7538        );
7539        assert_eq!(controller.offset(), last.offset);
7540        let (top, _) = row_rect(w, 200).expect("materialized");
7541        assert_near(top, 0.0, "and it is the position the row landed at");
7542    }
7543
7544    #[test]
7545    fn an_unknown_key_is_a_no_op() {
7546        let controller = ScrollController::new();
7547        let mut logic = variable_logic(50, &controller);
7548        let mut root = converged(&mut logic);
7549        controller.jump_to(300.0);
7550        settle(&mut root, &mut logic, 100.0, 6);
7551        let before = controller.offset();
7552
7553        controller.scroll_to_item("no-such-row", ItemAlignment::Start, false);
7554        settle(&mut root, &mut logic, 300.0, 4);
7555        assert_eq!(controller.offset(), before);
7556        assert!(list(&root).item_target.is_none());
7557
7558        // A positional list has no keys at all: every key is unknown.
7559        let positional = ScrollController::new();
7560        let handle = positional.clone();
7561        let mut logic: Logic = Box::new(move |_: &mut ()| {
7562            list_view(50, ROW, |_| any::<(), _>(leaf(VIEWPORT.width, ROW)))
7563                .controller(handle.clone())
7564        });
7565        let mut root = converged(&mut logic);
7566        positional.scroll_to_item(ChildKey::new(10u64), ItemAlignment::Start, false);
7567        settle(&mut root, &mut logic, 100.0, 4);
7568        assert_eq!(positional.offset(), 0.0);
7569        assert!(list(&root).item_target.is_none());
7570    }
7571
7572    /// The anchor jump a rendered Markdown document makes: 200 keyed
7573    /// heading/paragraph rows of self-sized, mostly unmeasured heights, a jump
7574    /// to the heading keyed `h-150`, and the heading at the top afterwards.
7575    #[test]
7576    fn a_markdown_style_anchor_jump_puts_the_heading_at_the_top() {
7577        fn key(i: usize) -> String {
7578            if i.is_multiple_of(2) {
7579                format!("h-{i}")
7580            } else {
7581                format!("p-{i}")
7582            }
7583        }
7584        fn height(i: usize) -> f64 {
7585            if i.is_multiple_of(2) {
7586                32.0
7587            } else {
7588                48.0 + (i * 37 % 120) as f64
7589            }
7590        }
7591        let controller = ScrollController::new();
7592        let handle = controller.clone();
7593        let mut logic: Logic = Box::new(move |_: &mut ()| {
7594            ListView::builder_keyed(
7595                200,
7596                56.0,
7597                |i| ChildKey::new(key(i)),
7598                |i| any::<(), _>(leaf(VIEWPORT.width, height(i))),
7599            )
7600            .estimated_item_extent(56.0)
7601            .controller(handle.clone())
7602        });
7603        let mut root = converged(&mut logic);
7604
7605        controller.scroll_to_item("h-150", ItemAlignment::Start, false);
7606        settle(&mut root, &mut logic, 100.0, 8);
7607        let offset = controller.offset();
7608        let w = list(&root);
7609        let slot = w
7610            .keys
7611            .iter()
7612            .position(|&i| i == 150)
7613            .expect("h-150 materialized");
7614        assert_eq!(w.slot_keys[slot], ChildKey::new("h-150"));
7615        assert_near(
7616            w.slot_y[slot],
7617            offset,
7618            "the heading's content top is the offset",
7619        );
7620        let (top, _) = row_rect(w, 150).expect("materialized");
7621        assert_near(top, 0.0, "the heading is at the top of the viewport");
7622    }
7623
7624    // --- A command applied mid-drag ends the drag (mirrors `ScrollView`'s
7625    //     own tests of the same rule). ---
7626
7627    fn pointer(phase: PointerPhase, y: f64) -> InputEvent {
7628        InputEvent::Pointer(PointerEvent {
7629            phase,
7630            position: Point::new(10.0, y),
7631            button: PointerButton::Primary,
7632        })
7633    }
7634
7635    /// A standalone, laid-out uniform keyed list of 100 [`ROW`]-tall rows
7636    /// running `physics`, with `controller` attached — driven directly rather
7637    /// than through a [`RenderRoot`] so a test can reach into its state.
7638    fn standalone(
7639        controller: &ScrollController,
7640        physics: Option<Rc<dyn ScrollPhysics>>,
7641    ) -> ListViewWidget {
7642        let view = uniform_logic(100, controller)(&mut ());
7643        let mut counter = 0u64;
7644        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
7645        if let Some(physics) = physics {
7646            w.physics = physics;
7647        }
7648        w.layout(&mut LayoutCtx::new(), &BoxConstraints::tight(VIEWPORT));
7649        w
7650    }
7651
7652    fn send(w: &mut ListViewWidget, event: &InputEvent, t_ms: f64) {
7653        let mut unit = ();
7654        let state: &mut dyn Any = &mut unit;
7655        let mut ctx = EventCtx::new(state, Point::ZERO, w.viewport);
7656        w.event_at(&mut ctx, event, t_ms);
7657    }
7658
7659    /// Paint at an explicit frame time, returning whether paint asked for
7660    /// another frame.
7661    fn paint_at(w: &mut ListViewWidget, ms: f64) -> bool {
7662        let now = FrameTime::from_nanos((ms * 1_000_000.0) as u64);
7663        let mut ctx = PaintCtx::for_test(Point::ZERO, w.viewport, now);
7664        w.paint(&mut ctx, &mut RecordingScene::default());
7665        ctx.needs_frame()
7666    }
7667
7668    /// Down, then two moves upward — the first crosses the slop and takes
7669    /// over, the second scrolls 30 px — leaving the drag live.
7670    fn drag_without_release(w: &mut ListViewWidget) {
7671        send(w, &pointer(PointerPhase::Down, 150.0), 0.0);
7672        send(w, &pointer(PointerPhase::Move, 120.0), 16.0);
7673        send(w, &pointer(PointerPhase::Move, 90.0), 32.0);
7674        assert!(w.scrolling && w.down_active, "the drag is live");
7675    }
7676
7677    /// How many of the three post-release motions are live at once.
7678    fn live_motions(w: &ListViewWidget) -> usize {
7679        usize::from(w.fling.is_some())
7680            + usize::from(w.settling)
7681            + usize::from(w.ballistic.is_some())
7682    }
7683
7684    fn both_physics() -> [Rc<dyn ScrollPhysics>; 2] {
7685        [Rc::new(RubberBand::new()), Rc::new(Bouncing::new())]
7686    }
7687
7688    #[test]
7689    fn an_animate_to_mid_drag_ends_the_drag_and_owns_the_offset() {
7690        for physics in both_physics() {
7691            let controller = ScrollController::new();
7692            let mut w = standalone(&controller, Some(physics));
7693            drag_without_release(&mut w);
7694            let start = w.offset();
7695            assert!(start > 0.0, "the drag moved the content");
7696            let stale_veto = Rc::clone(&w.live_veto);
7697
7698            controller.animate_to(
7699                start + 200.0,
7700                AnimateTo {
7701                    duration_ms: 200.0,
7702                    curve: Curve::Linear,
7703                },
7704            );
7705            assert!(paint_at(&mut w, 0.0), "the tween asks for frames");
7706            assert!(!w.scrolling, "the command ended the drag");
7707            assert!(!w.down_active, "and disarmed the gesture");
7708            assert!(!w.deferring);
7709            assert!(
7710                !Rc::ptr_eq(&w.live_veto, &stale_veto),
7711                "the gesture's veto cell is replaced, like on a Down"
7712            );
7713            assert!(controller.is_animating());
7714
7715            assert!(paint_at(&mut w, 100.0));
7716            let mid = w.offset();
7717            assert!(
7718                (mid - (start + 100.0)).abs() < 1e-6,
7719                "the offset tracks the tween, offset: {mid}"
7720            );
7721            send(&mut w, &pointer(PointerPhase::Move, 20.0), 116.0);
7722            assert_eq!(w.offset(), mid, "a later Move does not move the content");
7723            assert!(!w.scrolling, "and never re-arms the drag");
7724
7725            send(&mut w, &pointer(PointerPhase::Up, 20.0), 120.0);
7726            assert!(w.fling.is_none(), "no stray fling");
7727            assert!(!w.settling, "no stray settle");
7728            assert_eq!(live_motions(&w), 1, "only the tween is live");
7729            assert!(controller.is_animating(), "the tween runs on");
7730
7731            // (A standalone list keeps asking for the rebuild that would
7732            // re-window it, so the pump's own state is what is asserted.)
7733            paint_at(&mut w, 200.0);
7734            assert_eq!(live_motions(&w), 0, "nothing is left animating");
7735            assert_eq!(w.offset(), start + 200.0);
7736            assert!(!controller.is_animating(), "completion resolves it");
7737            assert_eq!(live_motions(&w), 0);
7738        }
7739    }
7740
7741    #[test]
7742    fn a_release_tears_down_a_tween_before_starting_its_own_motion() {
7743        // Defence in depth behind the rule above: a tween live under a
7744        // still-armed drag is replaced by the release through the single
7745        // tear-down path, on the legacy and the simulation release paths.
7746        for physics in both_physics() {
7747            let controller = ScrollController::new();
7748            let mut w = standalone(&controller, Some(physics));
7749            drag_without_release(&mut w);
7750            controller.animate_to(
7751                3000.0,
7752                AnimateTo {
7753                    duration_ms: 400.0,
7754                    curve: Curve::Linear,
7755                },
7756            );
7757            paint_at(&mut w, 0.0);
7758            assert!(controller.is_animating());
7759            // Force the drag back on, as if the command had not ended it.
7760            w.scrolling = true;
7761            w.down_active = true;
7762
7763            send(&mut w, &pointer(PointerPhase::Up, 90.0), 40.0);
7764            assert!(
7765                live_motions(&w) <= 1,
7766                "fling, settle and simulation are never live together"
7767            );
7768            assert!(
7769                !controller.is_animating(),
7770                "the replaced tween no longer reports itself"
7771            );
7772            assert!(!w.controller_animating);
7773            // Whatever the release started runs to rest on its own, never
7774            // resurrecting the tween's flag.
7775            let mut ms = 40.0;
7776            while live_motions(&w) > 0 {
7777                paint_at(&mut w, ms);
7778                assert!(live_motions(&w) <= 1);
7779                assert!(!controller.is_animating());
7780                ms += 16.0;
7781                assert!(ms < 10_000.0, "the release motion never settled");
7782            }
7783        }
7784    }
7785
7786    #[test]
7787    fn a_command_during_the_pre_slop_hold_keeps_the_drag_startable() {
7788        // Mirrors the ScrollView rule: a hold inside the slop writes no
7789        // offset, so a command drained meanwhile leaves it armed and the
7790        // finger's later travel still takes the gesture over and scrolls.
7791        for physics in both_physics() {
7792            let controller = ScrollController::new();
7793            let mut w = standalone(&controller, Some(physics));
7794            send(&mut w, &pointer(PointerPhase::Down, 150.0), 0.0);
7795            send(
7796                &mut w,
7797                &pointer(PointerPhase::Move, 150.0 - TOUCH_SLOP / 2.0),
7798                16.0,
7799            );
7800            assert!(w.down_active && !w.scrolling, "armed, not yet a drag");
7801
7802            controller.jump_to(20.0);
7803            paint_at(&mut w, 32.0);
7804            assert!(w.offset() > 0.0, "the jump applied");
7805            assert!(w.down_active, "the hold stays armed");
7806            assert!(!w.scrolling);
7807
7808            send(&mut w, &pointer(PointerPhase::Move, 110.0), 48.0);
7809            assert!(w.scrolling, "the slop takeover still happens");
7810            let after_takeover = w.offset();
7811            send(&mut w, &pointer(PointerPhase::Move, 100.0), 64.0);
7812            assert!(
7813                w.offset() > after_takeover,
7814                "the finger scrolls the content"
7815            );
7816
7817            send(&mut w, &pointer(PointerPhase::Up, 100.0), 80.0);
7818            assert!(!w.down_active && !w.scrolling);
7819        }
7820    }
7821
7822    #[test]
7823    fn a_pending_item_target_waits_out_a_live_drag() {
7824        let controller = ScrollController::new();
7825        let mut w = standalone(&controller, None);
7826        assert!(w.laid_out);
7827        w.item_target = Some(ItemTarget {
7828            key: ChildKey::new(40u64),
7829            alignment: ItemAlignment::Start,
7830            frames_left: ITEM_SETTLE_MAX_FRAMES,
7831        });
7832        w.scrolling = true;
7833        w.down_active = true;
7834        let before = w.placement_offset();
7835        w.settle_item_target();
7836        assert_eq!(
7837            w.placement_offset(),
7838            before,
7839            "no correcting jump fights the finger"
7840        );
7841        assert_eq!(
7842            w.item_target.map(|t| t.frames_left),
7843            Some(ITEM_SETTLE_MAX_FRAMES),
7844            "and none of the bounded frames is spent waiting"
7845        );
7846        assert!(w.scrolling, "the drag itself is untouched");
7847
7848        // The drag ends: the very next settle step lands the row.
7849        w.scrolling = false;
7850        w.down_active = false;
7851        w.settle_item_target();
7852        assert_eq!(w.placement_offset(), 40.0 * ROW, "the target resumes");
7853    }
7854
7855    #[test]
7856    fn a_scroll_to_item_mid_drag_ends_the_drag_and_settles_after_the_release() {
7857        for animated in [false, true] {
7858            let controller = ScrollController::new();
7859            let mut logic = variable_logic(300, &controller);
7860            let mut root = converged(&mut logic);
7861            root.event(&mut (), &pointer(PointerPhase::Down, 150.0));
7862            root.event(&mut (), &pointer(PointerPhase::Move, 120.0));
7863            root.event(&mut (), &pointer(PointerPhase::Move, 90.0));
7864            assert!(list(&root).scrolling, "the drag is live");
7865
7866            let target = 250usize;
7867            controller.scroll_to_item(ChildKey::new(target as u64), ItemAlignment::Start, animated);
7868            frame(&mut root, &mut logic, 100.0);
7869            let w = list(&root);
7870            assert!(!w.scrolling && !w.down_active, "the command ended the drag");
7871            assert_eq!(controller.is_animating(), animated);
7872            let placed = w.placement_offset();
7873            assert!(placed > 0.0, "the content sits off the top");
7874
7875            // The finger keeps going and lets go: neither touches the content.
7876            root.event(&mut (), &pointer(PointerPhase::Move, 20.0));
7877            assert_eq!(
7878                list(&root).placement_offset(),
7879                placed,
7880                "a later Move does not move the content"
7881            );
7882            root.event(&mut (), &pointer(PointerPhase::Up, 20.0));
7883            let w = list(&root);
7884            assert!(
7885                w.fling.is_none() && !w.settling,
7886                "the release starts nothing"
7887            );
7888            assert_eq!(w.ballistic.is_some(), animated, "a tween runs on");
7889
7890            // The request settles after the drag, landing the row at the top.
7891            settle(&mut root, &mut logic, 116.0, 40);
7892            let w = list(&root);
7893            assert!(w.item_target.is_none(), "the request finished settling");
7894            assert!(!controller.is_animating());
7895            let (top, _) = row_rect(w, target).expect("the target row is materialized");
7896            assert_near(top, 0.0, "the row's top edge sits at the viewport top");
7897        }
7898    }
7899
7900    #[test]
7901    fn dropping_the_widget_mid_tween_resolves_is_animating() {
7902        let controller = ScrollController::new();
7903        let mut w = standalone(&controller, None);
7904        controller.animate_to(
7905            400.0,
7906            AnimateTo {
7907                duration_ms: 200.0,
7908                curve: Curve::Linear,
7909            },
7910        );
7911        paint_at(&mut w, 0.0);
7912        assert!(controller.is_animating(), "the tween is running");
7913        assert!(controller.is_attached());
7914
7915        drop(w);
7916        assert!(
7917            !controller.is_animating(),
7918            "the dropped surface's tween no longer reports itself"
7919        );
7920        assert!(!controller.is_attached());
7921    }
7922
7923    #[test]
7924    fn a_second_surface_attaching_mid_tween_reports_not_animating() {
7925        let controller = ScrollController::new();
7926        let mut first = standalone(&controller, None);
7927        controller.animate_to(
7928            400.0,
7929            AnimateTo {
7930                duration_ms: 200.0,
7931                curve: Curve::Linear,
7932            },
7933        );
7934        paint_at(&mut first, 0.0);
7935        assert!(controller.is_animating(), "the first surface's tween runs");
7936
7937        // A second surface takes the handle over mid-tween — it has never
7938        // started a tween of its own, so the handle it now holds reports
7939        // not animating at once, not the first surface's stale `true`.
7940        let second = standalone(&controller, None);
7941        assert!(
7942            !controller.is_animating(),
7943            "the new holder has no tween of its own"
7944        );
7945
7946        // The displaced first surface's tween keeps running on its own
7947        // widget state regardless, but it no longer has a current binding
7948        // to publish through.
7949        assert!(
7950            paint_at(&mut first, 100.0),
7951            "the orphaned tween still pumps"
7952        );
7953        assert!(
7954            !controller.is_animating(),
7955            "the orphaned tween cannot resurrect the handle's flag"
7956        );
7957        drop(second);
7958        drop(first);
7959    }
7960
7961    #[test]
7962    fn rebinding_to_a_different_handle_mid_tween_moves_the_live_flag() {
7963        let old_controller = ScrollController::new();
7964        let old_view = uniform_logic(100, &old_controller)(&mut ());
7965        let mut counter = 0u64;
7966        let mut w = View::<()>::build(&old_view, &mut BuildCtx::new(&mut counter));
7967        w.layout(&mut LayoutCtx::new(), &BoxConstraints::tight(VIEWPORT));
7968
7969        old_controller.animate_to(
7970            400.0,
7971            AnimateTo {
7972                duration_ms: 200.0,
7973                curve: Curve::Linear,
7974            },
7975        );
7976        paint_at(&mut w, 0.0);
7977        assert!(
7978            old_controller.is_animating(),
7979            "the tween runs on the old handle"
7980        );
7981
7982        // A rebuild rebinds the same widget to a different handle while the
7983        // tween is still live — the widget's own tween survives the rebind
7984        // (it belongs to the widget, not the handle), so the new handle
7985        // reports it live and the old one is released.
7986        let new_controller = ScrollController::new();
7987        let new_view = uniform_logic(100, &new_controller)(&mut ());
7988        let mut counter2 = 100u64;
7989        let flags = View::<()>::rebuild(
7990            &new_view,
7991            &old_view,
7992            &mut w,
7993            &mut BuildCtx::new(&mut counter2),
7994        );
7995        assert!(flags.needs_paint());
7996
7997        assert!(
7998            !old_controller.is_animating(),
7999            "the superseded handle no longer reports the tween"
8000        );
8001        assert!(!old_controller.is_attached());
8002        assert!(
8003            new_controller.is_animating(),
8004            "the freshly bound handle publishes the still-running tween"
8005        );
8006
8007        // Running the tween to completion resolves it on the new handle. A
8008        // list's paint can still ask for one more frame purely to re-window
8009        // around the landed offset (unrelated to the tween itself), so the
8010        // assertion that matters is on `is_animating`, not paint's own
8011        // continuation request.
8012        paint_at(&mut w, 200.0);
8013        assert_eq!(w.offset(), 400.0, "the tween reached its target");
8014        assert!(
8015            !new_controller.is_animating(),
8016            "completion resolves is_animating on the current handle"
8017        );
8018    }
8019
8020    #[test]
8021    fn a_no_op_scroll_to_item_still_supersedes_the_tween_and_settle_in_flight() {
8022        let controller = ScrollController::new();
8023        let mut logic = variable_logic(300, &controller);
8024        let mut root = converged(&mut logic);
8025
8026        // An unknown key arriving mid-tween: nothing to move to, but the
8027        // animated request it follows stops where it stands.
8028        controller.scroll_to_item(ChildKey::new(120u64), ItemAlignment::Start, true);
8029        frame(&mut root, &mut logic, 100.0); // drains and seeds the tween
8030        frame(&mut root, &mut logic, 200.0);
8031        assert!(controller.is_animating(), "the tween is under way");
8032        assert!(list(&root).item_target.is_some());
8033        controller.scroll_to_item("no-such-row", ItemAlignment::Start, true);
8034        frame(&mut root, &mut logic, 216.0);
8035        assert!(!controller.is_animating(), "the unknown key ends the tween");
8036        assert!(list(&root).ballistic.is_none(), "the tween is really gone");
8037        assert!(
8038            list(&root).item_target.is_none(),
8039            "and the earlier request's settle loop"
8040        );
8041        let frozen = list(&root).placement_offset();
8042        assert!(frozen > 0.0, "it stopped part-way ({frozen})");
8043        frame(&mut root, &mut logic, 600.0);
8044        frame(&mut root, &mut logic, 616.0);
8045        assert_near(
8046            list(&root).placement_offset(),
8047            frozen,
8048            "nothing carries the list on afterwards",
8049        );
8050
8051        // `Nearest` on a row already fully on screen, mid-tween, the same way
8052        // (a slow tween, so the window keeps up with it frame to frame).
8053        controller.animate_to(
8054            frozen + 100.0,
8055            AnimateTo {
8056                duration_ms: 400.0,
8057                curve: Curve::Linear,
8058            },
8059        );
8060        frame(&mut root, &mut logic, 700.0);
8061        frame(&mut root, &mut logic, 800.0);
8062        frame(&mut root, &mut logic, 816.0);
8063        assert!(controller.is_animating());
8064        let w = list(&root);
8065        let visible = w
8066            .keys
8067            .iter()
8068            .copied()
8069            .find(|&index| {
8070                row_rect(w, index)
8071                    .is_some_and(|(top, h)| top >= 10.0 && top + h <= VIEWPORT.height - 10.0)
8072            })
8073            .expect("some row sits fully inside the viewport");
8074        let before = list(&root).placement_offset();
8075        controller.scroll_to_item(ChildKey::new(visible as u64), ItemAlignment::Nearest, false);
8076        frame(&mut root, &mut logic, 832.0);
8077        assert!(
8078            !controller.is_animating(),
8079            "a visible Nearest row ends it too"
8080        );
8081        assert!(list(&root).item_target.is_none());
8082        assert_near(
8083            list(&root).placement_offset(),
8084            before,
8085            "the list stays where the tween stood",
8086        );
8087    }
8088
8089    #[test]
8090    fn a_no_op_scroll_to_item_leaves_a_user_fling_alone() {
8091        let controller = ScrollController::new();
8092        let mut logic = uniform_logic(100, &controller);
8093        let mut root = converged(&mut logic);
8094        let drag = |phase: PointerPhase, y: f64| {
8095            InputEvent::Pointer(PointerEvent {
8096                phase,
8097                position: Point::new(10.0, y),
8098                button: PointerButton::Primary,
8099            })
8100        };
8101        // Velocity tracking reads the last paint's clock, so a frame between
8102        // the moves gives the release a real speed.
8103        frame(&mut root, &mut logic, 100.0);
8104        root.event(&mut (), &drag(PointerPhase::Down, 180.0));
8105        frame(&mut root, &mut logic, 116.0);
8106        root.event(&mut (), &drag(PointerPhase::Move, 150.0));
8107        frame(&mut root, &mut logic, 132.0);
8108        root.event(&mut (), &drag(PointerPhase::Move, 100.0));
8109        root.event(&mut (), &drag(PointerPhase::Up, 100.0));
8110        assert!(list(&root).is_flinging(), "the release flings");
8111        controller.scroll_to_item("no-such-row", ItemAlignment::Start, false);
8112        frame(&mut root, &mut logic, 148.0);
8113        assert!(
8114            list(&root).is_flinging(),
8115            "a request naming no position does not stop the user's own motion"
8116        );
8117    }
8118
8119    #[test]
8120    fn a_near_end_fire_during_an_animate_to_reaches_the_app_through_the_frame_flush() {
8121        use std::cell::Cell;
8122
8123        let controller = ScrollController::new();
8124        let fired = Rc::new(Cell::new(0u32));
8125        let (handle, sink) = (controller.clone(), Rc::clone(&fired));
8126        let mut logic: Logic = Box::new(move |_: &mut ()| {
8127            let sink = Rc::clone(&sink);
8128            ListView::builder_keyed(
8129                100,
8130                ROW,
8131                |i| ChildKey::new(i as u64),
8132                |_| any::<(), _>(leaf(VIEWPORT.width, ROW)),
8133            )
8134            .on_near_end(move |_: &mut ()| sink.set(sink.get() + 1), ROW)
8135            .controller(handle.clone())
8136        });
8137        let mut root = converged(&mut logic);
8138        assert_eq!(fired.get(), 0);
8139        controller.animate_to(
8140            100.0 * ROW,
8141            AnimateTo {
8142                duration_ms: 200.0,
8143                curve: Curve::Linear,
8144            },
8145        );
8146        // Frames only — no input event anywhere — until the tween has landed
8147        // and the owed flush has run.
8148        settle(&mut root, &mut logic, 100.0, 40);
8149        assert_eq!(controller.offset(), 100.0 * ROW - VIEWPORT.height);
8150        assert_eq!(
8151            fired.get(),
8152            1,
8153            "the edge the tween crossed fired through the next frame's flush"
8154        );
8155    }
8156}