Skip to main content

frust_widgets/slivers/
fixed_extent_list.rs

1//! `sliver_fixed_extent_list`: a lazy list of rows that all share one main-axis
2//! extent — Flutter's `SliverFixedExtentList`
3//! (`rendering/sliver_fixed_extent_list.dart`'s
4//! `RenderSliverFixedExtentBoxAdaptor`), materialised the way
5//! [`ListView`](crate::ListView) materialises its rows.
6//!
7//! # The cache window is the window
8//!
9//! Only the rows covering the cache region the viewport hands this sliver —
10//! `[scroll_offset + cache_origin, … + remaining_cache_extent)` in the
11//! sliver's own scroll terms — are materialised, and nothing more: the
12//! viewport's [`cache_extent`](crate::CustomScrollView::cache_extent) *is* the
13//! buffer, so there is no separate buffer constant as `ListView` has. Row `i`
14//! spans `[i × extent, (i + 1) × extent)`, so the window is the index range
15//! `[floor(start / extent), ceil(end / extent))` clamped to the item count,
16//! with Flutter's precision tolerance absorbing float noise at a row boundary
17//! (`getMinChildIndexForScrollOffset` / `getMaxChildIndexForScrollOffset`). A
18//! sliver outside the cache region materialises nothing.
19//!
20//! # Materialised at rebuild time
21//!
22//! Rows are built only in [`View::rebuild`], never at layout — `ListView`'s
23//! windowing invariant (`list_view.rs`'s *Windowed materialization at rebuild
24//! time*). Rebuild reads the constraints the last sliver layout ran with off
25//! the retained widget, recomputes the window from them and reconciles the
26//! live rows to it through the reconcilers it shares with `ListView`
27//! (`slivers/reconcile.rs`): rows leaving the window are torn down at once,
28//! rows entering are built fresh, survivors keep their pods. The very first
29//! build has no constraints yet and materialises nothing; whenever the
30//! materialised rows do not cover the window the last layout asked for (the
31//! first frame, a scroll, a constraint change), paint requests one more frame
32//! so the next rebuild re-windows — the same one-frame convergence `ListView`
33//! accepts.
34//!
35//! [`sliver_fixed_extent_list`] reconciles rows by item index,
36//! [`sliver_fixed_extent_list_keyed`] by a caller-supplied stable key, with
37//! exactly `ListView::builder` / `ListView::builder_keyed`'s contracts
38//! (`list_view.rs`'s *Row identity*): a positional list reattaches a row's
39//! state to whatever later sits at its index, a keyed one carries it with the
40//! row. A keyed sliver does not re-anchor the scroll position on a prepend
41//! (`ListView`'s keyed anchoring); a prepend above the viewport moves the
42//! content down by the prepended rows.
43//!
44//! # Layout and geometry
45//!
46//! Every materialised row is laid out tight: `extent` along the axis, the
47//! cross-axis extent across it, placed at `i × extent − scroll_offset`. The
48//! geometry is closed form and a function of the constraints alone (the
49//! layout-skip contract), never of which rows happen to be materialised: the
50//! scroll and max-paint extents are `item_count × extent`, the paint and cache
51//! extents the parts of that span inside the paint and cache regions. Paint
52//! clips to the paint extent when rows are cut by it, and skips rows wholly
53//! outside it (a row only cached is laid out, never painted).
54//!
55//! # Near-edge callbacks
56//!
57//! [`SliverFixedExtentListView::on_near_start`] and
58//! [`SliverFixedExtentListView::on_near_end`] are `ListView`'s
59//! edge-triggered pair: fired once per approach to within the threshold of the
60//! list's start (its scroll offset) or end (the content left past the
61//! viewport's trailing edge), rearmed past twice the threshold or by an
62//! item-count change. A sliver learns its position only at layout, so that is
63//! where the edges are observed — whenever the list moved against the viewport
64//! (its scroll offset or the paint space before it changed), never on its
65//! first layout, so mounting fires nothing, as on `ListView` where only motion
66//! does. A fire is delivered on the next event that reaches the list: the
67//! layout requests the `Housekeeping` broadcast that guarantees one.
68
69use std::collections::HashMap;
70use std::ops::Range;
71use std::rc::Rc;
72
73use frust_core::sliver::PRECISION_ERROR_TOLERANCE;
74use frust_core::{
75    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
76    LayoutCtx, PaintCtx, PaintScene, ScrollAxis, ScrollDirection, SemanticsCtx, SliverConstraints,
77    SliverGeometry, SliverWidget, View, Widget, any,
78};
79use kurbo::Size;
80
81use super::adapter::{axis_point, finite_extent, non_negative};
82use super::reconcile::{self, ItemViews, LiveRows};
83use super::{cache_offset, paint_offset};
84use crate::ChildKey;
85use crate::authoring::ErasedCallback;
86
87/// The debug-only tripwire message for two materialised rows claiming one
88/// key — `ListView::builder_keyed`'s rule: no positional fallback, the first
89/// slot claiming a key keeps the live row in release.
90const DUPLICATE_KEY_MSG: &str = "sliver_fixed_extent_list_keyed produced a duplicate key inside \
91     one window: row identity is ambiguous (release: the first slot claiming a key keeps the live \
92     row, later duplicates build fresh)";
93
94/// A view-held item builder, erased per row.
95type ItemBuilder<State> = Rc<dyn Fn(usize) -> AnyView<State>>;
96
97/// A view-held stable-key function (`item index -> row identity`).
98type KeyOf = Rc<dyn Fn(usize) -> ChildKey>;
99
100/// A view-held near-edge callback (erased on build).
101type OnNearEdge<State> = Rc<dyn Fn(&mut State)>;
102
103/// A lazy list of equal-extent rows. See the [module docs](self).
104pub struct SliverFixedExtentListView<State: 'static> {
105    item_count: usize,
106    item_extent: f64,
107    builder: ItemBuilder<State>,
108    /// `None` reconciles rows by item index.
109    key_of: Option<KeyOf>,
110    on_near_start: Option<OnNearEdge<State>>,
111    near_start_threshold: f64,
112    on_near_end: Option<OnNearEdge<State>>,
113    near_end_threshold: f64,
114}
115
116/// A lazy list of `item_count` rows, each `item_extent` logical px along the
117/// scroll axis, whose row at `index` is `builder(index)` — reconciled by item
118/// index, with [`ListView::builder`](crate::ListView::builder)'s contract.
119/// See the [module docs](self).
120///
121/// # Panics
122///
123/// If `item_extent` is not positive and finite.
124///
125/// ```
126/// use frust_widgets::{CustomScrollView, custom_scroll_view, sliver_fixed_extent_list, text};
127/// let view: CustomScrollView<()> = custom_scroll_view(sliver_fixed_extent_list(
128///     10_000,
129///     48.0,
130///     |i| text(format!("row {i}")),
131/// ));
132/// # let _ = view;
133/// ```
134pub fn sliver_fixed_extent_list<State: 'static, V: View<State>>(
135    item_count: usize,
136    item_extent: f64,
137    builder: impl Fn(usize) -> V + 'static,
138) -> SliverFixedExtentListView<State> {
139    SliverFixedExtentListView::new(item_count, item_extent, None, builder)
140}
141
142/// [`sliver_fixed_extent_list`] reconciled by the stable key `key_of(index)`
143/// instead of by item index, with
144/// [`ListView::builder_keyed`](crate::ListView::builder_keyed)'s contract:
145/// `key_of` runs once per materialised row per rebuild, and a duplicate key in
146/// one window trips a `debug_assert!`. See the [module docs](self).
147///
148/// # Panics
149///
150/// If `item_extent` is not positive and finite.
151///
152/// ```
153/// use frust_widgets::{
154///     ChildKey, CustomScrollView, custom_scroll_view, sliver_fixed_extent_list_keyed, text,
155/// };
156/// let ids: Vec<u64> = (0..100).collect();
157/// let keys = ids.clone();
158/// let view: CustomScrollView<()> = custom_scroll_view(sliver_fixed_extent_list_keyed(
159///     ids.len(),
160///     48.0,
161///     move |i| ChildKey::new(keys[i]),
162///     move |i| text(format!("item {}", ids[i])),
163/// ));
164/// # let _ = view;
165/// ```
166pub fn sliver_fixed_extent_list_keyed<State: 'static, V: View<State>>(
167    item_count: usize,
168    item_extent: f64,
169    key_of: impl Fn(usize) -> ChildKey + 'static,
170    builder: impl Fn(usize) -> V + 'static,
171) -> SliverFixedExtentListView<State> {
172    SliverFixedExtentListView::new(item_count, item_extent, Some(Rc::new(key_of)), builder)
173}
174
175impl<State: 'static> SliverFixedExtentListView<State> {
176    fn new<V: View<State>>(
177        item_count: usize,
178        item_extent: f64,
179        key_of: Option<KeyOf>,
180        builder: impl Fn(usize) -> V + 'static,
181    ) -> Self {
182        assert!(
183            item_extent > 0.0 && item_extent.is_finite(),
184            "sliver_fixed_extent_list item_extent must be positive and finite"
185        );
186        Self {
187            item_count,
188            item_extent,
189            builder: Rc::new(move |index| any(builder(index))),
190            key_of,
191            on_near_start: None,
192            near_start_threshold: 0.0,
193            on_near_end: None,
194            near_end_threshold: 0.0,
195        }
196    }
197
198    /// Fire `callback` when the list's start comes within `threshold_px` of
199    /// the viewport's leading edge — the "load older" edge, edge-triggered and
200    /// rearmed as [`ListView::on_near_start`](crate::ListView::on_near_start)
201    /// is. See the [module docs](self)' *Near-edge callbacks*.
202    pub fn on_near_start<F: Fn(&mut State) + 'static>(
203        mut self,
204        callback: F,
205        threshold_px: f64,
206    ) -> Self {
207        self.on_near_start = Some(Rc::new(callback));
208        self.near_start_threshold = threshold_px;
209        self
210    }
211
212    /// Fire `callback` when the list's end comes within `threshold_px` of the
213    /// viewport's trailing edge — the "load newer" edge, mirroring
214    /// [`SliverFixedExtentListView::on_near_start`] (and
215    /// [`ListView::on_near_end`](crate::ListView::on_near_end)).
216    pub fn on_near_end<F: Fn(&mut State) + 'static>(
217        mut self,
218        callback: F,
219        threshold_px: f64,
220    ) -> Self {
221        self.on_near_end = Some(Rc::new(callback));
222        self.near_end_threshold = threshold_px;
223        self
224    }
225}
226
227/// One edge-triggered near-edge callback and its arming state.
228struct NearEdge {
229    callback: Option<ErasedCallback>,
230    threshold: f64,
231    /// Whether the edge fires on the next approach. Cleared when it fires;
232    /// set again past `2 × threshold` or by an item-count change.
233    armed: bool,
234    /// A fire observed at layout, delivered on the next event.
235    pending: bool,
236}
237
238impl NearEdge {
239    fn new() -> Self {
240        Self {
241            callback: None,
242            threshold: 0.0,
243            armed: true,
244            pending: false,
245        }
246    }
247
248    /// Observe the list's `distance` from this edge; returns whether the edge
249    /// just fired (and records it as pending).
250    fn observe(&mut self, distance: f64) -> bool {
251        if self.callback.is_none() {
252            return false;
253        }
254        if distance > 2.0 * self.threshold {
255            self.armed = true;
256        }
257        if self.armed && distance <= self.threshold {
258            self.armed = false;
259            self.pending = true;
260            return true;
261        }
262        false
263    }
264
265    /// Run a pending fire's callback.
266    fn deliver(&mut self, ctx: &mut EventCtx) {
267        if std::mem::take(&mut self.pending)
268            && let Some(callback) = self.callback.as_mut()
269        {
270            callback(ctx);
271        }
272    }
273}
274
275/// The retained widget for a [`SliverFixedExtentListView`]: the materialised
276/// rows, the constraints rebuild windows against, and the near-edge state.
277pub struct SliverFixedExtentListWidget {
278    /// The materialised rows, parallel to [`Self::indices`].
279    children: Vec<ChildPod>,
280    /// The item index each row renders — a contiguous ascending run.
281    indices: Vec<usize>,
282    /// Row identity for a keyed list: `key -> the item index that row
283    /// occupied` as of the last rebuild. Empty for a positional list.
284    key_index: HashMap<ChildKey, usize>,
285    item_count: usize,
286    item_extent: f64,
287    /// The constraints the last sliver layout ran with — what rebuild windows
288    /// against. `None` until the first layout.
289    constraints: Option<SliverConstraints>,
290    /// The paint extent of the last sliver layout.
291    paint_extent: f64,
292    /// Whether the last layout cut a row at the paint extent (so paint clips).
293    clip: bool,
294    /// Where the near edges were last observed — `(scroll_offset,
295    /// remaining_paint_extent)`; `None` before the first layout.
296    observed_at: Option<(f64, f64)>,
297    near_start: NearEdge,
298    near_end: NearEdge,
299}
300
301/// The first row index whose span reaches past `offset` — Flutter's
302/// `getMinChildIndexForScrollOffset`, unclamped.
303fn min_index(offset: f64, extent: f64) -> f64 {
304    let actual = offset / extent;
305    let round = actual.round();
306    if (actual * extent - round * extent).abs() < PRECISION_ERROR_TOLERANCE {
307        round
308    } else {
309        actual.floor()
310    }
311}
312
313/// One past the last row index whose span starts before `offset` — Flutter's
314/// `getMaxChildIndexForScrollOffset` plus one, unclamped.
315fn end_index(offset: f64, extent: f64) -> f64 {
316    let actual = offset / extent - 1.0;
317    let round = actual.round();
318    if (actual * extent - round * extent).abs() < PRECISION_ERROR_TOLERANCE {
319        round.max(0.0) + 1.0
320    } else {
321        actual.ceil().max(0.0) + 1.0
322    }
323}
324
325/// The row indices covering the cache region of `sc`, clamped to
326/// `[0, item_count]` — the module docs' *The cache window is the window*.
327fn cache_window(sc: &SliverConstraints, item_count: usize, item_extent: f64) -> Range<usize> {
328    if item_count == 0 || sc.remaining_cache_extent <= 0.0 {
329        return 0..0;
330    }
331    let count = item_count as f64;
332    let start = (sc.scroll_offset + sc.cache_origin).max(0.0);
333    let end = start + sc.remaining_cache_extent;
334    let first = min_index(start, item_extent).clamp(0.0, count);
335    let last = if end.is_finite() {
336        end_index(end, item_extent).clamp(0.0, count)
337    } else {
338        count
339    };
340    let (first, last) = (first as usize, last as usize);
341    first.min(last)..last
342}
343
344impl SliverFixedExtentListWidget {
345    fn new(item_count: usize, item_extent: f64) -> Self {
346        Self {
347            children: Vec::new(),
348            indices: Vec::new(),
349            key_index: HashMap::new(),
350            item_count,
351            item_extent,
352            constraints: None,
353            paint_extent: 0.0,
354            clip: false,
355            observed_at: None,
356            near_start: NearEdge::new(),
357            near_end: NearEdge::new(),
358        }
359    }
360
361    /// The item indices currently materialised, ascending.
362    pub fn window(&self) -> &[usize] {
363        &self.indices
364    }
365
366    /// How many items the list holds.
367    pub fn item_count(&self) -> usize {
368        self.item_count
369    }
370
371    /// The rows the last layout's cache region asks for — empty before the
372    /// first layout.
373    fn desired_window(&self) -> Range<usize> {
374        match &self.constraints {
375            Some(sc) => cache_window(sc, self.item_count, self.item_extent),
376            None => 0..0,
377        }
378    }
379
380    /// Whether the materialised rows cover `range`.
381    fn covers(&self, range: &Range<usize>) -> bool {
382        if range.is_empty() {
383            return true;
384        }
385        match (self.indices.first(), self.indices.last()) {
386            (Some(&first), Some(&last)) => first <= range.start && last + 1 >= range.end,
387            _ => false,
388        }
389    }
390
391    /// Observe both near edges at `sc` — only once the list has moved against
392    /// the viewport since the last observation (the module docs' *Near-edge
393    /// callbacks*). Owes the flush that delivers a fire.
394    fn observe_edges(&mut self, sc: &SliverConstraints, total: f64) {
395        let at = (sc.scroll_offset, sc.remaining_paint_extent);
396        let previous = self.observed_at.replace(at);
397        if previous.is_none_or(|previous| previous == at) || self.item_count == 0 {
398            return;
399        }
400        let to_end = non_negative(total - sc.scroll_offset - sc.remaining_paint_extent);
401        let start = self.near_start.observe(sc.scroll_offset);
402        let end = self.near_end.observe(to_end);
403        if start || end {
404            frust_core::mark_pending_result_flush();
405        }
406    }
407}
408
409impl<State: 'static> SliverFixedExtentListView<State> {
410    /// Reinstall the near-edge callbacks (closures are not comparable, so
411    /// every build and rebuild does).
412    fn install_edges(&self, widget: &mut SliverFixedExtentListWidget) {
413        widget.near_start.callback = self
414            .on_near_start
415            .as_ref()
416            .map(crate::authoring::erase_callback);
417        widget.near_start.threshold = self.near_start_threshold;
418        widget.near_end.callback = self
419            .on_near_end
420            .as_ref()
421            .map(crate::authoring::erase_callback);
422        widget.near_end.threshold = self.near_end_threshold;
423    }
424}
425
426impl<State: 'static> View<State> for SliverFixedExtentListView<State> {
427    type Element = SliverFixedExtentListWidget;
428
429    /// No layout has run yet, so there is no cache region to window against:
430    /// nothing is materialised until the first rebuild after it.
431    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SliverFixedExtentListWidget {
432        let mut widget = SliverFixedExtentListWidget::new(self.item_count, self.item_extent);
433        self.install_edges(&mut widget);
434        widget
435    }
436
437    fn rebuild(
438        &self,
439        prev: &Self,
440        element: &mut SliverFixedExtentListWidget,
441        ctx: &mut BuildCtx<'_>,
442    ) -> ChangeFlags {
443        self.install_edges(element);
444        let mut flags = ChangeFlags::NONE;
445        if element.item_count != self.item_count {
446            element.item_count = self.item_count;
447            // Content length changed: both edges may fire again.
448            element.near_start.armed = true;
449            element.near_end.armed = true;
450            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
451        }
452        if element.item_extent != self.item_extent {
453            element.item_extent = self.item_extent;
454            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
455        }
456        let range = element.desired_window();
457        let views = ItemViews {
458            prev: &*prev.builder,
459            next: &*self.builder,
460        };
461        let rows = LiveRows {
462            children: &mut element.children,
463            indices: &mut element.indices,
464        };
465        flags |= match self.key_of.as_ref() {
466            Some(key_of) => {
467                reconcile::reconcile_keyed(
468                    views,
469                    rows,
470                    &mut element.key_index,
471                    &**key_of,
472                    range,
473                    DUPLICATE_KEY_MSG,
474                    ctx,
475                )
476                .flags
477            }
478            None => {
479                // A positional frame owns no key identities: drop whatever map
480                // a previous keyed frame left, so a list switched back to keyed
481                // later cannot match against a window this path materialised.
482                element.key_index.clear();
483                reconcile::reconcile_positional(views, rows, range, ctx)
484            }
485        };
486        flags
487    }
488
489    fn teardown(&self, element: &mut SliverFixedExtentListWidget, ctx: &mut BuildCtx<'_>) {
490        for (index, pod) in element.indices.iter().zip(element.children.iter_mut()) {
491            crate::authoring::teardown_child(&(self.builder)(*index), pod, ctx);
492        }
493    }
494}
495
496impl Widget for SliverFixedExtentListWidget {
497    /// Outside a viewport the list lays out as if it were the only sliver of a
498    /// viewport `bc.max()` long, scrolled to its start: it materialises (at
499    /// the next rebuild) and sizes itself to the rows that fit, or to all of
500    /// them under an unbounded constraint.
501    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
502        let axis = self.constraints.map_or(ScrollAxis::Vertical, |sc| sc.axis);
503        let max = bc.max();
504        let main = non_negative(axis.main_extent(max));
505        let cross = finite_extent(axis.cross_extent(max));
506        let sc = SliverConstraints {
507            axis,
508            scroll_offset: 0.0,
509            preceding_scroll_extent: 0.0,
510            overlap: 0.0,
511            remaining_paint_extent: main,
512            cross_axis_extent: cross,
513            viewport_main_axis_extent: main,
514            cache_origin: 0.0,
515            remaining_cache_extent: main,
516            user_scroll_direction: ScrollDirection::Idle,
517        };
518        let geometry = self.layout_sliver(ctx, &sc);
519        bc.constrain(axis.size(geometry.paint_extent, cross))
520    }
521
522    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
523        if self.clip {
524            scene.push_clip(ctx.origin(), ctx.size());
525        }
526        let axis = self.constraints.map_or(ScrollAxis::Vertical, |sc| sc.axis);
527        let (extent, paint_extent) = (self.item_extent, self.paint_extent);
528        for pod in &mut self.children {
529            // A row only cached is laid out but never painted.
530            let origin = pod.origin();
531            let start = axis.main_extent(Size::new(origin.x, origin.y));
532            if start + extent > 0.0 && start < paint_extent {
533                pod.paint_child(ctx, scene);
534            }
535        }
536        if self.clip {
537            scene.pop_clip();
538        }
539        // The next rebuild re-windows to what this frame's layout asked for;
540        // make sure it runs. A fire observed at layout owes the same frame.
541        let wanted = self.desired_window();
542        if !self.covers(&wanted) || self.near_start.pending || self.near_end.pending {
543            ctx.request_frame();
544        }
545    }
546
547    /// Delivers a near-edge fire observed at layout, then routes the event to
548    /// the rows: the viewport only delivers a positional event inside this
549    /// sliver's paint extent, where only painted rows sit.
550    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
551        self.near_start.deliver(ctx);
552        self.near_end.deliver(ctx);
553        crate::authoring::route_event(&mut self.children, ctx, event)
554    }
555
556    fn semantics(&self, ctx: &mut SemanticsCtx) {
557        for pod in &self.children {
558            pod.semantics_child(ctx);
559        }
560    }
561
562    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
563        Some(self)
564    }
565
566    crate::authoring::visit_children!(children);
567}
568
569impl SliverWidget for SliverFixedExtentListWidget {
570    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
571        let axis = sc.axis;
572        let extent = self.item_extent;
573        let total = self.item_count as f64 * extent;
574        let row = BoxConstraints::tight(axis.size(extent, sc.cross_axis_extent));
575        for (index, pod) in self.indices.iter().zip(self.children.iter_mut()) {
576            pod.layout_child(ctx, &row);
577            pod.set_origin(axis_point(
578                axis,
579                *index as f64 * extent - sc.scroll_offset,
580                0.0,
581            ));
582        }
583        let paint_extent = paint_offset(sc, 0.0, total);
584        let geometry = SliverGeometry {
585            cache_extent: cache_offset(sc, 0.0, total).max(paint_extent),
586            has_visual_overflow: total > sc.scroll_offset + sc.remaining_paint_extent
587                || sc.scroll_offset > 0.0,
588            ..SliverGeometry::new(total, paint_extent, total)
589        };
590        self.constraints = Some(*sc);
591        self.paint_extent = paint_extent;
592        self.clip = geometry.has_visual_overflow;
593        self.observe_edges(sc, total);
594        geometry
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    fn constraints(scroll_offset: f64, cache: f64) -> SliverConstraints {
603        SliverConstraints {
604            axis: ScrollAxis::Vertical,
605            scroll_offset,
606            preceding_scroll_extent: 0.0,
607            overlap: 0.0,
608            remaining_paint_extent: 500.0,
609            cross_axis_extent: 400.0,
610            viewport_main_axis_extent: 500.0,
611            cache_origin: -scroll_offset.min(cache),
612            remaining_cache_extent: 500.0 + cache + scroll_offset.min(cache),
613            user_scroll_direction: ScrollDirection::Idle,
614        }
615    }
616
617    #[test]
618    fn the_window_is_exactly_the_rows_the_cache_region_touches() {
619        // [0, 750): rows 0..15 of 50 px.
620        assert_eq!(cache_window(&constraints(0.0, 250.0), 10_000, 50.0), 0..15);
621        // [750, 1750) at offset 1000: rows 15..35.
622        assert_eq!(
623            cache_window(&constraints(1000.0, 250.0), 10_000, 50.0),
624            15..35
625        );
626        // A boundary a hair off a row edge does not pull in a neighbour.
627        let sc = SliverConstraints {
628            scroll_offset: 1000.0 + 1e-12,
629            ..constraints(1000.0, 250.0)
630        };
631        assert_eq!(cache_window(&sc, 10_000, 50.0), 15..35);
632        // Mid-row edges round outward.
633        assert_eq!(
634            cache_window(&constraints(1010.0, 250.0), 10_000, 50.0),
635            15..36
636        );
637        // Clamped to the item count, and empty past it or with no cache space.
638        assert_eq!(cache_window(&constraints(0.0, 250.0), 4, 50.0), 0..4);
639        assert_eq!(cache_window(&constraints(5000.0, 250.0), 4, 50.0), 4..4);
640        let none = SliverConstraints {
641            remaining_cache_extent: 0.0,
642            ..constraints(0.0, 250.0)
643        };
644        assert!(cache_window(&none, 100, 50.0).is_empty());
645        assert!(cache_window(&constraints(0.0, 250.0), 0, 50.0).is_empty());
646        // An unbounded region takes every row.
647        let all = SliverConstraints {
648            remaining_cache_extent: f64::INFINITY,
649            ..constraints(0.0, 250.0)
650        };
651        assert_eq!(cache_window(&all, 7, 50.0), 0..7);
652    }
653
654    #[test]
655    fn a_near_edge_fires_once_per_approach() {
656        let mut edge = NearEdge::new();
657        assert!(!edge.observe(0.0), "no callback, no fire");
658        edge.callback = Some(Box::new(|_: &mut EventCtx| {}));
659        edge.threshold = 100.0;
660        assert!(edge.observe(50.0));
661        assert!(edge.pending);
662        assert!(!edge.observe(20.0), "disarmed");
663        assert!(!edge.observe(150.0), "not yet past twice the threshold");
664        assert!(!edge.observe(80.0));
665        assert!(!edge.observe(250.0));
666        assert!(edge.observe(90.0), "rearmed past twice the threshold");
667    }
668}