Skip to main content

frust_widgets/slivers/
adapter.rs

1//! `sliver_box`: one box child inside a scrolling viewport — Flutter's
2//! `SliverToBoxAdapter` (`rendering/sliver.dart`'s `RenderSliverToBoxAdapter`).
3//!
4//! The adapter lays its child out as a box, tight to the viewport's cross axis
5//! and unbounded along the scroll axis
6//! ([`SliverConstraints::as_box_constraints`]), and reports the child's main
7//! extent as its scroll extent. It paints only the part of the child inside the
8//! viewport's remaining paint space: the child is offset by `-scroll_offset`
9//! inside a paint extent of `clamp(extent − scroll_offset, 0,
10//! remaining_paint_extent)`, and clipped when that cuts it
11//! ([`SliverGeometry::has_visual_overflow`]).
12//!
13//! A [`CustomScrollView`](crate::CustomScrollView) wraps every plain box child
14//! in exactly this geometry on its own, so `sliver_box` is only needed to make
15//! the adaptation explicit — or to lay a widget that *does* speak the sliver
16//! protocol out as a box instead. The two share [`box_geometry`] and
17//! [`box_child_origin`], so the explicit and the implicit adapter cannot drift.
18
19use frust_core::{
20    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
21    LayoutCtx, PaintCtx, PaintScene, ScrollAxis, SemanticsCtx, SliverConstraints, SliverGeometry,
22    SliverWidget, View, Widget, any,
23};
24use kurbo::{Point, Size};
25
26/// `value` as a usable extent: NaN becomes `0.0`, a negative value clamps to
27/// `0.0`, and `+∞` is kept (an unbounded extent is meaningful to the sliver
28/// protocol, e.g. a shrink-wrapping viewport's remaining paint extent).
29pub(crate) fn non_negative(value: f64) -> f64 {
30    if value.is_nan() { 0.0 } else { value.max(0.0) }
31}
32
33/// `value` as a finite, non-negative extent: anything NaN or infinite becomes
34/// `0.0`. What a box child's measured main extent is read through, so an
35/// unbounded child scrolls nothing rather than poisoning the viewport's sums.
36pub(crate) fn finite_extent(value: f64) -> f64 {
37    if value.is_finite() {
38        value.max(0.0)
39    } else {
40        0.0
41    }
42}
43
44/// The point `main` px along `axis` and `cross` px across it.
45pub(crate) fn axis_point(axis: ScrollAxis, main: f64, cross: f64) -> Point {
46    match axis {
47        ScrollAxis::Vertical => Point::new(cross, main),
48        ScrollAxis::Horizontal => Point::new(main, cross),
49    }
50}
51
52/// How much of the content span `[from, to)` lies inside the paint region
53/// `sc` describes — Flutter's `RenderSliver.calculatePaintOffset`.
54fn paint_offset_of(sc: &SliverConstraints, from: f64, to: f64) -> f64 {
55    let a = sc.scroll_offset;
56    let b = a + sc.remaining_paint_extent;
57    let span = to.max(a).min(b) - from.max(a).min(b);
58    span.max(0.0).min(sc.remaining_paint_extent)
59}
60
61/// How much of the content span `[from, to)` lies inside the cache region
62/// `sc` describes — Flutter's `RenderSliver.calculateCacheOffset`, with the
63/// region read as [`SliverConstraints::remaining_cache_extent`] documents it:
64/// that much content starting at `cache_origin`.
65fn cache_offset_of(sc: &SliverConstraints, from: f64, to: f64) -> f64 {
66    let a = sc.scroll_offset + sc.cache_origin;
67    let b = a + sc.remaining_cache_extent;
68    let span = to.max(a).min(b) - from.max(a).min(b);
69    span.max(0.0).min(sc.remaining_cache_extent)
70}
71
72/// The geometry of a box child `extent` px long along the scroll axis, laid
73/// out under `sc` — `RenderSliverToBoxAdapter.performLayout`'s.
74///
75/// `extent` is read through [`finite_extent`]. The cache extent is never
76/// reported below the paint extent, so float noise in a caller's running
77/// subtraction cannot trip [`SliverGeometry::debug_assert_valid`].
78pub(crate) fn box_geometry(extent: f64, sc: &SliverConstraints) -> SliverGeometry {
79    let extent = finite_extent(extent);
80    let paint_extent = paint_offset_of(sc, 0.0, extent);
81    let cache_extent = cache_offset_of(sc, 0.0, extent).max(paint_extent);
82    SliverGeometry {
83        cache_extent,
84        has_visual_overflow: extent > sc.remaining_paint_extent || sc.scroll_offset > 0.0,
85        ..SliverGeometry::new(extent, paint_extent, extent)
86    }
87}
88
89/// Where a box child sits relative to its sliver's paint start: scrolled back
90/// by the sliver's own `scroll_offset`, so the part already scrolled past sits
91/// before the paint start (and is clipped away).
92pub(crate) fn box_child_origin(sc: &SliverConstraints) -> Point {
93    axis_point(sc.axis, -sc.scroll_offset, 0.0)
94}
95
96/// A box child adapted to the sliver protocol. See the [module docs](self).
97pub struct SliverBoxView<State: 'static> {
98    child: AnyView<State>,
99}
100
101/// Adapt `child` — any box view — to a sliver: it scrolls by its own main-axis
102/// extent and paints the part of itself inside the viewport. See the
103/// [module docs](self).
104///
105/// ```
106/// use frust_widgets::{CustomScrollView, custom_scroll_view, sliver_box, text};
107/// let view: CustomScrollView<()> = custom_scroll_view((sliver_box(text("header")), text("body")));
108/// # let _ = view;
109/// ```
110pub fn sliver_box<State: 'static, V: View<State>>(child: V) -> SliverBoxView<State> {
111    SliverBoxView { child: any(child) }
112}
113
114/// The retained widget for a [`SliverBoxView`]: the child's pod, plus whether
115/// the last sliver layout cut the child (so paint clips it).
116pub struct SliverBoxWidget {
117    child: ChildPod,
118    /// Whether the last layout left part of the child outside this sliver's
119    /// paint extent — [`SliverGeometry::has_visual_overflow`]. Always `false`
120    /// after a plain box layout, which never cuts the child.
121    clip: bool,
122}
123
124impl<State: 'static> View<State> for SliverBoxView<State> {
125    type Element = SliverBoxWidget;
126
127    fn build(&self, ctx: &mut BuildCtx<'_>) -> SliverBoxWidget {
128        SliverBoxWidget {
129            child: crate::authoring::build_child(&self.child, ctx),
130            clip: false,
131        }
132    }
133
134    fn rebuild(
135        &self,
136        prev: &Self,
137        element: &mut SliverBoxWidget,
138        ctx: &mut BuildCtx<'_>,
139    ) -> ChangeFlags {
140        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
141    }
142
143    fn teardown(&self, element: &mut SliverBoxWidget, ctx: &mut BuildCtx<'_>) {
144        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
145    }
146}
147
148impl Widget for SliverBoxWidget {
149    /// Outside a viewport the adapter is transparent: the child is laid out
150    /// under `bc` at the origin, uncut.
151    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
152        let size = self.child.layout_child(ctx, bc);
153        self.child.set_origin(Point::ZERO);
154        self.clip = false;
155        size
156    }
157
158    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
159        if self.clip {
160            scene.push_clip(ctx.origin(), ctx.size());
161        }
162        self.child.paint_child(ctx, scene);
163        if self.clip {
164            scene.pop_clip();
165        }
166    }
167
168    /// The viewport only delivers a positional event inside this sliver's
169    /// paint extent (or to a captured child), and that extent lies inside the
170    /// child's own bounds, so the ordinary single-child routing applies.
171    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
172        crate::authoring::route_event_single(&mut self.child, ctx, event)
173    }
174
175    fn semantics(&self, ctx: &mut SemanticsCtx) {
176        self.child.semantics_child(ctx);
177    }
178
179    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
180        Some(self)
181    }
182
183    crate::authoring::visit_children!(child);
184}
185
186impl SliverWidget for SliverBoxWidget {
187    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
188        let size = self.child.layout_child(ctx, &sc.as_box_constraints());
189        let geometry = box_geometry(sc.axis.main_extent(size), sc);
190        self.child.set_origin(box_child_origin(sc));
191        self.clip = geometry.has_visual_overflow;
192        geometry
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use frust_core::ScrollDirection;
200
201    fn constraints(scroll_offset: f64, remaining_paint_extent: f64) -> SliverConstraints {
202        SliverConstraints {
203            axis: ScrollAxis::Vertical,
204            scroll_offset,
205            preceding_scroll_extent: 0.0,
206            overlap: 0.0,
207            remaining_paint_extent,
208            cross_axis_extent: 400.0,
209            viewport_main_axis_extent: 500.0,
210            cache_origin: -scroll_offset.min(250.0),
211            remaining_cache_extent: remaining_paint_extent + 250.0 + scroll_offset.min(250.0),
212            user_scroll_direction: ScrollDirection::Idle,
213        }
214    }
215
216    #[test]
217    fn a_fully_visible_child_paints_its_whole_extent_uncut() {
218        let sc = constraints(0.0, 500.0);
219        let g = box_geometry(300.0, &sc);
220        assert_eq!(g.scroll_extent, 300.0);
221        assert_eq!(g.paint_extent, 300.0);
222        assert_eq!(g.layout_extent, 300.0);
223        assert_eq!(g.max_paint_extent, 300.0);
224        assert_eq!(g.cache_extent, 300.0);
225        assert!(g.visible && !g.has_visual_overflow);
226        g.debug_assert_valid(&sc);
227    }
228
229    #[test]
230    fn a_scrolled_child_paints_what_is_left_and_is_cut() {
231        let sc = constraints(250.0, 500.0);
232        let g = box_geometry(300.0, &sc);
233        assert_eq!(g.paint_extent, 50.0);
234        assert_eq!(g.hit_test_extent, 50.0);
235        assert!(g.has_visual_overflow);
236        assert_eq!(box_child_origin(&sc), Point::new(0.0, -250.0));
237        g.debug_assert_valid(&sc);
238    }
239
240    #[test]
241    fn a_child_in_the_cache_region_is_cached_but_invisible() {
242        let sc = SliverConstraints {
243            remaining_paint_extent: 0.0,
244            remaining_cache_extent: 250.0,
245            cache_origin: 0.0,
246            ..constraints(0.0, 0.0)
247        };
248        let g = box_geometry(100.0, &sc);
249        assert_eq!(g.paint_extent, 0.0);
250        assert!(!g.visible);
251        assert_eq!(g.cache_extent, 100.0);
252        g.debug_assert_valid(&sc);
253    }
254
255    #[test]
256    fn an_unbounded_or_nan_extent_scrolls_nothing() {
257        let sc = constraints(0.0, 500.0);
258        assert_eq!(box_geometry(f64::INFINITY, &sc), SliverGeometry::ZERO);
259        assert_eq!(box_geometry(f64::NAN, &sc), SliverGeometry::ZERO);
260        assert_eq!(box_geometry(-5.0, &sc), SliverGeometry::ZERO);
261    }
262
263    #[test]
264    fn extents_sanitise_without_panicking() {
265        assert_eq!(non_negative(f64::NAN), 0.0);
266        assert_eq!(non_negative(-1.0), 0.0);
267        assert_eq!(non_negative(f64::INFINITY), f64::INFINITY);
268        assert_eq!(finite_extent(f64::INFINITY), 0.0);
269        assert_eq!(finite_extent(12.5), 12.5);
270    }
271}