Skip to main content

frust_widgets/slivers/
padding.rs

1//! `sliver_padding`: empty space around a sliver — Flutter's `SliverPadding`
2//! (`rendering/sliver_padding.dart`'s `RenderSliverEdgeInsetsPadding`).
3//!
4//! The insets along the scroll axis (top and bottom on a vertical viewport,
5//! left and right on a horizontal one) become scroll extent before and after
6//! the child; the insets across it narrow the child's cross extent. The child
7//! is laid out under the incoming constraints shifted past the leading inset:
8//!
9//! * `scroll_offset` less the leading inset (never negative), and
10//!   `preceding_scroll_extent` plus it;
11//! * `remaining_paint_extent` less the part of the leading inset that is
12//!   painted, and a positive `overlap` less the same;
13//! * `cross_axis_extent` less both cross insets.
14//!
15//! Its geometry comes back with the insets added: the scroll and max-paint
16//! extents grow by both main-axis insets, the paint, layout, hit-test and
17//! cache extents by the painted (or cached) part of each, all clamped to
18//! what the incoming constraints leave. The child is placed after the painted
19//! part of the leading inset and the leading cross inset. A correction the
20//! child asks for is passed straight up.
21//!
22//! **The cache window is re-derived, not shifted.** Flutter hands the child
23//! `cacheOrigin + leadingInset`, which slides the child's cache window by the
24//! inset once the leading inset has scrolled away. Here the child's window is
25//! the incoming window `[scroll_offset + cache_origin, … +
26//! remaining_cache_extent)` intersected with the child's own span and
27//! expressed from its scroll offset, so it lines up with the viewport's
28//! (`viewport.rs`'s *The layout loop*) at every position; the remaining cache
29//! extent is the same as Flutter's.
30//!
31//! A child that is not a sliver is laid out as a box through the geometry
32//! [`sliver_box`](crate::sliver_box) gives it, and clipped to its paint extent
33//! when that cuts it. Laid out as a box itself (outside a viewport),
34//! `sliver_padding` behaves like [`Padding`](crate::Padding).
35
36use frust_core::{
37    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
38    LayoutCtx, PaintCtx, PaintScene, ScrollAxis, SemanticsCtx, SliverConstraints, SliverGeometry,
39    SliverWidget, View, Widget, any,
40};
41use kurbo::{Point, Size};
42
43use super::adapter::{axis_point, box_child_origin, box_geometry, finite_extent};
44use super::{cache_offset, paint_offset};
45use crate::EdgeInsets;
46
47/// Insets around a sliver. See the [module docs](self).
48pub struct SliverPaddingView<State: 'static> {
49    insets: EdgeInsets,
50    child: AnyView<State>,
51}
52
53/// Surround `sliver` with `insets`: the main-axis insets scroll before and
54/// after it, the cross-axis insets narrow it. A NaN, infinite or negative
55/// inset is treated as `0.0`. See the [module docs](self).
56///
57/// ```
58/// use frust_widgets::{
59///     CustomScrollView, EdgeInsets, custom_scroll_view, sliver_box, sliver_padding, text,
60/// };
61/// let view: CustomScrollView<()> =
62///     custom_scroll_view(sliver_padding(EdgeInsets::all(16.0), sliver_box(text("body"))));
63/// # let _ = view;
64/// ```
65pub fn sliver_padding<State: 'static, V: View<State>>(
66    insets: EdgeInsets,
67    sliver: V,
68) -> SliverPaddingView<State> {
69    SliverPaddingView {
70        insets,
71        child: any(sliver),
72    }
73}
74
75/// The retained widget for a [`SliverPaddingView`].
76pub struct SliverPaddingWidget {
77    child: ChildPod,
78    insets: EdgeInsets,
79    /// Whether the last layout left the child visible — what paint reads to
80    /// skip it. Always `true` after a box layout.
81    child_visible: bool,
82    /// The local rect (origin, size) a box child is clipped to when the last
83    /// sliver layout cut it; `None` otherwise.
84    clip: Option<(Point, Size)>,
85}
86
87/// `insets` with every edge read through [`finite_extent`].
88fn sanitized(insets: EdgeInsets) -> EdgeInsets {
89    EdgeInsets {
90        left: finite_extent(insets.left),
91        top: finite_extent(insets.top),
92        right: finite_extent(insets.right),
93        bottom: finite_extent(insets.bottom),
94    }
95}
96
97/// The insets along `axis`: `(leading main, trailing main, leading cross,
98/// both cross)`.
99fn axis_insets(insets: &EdgeInsets, axis: ScrollAxis) -> (f64, f64, f64, f64) {
100    match axis {
101        ScrollAxis::Vertical => (
102            insets.top,
103            insets.bottom,
104            insets.left,
105            insets.left + insets.right,
106        ),
107        ScrollAxis::Horizontal => (
108            insets.left,
109            insets.right,
110            insets.top,
111            insets.top + insets.bottom,
112        ),
113    }
114}
115
116/// The constraints the child of a padding with `before` px of leading main
117/// inset and `cross_padding` px of cross insets is laid out under, given the
118/// padding's own `sc`. See the [module docs](self).
119fn child_constraints(sc: &SliverConstraints, before: f64, cross_padding: f64) -> SliverConstraints {
120    let before_paint = paint_offset(sc, 0.0, before);
121    let overlap = if sc.overlap > 0.0 {
122        (sc.overlap - before_paint).max(0.0)
123    } else {
124        sc.overlap
125    };
126    let scroll_offset = (sc.scroll_offset - before).max(0.0);
127    // The incoming cache window, in the child's own scroll terms and cut to
128    // its start.
129    let window_start = sc.scroll_offset + sc.cache_origin;
130    let window_end = window_start + sc.remaining_cache_extent;
131    let child_window_start = (window_start - before).max(0.0);
132    SliverConstraints {
133        scroll_offset,
134        preceding_scroll_extent: sc.preceding_scroll_extent + before,
135        overlap,
136        remaining_paint_extent: (sc.remaining_paint_extent - before_paint).max(0.0),
137        cross_axis_extent: (sc.cross_axis_extent - cross_padding).max(0.0),
138        cache_origin: (child_window_start - scroll_offset).min(0.0),
139        remaining_cache_extent: (window_end - before - child_window_start).max(0.0),
140        ..*sc
141    }
142}
143
144/// The padding's geometry around a child that reported `child` under the
145/// constraints [`child_constraints`] derived from `sc`.
146fn padded_geometry(
147    sc: &SliverConstraints,
148    child: &SliverGeometry,
149    before: f64,
150    after: f64,
151) -> SliverGeometry {
152    let main_padding = before + after;
153    let child_end = before + child.scroll_extent;
154    let before_paint = paint_offset(sc, 0.0, before);
155    let after_paint = paint_offset(sc, child_end, child_end + after);
156    let padding_paint = before_paint + after_paint;
157    let padding_cache =
158        cache_offset(sc, 0.0, before) + cache_offset(sc, child_end, child_end + after);
159    let paint_extent = (before_paint + child.paint_extent.max(child.layout_extent + after_paint))
160        .min(sc.remaining_paint_extent);
161    let layout_extent = (padding_paint + child.layout_extent).min(paint_extent);
162    SliverGeometry {
163        scroll_extent: main_padding + child.scroll_extent,
164        paint_extent,
165        paint_origin: child.paint_origin,
166        layout_extent,
167        max_paint_extent: main_padding + child.max_paint_extent,
168        hit_test_extent: (padding_paint + child.paint_extent)
169            .max(before_paint + child.hit_test_extent),
170        visible: paint_extent > 0.0,
171        has_visual_overflow: child.has_visual_overflow,
172        scroll_offset_correction: None,
173        cache_extent: (padding_cache + child.cache_extent)
174            .min(sc.remaining_cache_extent)
175            .max(layout_extent),
176    }
177}
178
179impl<State: 'static> View<State> for SliverPaddingView<State> {
180    type Element = SliverPaddingWidget;
181
182    fn build(&self, ctx: &mut BuildCtx<'_>) -> SliverPaddingWidget {
183        SliverPaddingWidget {
184            child: crate::authoring::build_child(&self.child, ctx),
185            insets: sanitized(self.insets),
186            child_visible: false,
187            clip: None,
188        }
189    }
190
191    fn rebuild(
192        &self,
193        prev: &Self,
194        element: &mut SliverPaddingWidget,
195        ctx: &mut BuildCtx<'_>,
196    ) -> ChangeFlags {
197        let mut flags =
198            crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
199        let insets = sanitized(self.insets);
200        if element.insets != insets {
201            element.insets = insets;
202            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
203        }
204        flags
205    }
206
207    fn teardown(&self, element: &mut SliverPaddingWidget, ctx: &mut BuildCtx<'_>) {
208        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
209    }
210}
211
212impl Widget for SliverPaddingWidget {
213    /// Outside a viewport the padding is a plain [`Padding`](crate::Padding):
214    /// the child is laid out under `bc` deflated by the insets, at the
215    /// leading insets.
216    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
217        let EdgeInsets {
218            left,
219            top,
220            right,
221            bottom,
222        } = self.insets;
223        let (h, v) = (left + right, top + bottom);
224        let child_bc = BoxConstraints::new(
225            Size::new(
226                (bc.min().width - h).max(0.0),
227                (bc.min().height - v).max(0.0),
228            ),
229            Size::new(
230                (bc.max().width - h).max(0.0),
231                (bc.max().height - v).max(0.0),
232            ),
233        );
234        let size = self.child.layout_child(ctx, &child_bc);
235        self.child.set_origin(Point::new(left, top));
236        self.child_visible = true;
237        self.clip = None;
238        bc.constrain(Size::new(size.width + h, size.height + v))
239    }
240
241    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
242        if !self.child_visible {
243            return;
244        }
245        if let Some((origin, size)) = self.clip {
246            scene.push_clip(ctx.origin() + origin.to_vec2(), size);
247        }
248        self.child.paint_child(ctx, scene);
249        if self.clip.is_some() {
250            scene.pop_clip();
251        }
252    }
253
254    /// The viewport delivers a positional event only inside this sliver's
255    /// hit-test extent, so the ordinary single-child routing applies.
256    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
257        crate::authoring::route_event_single(&mut self.child, ctx, event)
258    }
259
260    fn semantics(&self, ctx: &mut SemanticsCtx) {
261        self.child.semantics_child(ctx);
262    }
263
264    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
265        Some(self)
266    }
267
268    crate::authoring::visit_children!(child);
269}
270
271impl SliverWidget for SliverPaddingWidget {
272    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
273        let axis = sc.axis;
274        let (before, after, cross_before, cross_padding) = axis_insets(&self.insets, axis);
275        let child_sc = child_constraints(sc, before, cross_padding);
276        let (child, is_box) = match self.child.layout_sliver_child(ctx, &child_sc) {
277            Some(geometry) => (geometry, false),
278            None => {
279                let size = self.child.layout_child(ctx, &child_sc.as_box_constraints());
280                (box_geometry(axis.main_extent(size), &child_sc), true)
281            }
282        };
283        self.child_visible = child.visible;
284        if let Some(correction) = child.scroll_offset_correction {
285            self.clip = None;
286            return SliverGeometry {
287                scroll_offset_correction: Some(correction),
288                ..SliverGeometry::ZERO
289            };
290        }
291        let start = axis_point(axis, paint_offset(sc, 0.0, before), cross_before);
292        if is_box {
293            self.child
294                .set_origin(start + box_child_origin(&child_sc).to_vec2());
295            self.clip = child.has_visual_overflow.then(|| {
296                (
297                    start,
298                    axis.size(child.paint_extent, child_sc.cross_axis_extent),
299                )
300            });
301        } else {
302            self.child.set_origin(start);
303            self.clip = None;
304        }
305        padded_geometry(sc, &child, before, after)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use frust_core::ScrollDirection;
313
314    /// A vertical sliver's constraints `scroll_offset` into it, with the
315    /// viewport's 250 px cache window.
316    fn constraints(scroll_offset: f64, remaining_paint_extent: f64) -> SliverConstraints {
317        SliverConstraints {
318            axis: ScrollAxis::Vertical,
319            scroll_offset,
320            preceding_scroll_extent: 0.0,
321            overlap: 0.0,
322            remaining_paint_extent,
323            cross_axis_extent: 400.0,
324            viewport_main_axis_extent: 500.0,
325            cache_origin: -scroll_offset.min(250.0),
326            remaining_cache_extent: remaining_paint_extent + 250.0 + scroll_offset.min(250.0),
327            user_scroll_direction: ScrollDirection::Idle,
328        }
329    }
330
331    #[test]
332    fn the_child_starts_after_the_leading_inset() {
333        let sc = constraints(0.0, 500.0);
334        let child = child_constraints(&sc, 20.0, 30.0);
335        assert_eq!(child.scroll_offset, 0.0);
336        assert_eq!(child.remaining_paint_extent, 480.0);
337        assert_eq!(child.cross_axis_extent, 370.0);
338        assert_eq!(child.preceding_scroll_extent, 20.0);
339        assert_eq!(child.cache_origin, 0.0);
340        assert_eq!(child.remaining_cache_extent, 730.0);
341        child.debug_assert_valid();
342    }
343
344    #[test]
345    fn past_the_leading_inset_the_cache_window_matches_the_incoming_one() {
346        let sc = constraints(300.0, 500.0);
347        let child = child_constraints(&sc, 20.0, 0.0);
348        assert_eq!(child.scroll_offset, 280.0);
349        // The incoming window starts at 50; the child's at 30 of its own,
350        // which is 50 of the padding's.
351        assert_eq!(child.scroll_offset + child.cache_origin + 20.0, 50.0);
352        assert_eq!(child.remaining_cache_extent, sc.remaining_cache_extent);
353        child.debug_assert_valid();
354    }
355
356    #[test]
357    fn geometry_adds_the_insets_and_clamps_to_the_paint_space() {
358        let sc = constraints(0.0, 500.0);
359        let child_sc = child_constraints(&sc, 20.0, 0.0);
360        let child = box_geometry(100.0, &child_sc);
361        let g = padded_geometry(&sc, &child, 20.0, 30.0);
362        assert_eq!(g.scroll_extent, 150.0);
363        assert_eq!(g.paint_extent, 150.0);
364        assert_eq!(g.layout_extent, 150.0);
365        assert_eq!(g.max_paint_extent, 150.0);
366        assert_eq!(g.hit_test_extent, 150.0);
367        g.debug_assert_valid(&sc);
368
369        // Scrolled 50 in: the leading inset and 30 px of the child are gone.
370        let sc = constraints(50.0, 500.0);
371        let child_sc = child_constraints(&sc, 20.0, 0.0);
372        let child = box_geometry(100.0, &child_sc);
373        assert_eq!(child.paint_extent, 70.0);
374        let g = padded_geometry(&sc, &child, 20.0, 30.0);
375        assert_eq!(g.scroll_extent, 150.0);
376        assert_eq!(g.paint_extent, 100.0);
377        assert!(g.has_visual_overflow);
378        g.debug_assert_valid(&sc);
379
380        // Cut by the end of a short paint space.
381        let sc = constraints(0.0, 60.0);
382        let child_sc = child_constraints(&sc, 20.0, 0.0);
383        let child = box_geometry(100.0, &child_sc);
384        let g = padded_geometry(&sc, &child, 20.0, 30.0);
385        assert_eq!(g.paint_extent, 60.0);
386        assert_eq!(g.layout_extent, 60.0);
387        g.debug_assert_valid(&sc);
388    }
389
390    #[test]
391    fn horizontal_insets_swap_main_and_cross() {
392        let insets = EdgeInsets {
393            left: 1.0,
394            top: 2.0,
395            right: 3.0,
396            bottom: 4.0,
397        };
398        assert_eq!(
399            axis_insets(&insets, ScrollAxis::Vertical),
400            (2.0, 4.0, 1.0, 4.0)
401        );
402        assert_eq!(
403            axis_insets(&insets, ScrollAxis::Horizontal),
404            (1.0, 3.0, 2.0, 6.0)
405        );
406    }
407
408    #[test]
409    fn bad_insets_sanitise_to_zero() {
410        let insets = sanitized(EdgeInsets {
411            left: f64::NAN,
412            top: -3.0,
413            right: f64::INFINITY,
414            bottom: 2.0,
415        });
416        assert_eq!(
417            insets,
418            EdgeInsets {
419                left: 0.0,
420                top: 0.0,
421                right: 0.0,
422                bottom: 2.0,
423            }
424        );
425    }
426}