Skip to main content

frust_core/
sliver.rs

1//! The sliver layout protocol for a scrolling viewport's children, mirroring
2//! Flutter's `RenderSliver` (`rendering/sliver.dart`).
3//!
4//! A sliver answers [`SliverConstraints`] with a [`SliverGeometry`], so it can lay
5//! out only its visible (or cached) content. A widget opts in through
6//! [`Widget::as_sliver`]; a viewport lays it out with
7//! [`ChildPod::layout_sliver_child`](crate::widget::ChildPod::layout_sliver_child),
8//! which applies the per-pod layout skip, and wraps a `None` child in a box
9//! adapter. Only forward growth is modelled.
10
11use kurbo::Size;
12
13use crate::layout::BoxConstraints;
14use crate::widget::{LayoutCtx, Widget};
15
16/// How far [`SliverGeometry::debug_assert_valid`] lets an extent overshoot its
17/// bound: Flutter's `precisionErrorTolerance`, for float noise.
18pub const PRECISION_ERROR_TOLERANCE: f64 = 1e-10;
19
20/// The axis a viewport scrolls along (Flutter's `Axis`).
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum ScrollAxis {
23    /// The main axis runs top to bottom; the cross axis is the width.
24    #[default]
25    Vertical,
26    /// The main axis runs left to right; the cross axis is the height.
27    Horizontal,
28}
29
30impl ScrollAxis {
31    /// The [`Size`] with `main` along this axis and `cross` across it.
32    pub fn size(self, main: f64, cross: f64) -> Size {
33        match self {
34            ScrollAxis::Vertical => Size::new(cross, main),
35            ScrollAxis::Horizontal => Size::new(main, cross),
36        }
37    }
38
39    /// `size`'s extent along this axis.
40    pub fn main_extent(self, size: Size) -> f64 {
41        match self {
42            ScrollAxis::Vertical => size.height,
43            ScrollAxis::Horizontal => size.width,
44        }
45    }
46
47    /// `size`'s extent across this axis.
48    pub fn cross_extent(self, size: Size) -> f64 {
49        match self {
50            ScrollAxis::Vertical => size.width,
51            ScrollAxis::Horizontal => size.height,
52        }
53    }
54}
55
56/// Which way content grows from the viewport's leading edge (Flutter's
57/// `GrowthDirection`); non-exhaustive so Flutter's `reverse` can be added.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
59#[non_exhaustive]
60pub enum GrowthDirection {
61    /// Down, or right.
62    #[default]
63    Forward,
64}
65
66/// The user's current scroll direction (Flutter's `ScrollDirection`).
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
68pub enum ScrollDirection {
69    /// Not scrolling, or the direction is unknown.
70    #[default]
71    Idle,
72    /// Scrolling toward the content's start (the scroll offset is decreasing).
73    Forward,
74    /// Scrolling toward the content's end (the scroll offset is increasing).
75    Reverse,
76}
77
78/// What a viewport hands each sliver child (Flutter's `SliverConstraints`), in
79/// logical px along [`axis`](Self::axis) unless named `cross_axis_*`. The layout
80/// skip compares with `PartialEq`, so a NaN field always lays out again.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct SliverConstraints {
83    pub axis: ScrollAxis,
84    /// How far the viewport's leading edge has scrolled into this sliver; `0`
85    /// while its start is on screen (Flutter's `scrollOffset`).
86    pub scroll_offset: f64,
87    /// Flutter's `precedingScrollExtent`; may be infinite.
88    pub preceding_scroll_extent: f64,
89    /// Pixels an earlier sliver (a pinned header) already covers (Flutter's `overlap`).
90    pub overlap: f64,
91    /// Paint space earlier slivers left (Flutter's `remainingPaintExtent`); may
92    /// be infinite for a shrink-wrapping viewport.
93    pub remaining_paint_extent: f64,
94    pub cross_axis_extent: f64,
95    pub viewport_main_axis_extent: f64,
96    /// Cache-area start relative to `scroll_offset`; never positive.
97    pub cache_origin: f64,
98    /// Content to provide from `cache_origin`, visible plus cache area.
99    pub remaining_cache_extent: f64,
100    pub user_scroll_direction: ScrollDirection,
101}
102
103impl SliverConstraints {
104    /// Always [`GrowthDirection::Forward`].
105    pub fn growth_direction(&self) -> GrowthDirection {
106        GrowthDirection::Forward
107    }
108
109    /// Tight to `cross_axis_extent` across the axis, unbounded along it
110    /// (Flutter's `asBoxConstraints()`).
111    pub fn as_box_constraints(&self) -> BoxConstraints {
112        let cross = self.cross_axis_extent;
113        BoxConstraints::new(
114            self.axis.size(0.0, cross),
115            self.axis.size(f64::INFINITY, cross),
116        )
117    }
118
119    /// Flutter's `SliverConstraints.debugAssertIsValid` (with `isNormalized`);
120    /// nothing without `debug_assertions`.
121    pub fn debug_assert_valid(&self) {
122        debug_assert!(
123            self.scroll_offset >= 0.0,
124            "SliverConstraints: scroll_offset {} is negative or NaN",
125            self.scroll_offset
126        );
127        debug_assert!(
128            self.cross_axis_extent >= 0.0,
129            "SliverConstraints: cross_axis_extent {} is negative or NaN",
130            self.cross_axis_extent
131        );
132        debug_assert!(
133            self.viewport_main_axis_extent >= 0.0,
134            "SliverConstraints: viewport_main_axis_extent {} is negative or NaN",
135            self.viewport_main_axis_extent
136        );
137        debug_assert!(
138            self.remaining_paint_extent >= 0.0,
139            "SliverConstraints: remaining_paint_extent {} is negative or NaN",
140            self.remaining_paint_extent
141        );
142        debug_assert!(
143            self.remaining_cache_extent >= 0.0,
144            "SliverConstraints: remaining_cache_extent {} is negative or NaN",
145            self.remaining_cache_extent
146        );
147        debug_assert!(
148            self.cache_origin <= 0.0,
149            "SliverConstraints: cache_origin {} is positive or NaN",
150            self.cache_origin
151        );
152        debug_assert!(
153            !self.preceding_scroll_extent.is_nan(),
154            "SliverConstraints: preceding_scroll_extent is NaN"
155        );
156        debug_assert!(!self.overlap.is_nan(), "SliverConstraints: overlap is NaN");
157    }
158}
159
160/// What a sliver reports from layout (Flutter's `SliverGeometry`), in logical
161/// px along the constraints' axis.
162#[derive(Clone, Copy, Debug, PartialEq)]
163pub struct SliverGeometry {
164    /// The (estimated) total scrollable extent.
165    pub scroll_extent: f64,
166    /// The visible size along the axis.
167    pub paint_extent: f64,
168    /// First visible pixel relative to the layout position; negative paints
169    /// over an earlier sliver.
170    pub paint_origin: f64,
171    /// Distance to the next sliver's first pixel; usually `paint_extent`.
172    pub layout_extent: f64,
173    /// The (estimated) paint extent given unlimited paint space.
174    pub max_paint_extent: f64,
175    /// Extent of the hit region from the paint start; usually `paint_extent`.
176    pub hit_test_extent: f64,
177    pub visible: bool,
178    /// The sliver painted outside its paint extent; the viewport must clip.
179    pub has_visual_overflow: bool,
180    /// A non-zero scroll-offset adjustment after which the viewport re-runs
181    /// layout, when an earlier extent estimate was wrong.
182    pub scroll_offset_correction: Option<f64>,
183    /// Cache extent consumed; at least `layout_extent`.
184    pub cache_extent: f64,
185}
186
187impl SliverGeometry {
188    /// An empty, invisible sliver.
189    pub const ZERO: SliverGeometry = SliverGeometry {
190        scroll_extent: 0.0,
191        paint_extent: 0.0,
192        paint_origin: 0.0,
193        layout_extent: 0.0,
194        max_paint_extent: 0.0,
195        hit_test_extent: 0.0,
196        visible: false,
197        has_visual_overflow: false,
198        scroll_offset_correction: None,
199        cache_extent: 0.0,
200    };
201
202    /// Flutter's constructor defaults: layout, hit-test and cache extents equal
203    /// `paint_extent`, `visible` is `paint_extent > 0`, the rest zero.
204    pub fn new(scroll_extent: f64, paint_extent: f64, max_paint_extent: f64) -> Self {
205        SliverGeometry {
206            scroll_extent,
207            paint_extent,
208            layout_extent: paint_extent,
209            max_paint_extent,
210            hit_test_extent: paint_extent,
211            visible: paint_extent > 0.0,
212            cache_extent: paint_extent,
213            ..SliverGeometry::ZERO
214        }
215    }
216
217    /// Flutter's `SliverGeometry.debugAssertIsValid` plus the paint-extent check
218    /// against `constraints`; nothing without `debug_assertions`.
219    pub fn debug_assert_valid(&self, constraints: &SliverConstraints) {
220        debug_assert!(
221            self.scroll_extent >= 0.0,
222            "SliverGeometry: scroll_extent {} is negative or NaN",
223            self.scroll_extent
224        );
225        debug_assert!(
226            self.paint_extent >= 0.0,
227            "SliverGeometry: paint_extent {} is negative or NaN",
228            self.paint_extent
229        );
230        debug_assert!(
231            self.layout_extent >= 0.0,
232            "SliverGeometry: layout_extent {} is negative or NaN",
233            self.layout_extent
234        );
235        debug_assert!(
236            self.cache_extent >= 0.0,
237            "SliverGeometry: cache_extent {} is negative or NaN",
238            self.cache_extent
239        );
240        debug_assert!(
241            self.hit_test_extent >= 0.0,
242            "SliverGeometry: hit_test_extent {} is negative or NaN",
243            self.hit_test_extent
244        );
245        debug_assert!(
246            self.layout_extent - self.paint_extent <= PRECISION_ERROR_TOLERANCE,
247            "SliverGeometry: layout_extent {} exceeds paint_extent {}",
248            self.layout_extent,
249            self.paint_extent
250        );
251        debug_assert!(
252            self.paint_extent - self.max_paint_extent <= PRECISION_ERROR_TOLERANCE,
253            "SliverGeometry: max_paint_extent {} is less than paint_extent {}",
254            self.max_paint_extent,
255            self.paint_extent
256        );
257        debug_assert!(
258            self.paint_extent - constraints.remaining_paint_extent <= PRECISION_ERROR_TOLERANCE,
259            "SliverGeometry: paint_extent {} exceeds the constraints' remaining_paint_extent {}",
260            self.paint_extent,
261            constraints.remaining_paint_extent
262        );
263        debug_assert!(
264            self.layout_extent - self.cache_extent <= PRECISION_ERROR_TOLERANCE,
265            "SliverGeometry: cache_extent {} is less than layout_extent {}",
266            self.cache_extent,
267            self.layout_extent
268        );
269        if let Some(correction) = self.scroll_offset_correction {
270            debug_assert!(
271                correction != 0.0 && correction.is_finite(),
272                "SliverGeometry: scroll_offset_correction {correction} is zero or not finite \
273                 (use None for no correction)"
274            );
275        }
276    }
277}
278
279/// A widget that lays out as a sliver (Flutter's `RenderSliver` layout half).
280///
281/// Opt in by returning `Some(self)` from [`Widget::as_sliver`]; it paints in a
282/// `(cross_axis_extent, paint_extent)` local space. [`Widget::layout`]'s
283/// layout-skip contract applies to `layout_sliver` unchanged.
284pub trait SliverWidget: Widget {
285    /// Lay the sliver out under `sc` and report its geometry, which must satisfy
286    /// [`SliverGeometry::debug_assert_valid`] against `sc`.
287    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry;
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn constraints(axis: ScrollAxis) -> SliverConstraints {
295        SliverConstraints {
296            axis,
297            scroll_offset: 0.0,
298            preceding_scroll_extent: 0.0,
299            overlap: 0.0,
300            remaining_paint_extent: 600.0,
301            cross_axis_extent: 400.0,
302            viewport_main_axis_extent: 600.0,
303            cache_origin: 0.0,
304            remaining_cache_extent: 850.0,
305            user_scroll_direction: ScrollDirection::Idle,
306        }
307    }
308
309    #[test]
310    fn axis_packs_main_and_cross() {
311        assert_eq!(ScrollAxis::Vertical.size(10.0, 20.0), Size::new(20.0, 10.0));
312        assert_eq!(
313            ScrollAxis::Horizontal.size(10.0, 20.0),
314            Size::new(10.0, 20.0)
315        );
316        let size = Size::new(3.0, 7.0);
317        assert_eq!(ScrollAxis::Vertical.main_extent(size), 7.0);
318        assert_eq!(ScrollAxis::Vertical.cross_extent(size), 3.0);
319        assert_eq!(ScrollAxis::Horizontal.main_extent(size), 3.0);
320        assert_eq!(ScrollAxis::Horizontal.cross_extent(size), 7.0);
321    }
322
323    #[test]
324    fn box_constraints_are_tight_across_and_unbounded_along() {
325        let bc = constraints(ScrollAxis::Vertical).as_box_constraints();
326        assert_eq!(bc.min(), Size::new(400.0, 0.0));
327        assert_eq!(bc.max(), Size::new(400.0, f64::INFINITY));
328        let bc = constraints(ScrollAxis::Horizontal).as_box_constraints();
329        assert_eq!(bc.min(), Size::new(0.0, 400.0));
330        assert_eq!(bc.max(), Size::new(f64::INFINITY, 400.0));
331    }
332
333    #[test]
334    fn growth_is_forward() {
335        assert_eq!(
336            constraints(ScrollAxis::Vertical).growth_direction(),
337            GrowthDirection::Forward
338        );
339    }
340
341    #[test]
342    fn new_applies_the_flutter_defaults() {
343        let g = SliverGeometry::new(1000.0, 600.0, 1000.0);
344        assert_eq!(g.layout_extent, 600.0);
345        assert_eq!(g.hit_test_extent, 600.0);
346        assert_eq!(g.cache_extent, 600.0);
347        assert!(g.visible);
348        assert!(!SliverGeometry::new(1000.0, 0.0, 1000.0).visible);
349        g.debug_assert_valid(&constraints(ScrollAxis::Vertical));
350        SliverGeometry::ZERO.debug_assert_valid(&constraints(ScrollAxis::Vertical));
351    }
352
353    #[test]
354    fn valid_constraints_pass() {
355        constraints(ScrollAxis::Vertical).debug_assert_valid();
356        SliverConstraints {
357            remaining_paint_extent: f64::INFINITY,
358            preceding_scroll_extent: f64::INFINITY,
359            cache_origin: -250.0,
360            ..constraints(ScrollAxis::Horizontal)
361        }
362        .debug_assert_valid();
363    }
364
365    #[test]
366    fn equal_constraints_compare_equal_and_nan_never_does() {
367        let sc = constraints(ScrollAxis::Vertical);
368        assert_eq!(sc, sc);
369        let moved = SliverConstraints {
370            scroll_offset: 5.0,
371            ..sc
372        };
373        assert_ne!(sc, moved);
374        let nan = SliverConstraints {
375            overlap: f64::NAN,
376            ..sc
377        };
378        assert_ne!(nan, nan);
379    }
380
381    #[cfg(debug_assertions)]
382    #[test]
383    #[should_panic(expected = "scroll_offset")]
384    fn negative_scroll_offset_is_rejected() {
385        SliverConstraints {
386            scroll_offset: -1.0,
387            ..constraints(ScrollAxis::Vertical)
388        }
389        .debug_assert_valid();
390    }
391
392    #[cfg(debug_assertions)]
393    #[test]
394    #[should_panic(expected = "cache_origin")]
395    fn positive_cache_origin_is_rejected() {
396        SliverConstraints {
397            cache_origin: 1.0,
398            ..constraints(ScrollAxis::Vertical)
399        }
400        .debug_assert_valid();
401    }
402
403    #[cfg(debug_assertions)]
404    #[test]
405    #[should_panic(expected = "remaining_paint_extent")]
406    fn paint_past_the_remaining_extent_is_rejected() {
407        SliverGeometry::new(1000.0, 601.0, 1000.0)
408            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
409    }
410
411    #[cfg(debug_assertions)]
412    #[test]
413    #[should_panic(expected = "exceeds paint_extent")]
414    fn layout_past_paint_is_rejected() {
415        SliverGeometry {
416            layout_extent: 200.0,
417            ..SliverGeometry::new(1000.0, 100.0, 1000.0)
418        }
419        .debug_assert_valid(&constraints(ScrollAxis::Vertical));
420    }
421
422    #[cfg(debug_assertions)]
423    #[test]
424    #[should_panic(expected = "max_paint_extent")]
425    fn paint_past_max_paint_is_rejected() {
426        SliverGeometry::new(1000.0, 300.0, 200.0)
427            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
428    }
429
430    #[cfg(debug_assertions)]
431    #[test]
432    #[should_panic(expected = "scroll_offset_correction")]
433    fn zero_correction_is_rejected() {
434        SliverGeometry {
435            scroll_offset_correction: Some(0.0),
436            ..SliverGeometry::ZERO
437        }
438        .debug_assert_valid(&constraints(ScrollAxis::Vertical));
439    }
440
441    #[test]
442    fn tolerance_absorbs_float_noise() {
443        SliverGeometry::new(1000.0, 600.0 + 1e-12, 1000.0)
444            .debug_assert_valid(&constraints(ScrollAxis::Vertical));
445    }
446}