Skip to main content

frust_core/
sliver.rs

1//! The sliver layout protocol: scroll-aware constraints and geometry for the
2//! children of a scrolling viewport, mirroring Flutter's `RenderSliver`
3//! protocol (`rendering/sliver.dart`).
4//!
5//! A box child answers [`BoxConstraints`] with a [`kurbo::Size`]. A sliver
6//! answers [`SliverConstraints`] — where the viewport has scrolled to, how much
7//! paint space is left, how wide the cross axis is — with a [`SliverGeometry`]
8//! saying how much it scrolls, how much it painted, and where the next sliver
9//! starts. That lets a sliver lay out only the part of its content that is
10//! visible (or inside the cache area), which a box child cannot.
11//!
12//! The protocol is opt-in and lives beside [`Widget::layout`], never inside it:
13//! a widget offers it through [`Widget::as_sliver`] (defaulted to `None`), and a
14//! viewport lays a sliver child out through
15//! [`ChildPod::layout_sliver_child`](crate::widget::ChildPod::layout_sliver_child),
16//! which applies the same per-pod layout skip [`ChildPod::layout_child`]
17//! does. A child that answers `None` is a plain box; the viewport wraps it in an
18//! adapter.
19//!
20//! Only forward growth ([`GrowthDirection::Forward`]) is modelled: slivers grow
21//! from the viewport's leading edge in the scroll axis' natural direction.
22//!
23//! [`ChildPod::layout_child`]: crate::widget::ChildPod::layout_child
24
25use kurbo::Size;
26
27use crate::layout::BoxConstraints;
28use crate::widget::{LayoutCtx, Widget};
29
30/// The tolerance [`SliverGeometry::debug_assert_valid`] allows an extent to
31/// overshoot its bound by — Flutter's `precisionErrorTolerance`, absorbing the
32/// floating-point noise a viewport's running subtraction accumulates.
33pub const PRECISION_ERROR_TOLERANCE: f64 = 1e-10;
34
35/// The axis a viewport scrolls along — Flutter's `Axis`, as a sliver's
36/// `SliverConstraints.axis` reads it.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
38pub enum ScrollAxis {
39    /// The main axis runs top to bottom; the cross axis is the width.
40    #[default]
41    Vertical,
42    /// The main axis runs left to right; the cross axis is the height.
43    Horizontal,
44}
45
46impl ScrollAxis {
47    /// The [`Size`] with `main` along this axis and `cross` across it.
48    pub fn size(self, main: f64, cross: f64) -> Size {
49        match self {
50            ScrollAxis::Vertical => Size::new(cross, main),
51            ScrollAxis::Horizontal => Size::new(main, cross),
52        }
53    }
54
55    /// `size`'s extent along this axis.
56    pub fn main_extent(self, size: Size) -> f64 {
57        match self {
58            ScrollAxis::Vertical => size.height,
59            ScrollAxis::Horizontal => size.width,
60        }
61    }
62
63    /// `size`'s extent across this axis.
64    pub fn cross_extent(self, size: Size) -> f64 {
65        match self {
66            ScrollAxis::Vertical => size.width,
67            ScrollAxis::Horizontal => size.height,
68        }
69    }
70}
71
72/// Which way content grows relative to the viewport's leading edge — Flutter's
73/// `GrowthDirection`.
74///
75/// Only [`GrowthDirection::Forward`] exists: slivers are laid out from the
76/// leading edge outward in the axis' natural direction. The type is
77/// non-exhaustive so a reverse-growing (centre-anchored) viewport can add
78/// Flutter's `reverse` without breaking a match.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum GrowthDirection {
82    /// Content grows away from the viewport's leading edge, in the scroll
83    /// axis' natural direction (down, or right).
84    #[default]
85    Forward,
86}
87
88/// The direction the user is currently scrolling in, relative to the content
89/// — Flutter's `ScrollDirection` (`rendering/viewport_offset.dart`).
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
91pub enum ScrollDirection {
92    /// Not scrolling, or the direction is unknown.
93    #[default]
94    Idle,
95    /// Scrolling toward the content's start (the scroll offset is decreasing).
96    Forward,
97    /// Scrolling toward the content's end (the scroll offset is increasing).
98    Reverse,
99}
100
101/// What a viewport hands each sliver child: its position in the scroll and the
102/// space left to fill — Flutter's `SliverConstraints`.
103///
104/// All extents are logical pixels along [`axis`](SliverConstraints::axis)
105/// unless named `cross_axis_*`. `PartialEq` is what
106/// [`ChildPod::layout_sliver_child`](crate::widget::ChildPod::layout_sliver_child)'s
107/// layout skip compares, so constraints carrying a NaN never compare equal and
108/// always lay out again.
109#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct SliverConstraints {
111    /// The axis the viewport scrolls along (Flutter's `axis`, derived there
112    /// from `axisDirection`).
113    pub axis: ScrollAxis,
114    /// How far into this sliver the viewport's leading edge has scrolled: `0`
115    /// while the sliver's start is on screen or ahead of it (Flutter's
116    /// `scrollOffset`).
117    pub scroll_offset: f64,
118    /// The scroll extent consumed by every sliver before this one (Flutter's
119    /// `precedingScrollExtent`); may be infinite after an unbounded sliver.
120    pub preceding_scroll_extent: f64,
121    /// How many pixels from this sliver's paint start an earlier sliver has
122    /// already painted over, e.g. a pinned header (Flutter's `overlap`).
123    pub overlap: f64,
124    /// How many pixels of content the sliver should consider providing — the
125    /// viewport's paint space not yet taken by earlier slivers (Flutter's
126    /// `remainingPaintExtent`); may be infinite for a shrink-wrapping viewport.
127    pub remaining_paint_extent: f64,
128    /// The viewport's extent across the scroll axis (Flutter's
129    /// `crossAxisExtent`).
130    pub cross_axis_extent: f64,
131    /// The viewport's extent along the scroll axis (Flutter's
132    /// `viewportMainAxisExtent`).
133    pub viewport_main_axis_extent: f64,
134    /// Where the cache area starts relative to `scroll_offset`, never positive
135    /// (Flutter's `cacheOrigin`).
136    pub cache_origin: f64,
137    /// How much content the sliver should provide starting at `cache_origin`,
138    /// visible region plus cache area (Flutter's `remainingCacheExtent`).
139    pub remaining_cache_extent: f64,
140    /// The direction the user is scrolling in (Flutter's `userScrollDirection`).
141    pub user_scroll_direction: ScrollDirection,
142}
143
144impl SliverConstraints {
145    /// Which way content grows — always [`GrowthDirection::Forward`] (Flutter's
146    /// `growthDirection`).
147    pub fn growth_direction(&self) -> GrowthDirection {
148        GrowthDirection::Forward
149    }
150
151    /// Box constraints for a box child laid out inside this sliver: tight to
152    /// `cross_axis_extent` across the axis, unbounded along it (Flutter's
153    /// `asBoxConstraints()` with its default extents).
154    pub fn as_box_constraints(&self) -> BoxConstraints {
155        let cross = self.cross_axis_extent;
156        BoxConstraints::new(
157            self.axis.size(0.0, cross),
158            self.axis.size(f64::INFINITY, cross),
159        )
160    }
161
162    /// Debug-assert the constraints are well formed — Flutter's
163    /// `SliverConstraints.debugAssertIsValid` (including `isNormalized`).
164    ///
165    /// Checks that `scroll_offset`, `cross_axis_extent`,
166    /// `viewport_main_axis_extent`, `remaining_paint_extent` and
167    /// `remaining_cache_extent` are non-negative (NaN fails), that
168    /// `cache_origin` is not positive, and that `preceding_scroll_extent` and
169    /// `overlap` are not NaN. Compiles to nothing without `debug_assertions`.
170    pub fn debug_assert_valid(&self) {
171        debug_assert!(
172            self.scroll_offset >= 0.0,
173            "SliverConstraints: scroll_offset {} is negative or NaN",
174            self.scroll_offset
175        );
176        debug_assert!(
177            self.cross_axis_extent >= 0.0,
178            "SliverConstraints: cross_axis_extent {} is negative or NaN",
179            self.cross_axis_extent
180        );
181        debug_assert!(
182            self.viewport_main_axis_extent >= 0.0,
183            "SliverConstraints: viewport_main_axis_extent {} is negative or NaN",
184            self.viewport_main_axis_extent
185        );
186        debug_assert!(
187            self.remaining_paint_extent >= 0.0,
188            "SliverConstraints: remaining_paint_extent {} is negative or NaN",
189            self.remaining_paint_extent
190        );
191        debug_assert!(
192            self.remaining_cache_extent >= 0.0,
193            "SliverConstraints: remaining_cache_extent {} is negative or NaN",
194            self.remaining_cache_extent
195        );
196        debug_assert!(
197            self.cache_origin <= 0.0,
198            "SliverConstraints: cache_origin {} is positive or NaN",
199            self.cache_origin
200        );
201        debug_assert!(
202            !self.preceding_scroll_extent.is_nan(),
203            "SliverConstraints: preceding_scroll_extent is NaN"
204        );
205        debug_assert!(!self.overlap.is_nan(), "SliverConstraints: overlap is NaN");
206    }
207}
208
209/// What a sliver reports back from layout: how far it scrolls, how much of
210/// the viewport it painted, and where the next sliver starts — Flutter's
211/// `SliverGeometry`.
212///
213/// All extents are logical pixels along the constraints' axis.
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub struct SliverGeometry {
216    /// The (estimated) total scrollable extent of the sliver's content
217    /// (Flutter's `scrollExtent`).
218    pub scroll_extent: f64,
219    /// How much of the remaining paint extent the sliver painted — its visible
220    /// size along the axis (Flutter's `paintExtent`).
221    pub paint_extent: f64,
222    /// Where the sliver's first visible pixel sits relative to its layout
223    /// position, e.g. negative to paint over an earlier sliver (Flutter's
224    /// `paintOrigin`).
225    pub paint_origin: f64,
226    /// The distance from this sliver's first visible pixel to the next sliver's
227    /// — usually `paint_extent`; less for a sliver the next one should overlap
228    /// (Flutter's `layoutExtent`).
229    pub layout_extent: f64,
230    /// The (estimated) paint extent the sliver would provide given infinite
231    /// remaining paint extent (Flutter's `maxPaintExtent`).
232    pub max_paint_extent: f64,
233    /// The distance from the paint start to the end of the region that accepts
234    /// hits — usually `paint_extent` (Flutter's `hitTestExtent`).
235    pub hit_test_extent: f64,
236    /// Whether the sliver should be painted at all — usually
237    /// `paint_extent > 0` (Flutter's `visible`).
238    pub visible: bool,
239    /// Whether the sliver painted outside its paint extent, so the viewport
240    /// must clip it (Flutter's `hasVisualOverflow`).
241    pub has_visual_overflow: bool,
242    /// A non-zero amount the viewport must adjust its scroll offset by before
243    /// re-running the whole layout, when the sliver found its earlier extent
244    /// estimate wrong (Flutter's `scrollOffsetCorrection`).
245    pub scroll_offset_correction: Option<f64>,
246    /// How much of the remaining cache extent the sliver consumed — at least
247    /// `layout_extent` (Flutter's `cacheExtent`).
248    pub cache_extent: f64,
249}
250
251impl SliverGeometry {
252    /// The geometry of an empty sliver: no extent of any kind and not visible
253    /// (Flutter's `SliverGeometry.zero`).
254    pub const ZERO: SliverGeometry = SliverGeometry {
255        scroll_extent: 0.0,
256        paint_extent: 0.0,
257        paint_origin: 0.0,
258        layout_extent: 0.0,
259        max_paint_extent: 0.0,
260        hit_test_extent: 0.0,
261        visible: false,
262        has_visual_overflow: false,
263        scroll_offset_correction: None,
264        cache_extent: 0.0,
265    };
266
267    /// A geometry with Flutter's constructor defaults for everything but the
268    /// three extents: `layout_extent` and `hit_test_extent` equal
269    /// `paint_extent`, `cache_extent` equals `layout_extent`, `visible` is
270    /// `paint_extent > 0`, and the rest is zero.
271    pub fn new(scroll_extent: f64, paint_extent: f64, max_paint_extent: f64) -> Self {
272        SliverGeometry {
273            scroll_extent,
274            paint_extent,
275            layout_extent: paint_extent,
276            max_paint_extent,
277            hit_test_extent: paint_extent,
278            visible: paint_extent > 0.0,
279            cache_extent: paint_extent,
280            ..SliverGeometry::ZERO
281        }
282    }
283
284    /// Debug-assert the geometry is well formed and fits `constraints` —
285    /// Flutter's `SliverGeometry.debugAssertIsValid` plus the paint-extent
286    /// check of `RenderSliver.debugAssertDoesMeetConstraints`.
287    ///
288    /// Checks that `scroll_extent`, `paint_extent`, `layout_extent`,
289    /// `cache_extent` and `hit_test_extent` are non-negative (NaN fails); that
290    /// `layout_extent <= paint_extent`, `paint_extent <= max_paint_extent`,
291    /// `paint_extent <= constraints.remaining_paint_extent` and
292    /// `layout_extent <= cache_extent`, each within
293    /// [`PRECISION_ERROR_TOLERANCE`]; and that a `scroll_offset_correction` is
294    /// finite and non-zero. Compiles to nothing without `debug_assertions`.
295    pub fn debug_assert_valid(&self, constraints: &SliverConstraints) {
296        debug_assert!(
297            self.scroll_extent >= 0.0,
298            "SliverGeometry: scroll_extent {} is negative or NaN",
299            self.scroll_extent
300        );
301        debug_assert!(
302            self.paint_extent >= 0.0,
303            "SliverGeometry: paint_extent {} is negative or NaN",
304            self.paint_extent
305        );
306        debug_assert!(
307            self.layout_extent >= 0.0,
308            "SliverGeometry: layout_extent {} is negative or NaN",
309            self.layout_extent
310        );
311        debug_assert!(
312            self.cache_extent >= 0.0,
313            "SliverGeometry: cache_extent {} is negative or NaN",
314            self.cache_extent
315        );
316        debug_assert!(
317            self.hit_test_extent >= 0.0,
318            "SliverGeometry: hit_test_extent {} is negative or NaN",
319            self.hit_test_extent
320        );
321        debug_assert!(
322            self.layout_extent - self.paint_extent <= PRECISION_ERROR_TOLERANCE,
323            "SliverGeometry: layout_extent {} exceeds paint_extent {}",
324            self.layout_extent,
325            self.paint_extent
326        );
327        debug_assert!(
328            self.paint_extent - self.max_paint_extent <= PRECISION_ERROR_TOLERANCE,
329            "SliverGeometry: max_paint_extent {} is less than paint_extent {}",
330            self.max_paint_extent,
331            self.paint_extent
332        );
333        debug_assert!(
334            self.paint_extent - constraints.remaining_paint_extent <= PRECISION_ERROR_TOLERANCE,
335            "SliverGeometry: paint_extent {} exceeds the constraints' remaining_paint_extent {}",
336            self.paint_extent,
337            constraints.remaining_paint_extent
338        );
339        debug_assert!(
340            self.layout_extent - self.cache_extent <= PRECISION_ERROR_TOLERANCE,
341            "SliverGeometry: cache_extent {} is less than layout_extent {}",
342            self.cache_extent,
343            self.layout_extent
344        );
345        if let Some(correction) = self.scroll_offset_correction {
346            debug_assert!(
347                correction != 0.0 && correction.is_finite(),
348                "SliverGeometry: scroll_offset_correction {correction} is zero or not finite \
349                 (use None for no correction)"
350            );
351        }
352    }
353}
354
355/// A widget that can be laid out as a sliver inside a scrolling viewport —
356/// Flutter's `RenderSliver`'s layout half (`performLayout` against
357/// `SliverConstraints`, producing `geometry`).
358///
359/// A sliver is still a [`Widget`]: it paints, handles events and contributes
360/// semantics like any other, in a local space whose size is
361/// `(cross_axis_extent, paint_extent)` in axis terms (what
362/// [`ChildPod::layout_sliver_child`](crate::widget::ChildPod::layout_sliver_child)
363/// records as the pod's size). A type implementing this trait opts in by
364/// overriding [`Widget::as_sliver`] to return `Some(self)`; its box
365/// [`Widget::layout`] still answers when it is laid out outside a viewport.
366///
367/// The layout-skip contract on [`Widget::layout`] applies unchanged: the
368/// result of `layout_sliver` must be a function of `sc`, the context's global
369/// inputs, and state that only changes through a channel that dirties the pod.
370pub trait SliverWidget: Widget {
371    /// Lay the sliver out under `sc` and report its geometry, which must satisfy
372    /// [`SliverGeometry::debug_assert_valid`] against `sc`.
373    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry;
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn constraints(axis: ScrollAxis) -> SliverConstraints {
381        SliverConstraints {
382            axis,
383            scroll_offset: 0.0,
384            preceding_scroll_extent: 0.0,
385            overlap: 0.0,
386            remaining_paint_extent: 600.0,
387            cross_axis_extent: 400.0,
388            viewport_main_axis_extent: 600.0,
389            cache_origin: 0.0,
390            remaining_cache_extent: 850.0,
391            user_scroll_direction: ScrollDirection::Idle,
392        }
393    }
394
395    #[test]
396    fn axis_packs_main_and_cross() {
397        assert_eq!(ScrollAxis::Vertical.size(10.0, 20.0), Size::new(20.0, 10.0));
398        assert_eq!(
399            ScrollAxis::Horizontal.size(10.0, 20.0),
400            Size::new(10.0, 20.0)
401        );
402        let size = Size::new(3.0, 7.0);
403        assert_eq!(ScrollAxis::Vertical.main_extent(size), 7.0);
404        assert_eq!(ScrollAxis::Vertical.cross_extent(size), 3.0);
405        assert_eq!(ScrollAxis::Horizontal.main_extent(size), 3.0);
406        assert_eq!(ScrollAxis::Horizontal.cross_extent(size), 7.0);
407    }
408
409    #[test]
410    fn box_constraints_are_tight_across_and_unbounded_along() {
411        let bc = constraints(ScrollAxis::Vertical).as_box_constraints();
412        assert_eq!(bc.min(), Size::new(400.0, 0.0));
413        assert_eq!(bc.max(), Size::new(400.0, f64::INFINITY));
414        let bc = constraints(ScrollAxis::Horizontal).as_box_constraints();
415        assert_eq!(bc.min(), Size::new(0.0, 400.0));
416        assert_eq!(bc.max(), Size::new(f64::INFINITY, 400.0));
417    }
418
419    #[test]
420    fn growth_is_forward() {
421        assert_eq!(
422            constraints(ScrollAxis::Vertical).growth_direction(),
423            GrowthDirection::Forward
424        );
425    }
426
427    #[test]
428    fn new_applies_the_flutter_defaults() {
429        let g = SliverGeometry::new(1000.0, 600.0, 1000.0);
430        assert_eq!(g.layout_extent, 600.0);
431        assert_eq!(g.hit_test_extent, 600.0);
432        assert_eq!(g.cache_extent, 600.0);
433        assert!(g.visible);
434        assert!(!SliverGeometry::new(1000.0, 0.0, 1000.0).visible);
435        g.debug_assert_valid(&constraints(ScrollAxis::Vertical));
436        SliverGeometry::ZERO.debug_assert_valid(&constraints(ScrollAxis::Vertical));
437    }
438
439    #[test]
440    fn valid_constraints_pass() {
441        constraints(ScrollAxis::Vertical).debug_assert_valid();
442        SliverConstraints {
443            remaining_paint_extent: f64::INFINITY,
444            preceding_scroll_extent: f64::INFINITY,
445            cache_origin: -250.0,
446            ..constraints(ScrollAxis::Horizontal)
447        }
448        .debug_assert_valid();
449    }
450
451    #[test]
452    fn equal_constraints_compare_equal_and_nan_never_does() {
453        let sc = constraints(ScrollAxis::Vertical);
454        assert_eq!(sc, sc);
455        let moved = SliverConstraints {
456            scroll_offset: 5.0,
457            ..sc
458        };
459        assert_ne!(sc, moved);
460        let nan = SliverConstraints {
461            overlap: f64::NAN,
462            ..sc
463        };
464        assert_ne!(nan, nan);
465    }
466
467    #[cfg(debug_assertions)]
468    #[test]
469    #[should_panic(expected = "scroll_offset")]
470    fn negative_scroll_offset_is_rejected() {
471        SliverConstraints {
472            scroll_offset: -1.0,
473            ..constraints(ScrollAxis::Vertical)
474        }
475        .debug_assert_valid();
476    }
477
478    #[cfg(debug_assertions)]
479    #[test]
480    #[should_panic(expected = "cache_origin")]
481    fn positive_cache_origin_is_rejected() {
482        SliverConstraints {
483            cache_origin: 1.0,
484            ..constraints(ScrollAxis::Vertical)
485        }
486        .debug_assert_valid();
487    }
488
489    #[cfg(debug_assertions)]
490    #[test]
491    #[should_panic(expected = "remaining_paint_extent")]
492    fn paint_past_the_remaining_extent_is_rejected() {
493        SliverGeometry::new(1000.0, 601.0, 1000.0)
494            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
495    }
496
497    #[cfg(debug_assertions)]
498    #[test]
499    #[should_panic(expected = "exceeds paint_extent")]
500    fn layout_past_paint_is_rejected() {
501        SliverGeometry {
502            layout_extent: 200.0,
503            ..SliverGeometry::new(1000.0, 100.0, 1000.0)
504        }
505        .debug_assert_valid(&constraints(ScrollAxis::Vertical));
506    }
507
508    #[cfg(debug_assertions)]
509    #[test]
510    #[should_panic(expected = "max_paint_extent")]
511    fn paint_past_max_paint_is_rejected() {
512        SliverGeometry::new(1000.0, 300.0, 200.0)
513            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
514    }
515
516    #[cfg(debug_assertions)]
517    #[test]
518    #[should_panic(expected = "scroll_offset_correction")]
519    fn zero_correction_is_rejected() {
520        SliverGeometry {
521            scroll_offset_correction: Some(0.0),
522            ..SliverGeometry::ZERO
523        }
524        .debug_assert_valid(&constraints(ScrollAxis::Vertical));
525    }
526
527    #[test]
528    fn tolerance_absorbs_float_noise() {
529        SliverGeometry::new(1000.0, 600.0 + 1e-12, 1000.0)
530            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
531    }
532}