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}