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