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}