Skip to main content

frust_widgets/
list_view.rs

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