Skip to main content

frust_widgets/
viewport.rs

1//! `CustomScrollView`: a scroll surface whose children are slivers — Flutter's
2//! `CustomScrollView` over `RenderViewport` (`rendering/viewport.dart`).
3//!
4//! A [`ScrollView`](crate::ScrollView) lays its one child out at unbounded
5//! height and scrolls the result. A [`CustomScrollView`] instead lays each of
6//! its children out with the sliver protocol ([`SliverConstraints`] in,
7//! [`SliverGeometry`] out; see [`frust_core::SliverWidget`]): every child is
8//! told where the viewport has scrolled to and how much paint space is left,
9//! so a sliver can lay out only what is visible or inside the cache area. The
10//! drag, fling, overscroll, nested-claim and controller machinery is the
11//! shared [`crate::scroll_core`] that `ScrollView` and `ListView` run on, so all
12//! three behave the same under the finger, the wheel and a
13//! [`ScrollController`].
14//!
15//! # The layout loop
16//!
17//! [`ViewportWidget`]'s layout is `RenderViewport.performLayout` for a viewport
18//! anchored at its leading edge:
19//!
20//! 1. The main-axis extent is the incoming constraint along the scroll axis;
21//!    the cross-axis extent the constraint across it. The cache area reaches
22//!    [`CustomScrollView::cache_extent`] past both ends of the visible region.
23//! 2. Children are laid out in order. Each gets a [`SliverConstraints`] built
24//!    from the running state: `scroll_offset = max(0, offset − preceding)`,
25//!    the preceding scroll extent, the overlap left by earlier slivers' paint
26//!    (`max_paint_offset − layout_offset`), the paint extent still unclaimed,
27//!    the cache window `[offset − cache, offset + extent + cache)` expressed
28//!    from the child's own scroll offset (`cache_origin` never reaching before
29//!    the child's start, `remaining_cache_extent` measured from there), and
30//!    the user's scroll direction. Every value is sanitised
31//!    first (NaN dropped, negatives clamped to zero, the cache origin kept
32//!    non-positive), so a transient zero or NaN window size can never trip
33//!    [`SliverConstraints::debug_assert_valid`].
34//! 3. A child answers through [`ChildPod::layout_sliver_child`], which applies
35//!    the per-pod layout skip. A child that is not a sliver answers `None` and
36//!    is laid out as a box through the same geometry as [`crate::sliver_box`]
37//!    (tight across, unbounded along, painted at `−scroll_offset` inside its
38//!    paint extent and clipped when cut).
39//! 4. A child reporting a
40//!    [`scroll_offset_correction`](SliverGeometry::scroll_offset_correction)
41//!    ends the attempt: the correction is applied to the scroll position (and
42//!    to the drag accumulator, so a live drag continues from the corrected
43//!    position; a running release simulation restarts from it at its current
44//!    velocity, while a controller tween keeps its absolute target, as
45//!    Flutter's does) and the loop starts again. The restarts are bounded at
46//!    ten cycles: a sliver still correcting on the last one trips a
47//!    `debug_assert!`, and a release build keeps that cycle's layout,
48//!    correction ignored.
49//! 5. Each child's pod is placed at the running paint position (plus its
50//!    `paint_origin`), and the maximum scroll extent — the sum of every
51//!    child's scroll extent less the viewport extent, never negative — is
52//!    published to the core, the attached controller and [`ScrollInfo`].
53//!
54//! After the loop, a position the new extent leaves out of range is clamped
55//! (outside a live drag, settle or simulation, exactly as `ScrollView` does)
56//! and any queued controller command is applied; either one moving the
57//! position runs the loop once more.
58//!
59//! Under an unbounded main axis the viewport shrink-wraps: every child is
60//! given infinite remaining paint extent and the viewport sizes itself to the
61//! sum of the children's scroll extents, so nothing scrolls. An unbounded
62//! cross axis lays children out at zero cross extent, as `ScrollView` treats
63//! an unbounded width.
64//!
65//! # Placement is a layout product
66//!
67//! A child's origin is decided by the loop alone, never adjusted at paint:
68//! moving a sliver changes its constraints, which only a layout can answer.
69//! Motion the paint pump drives — a fling, a release settle, a controller
70//! tween — therefore shows up on the frame after the pump moved the position:
71//! the paint that moved it requests a layout
72//! ([`PaintCtx::request_layout`]), and that frame lays out at the new
73//! position. Input-driven motion (a drag, the wheel) and a jump drained at
74//! layout have no such lag.
75//!
76//! # Paint, hit testing and semantics
77//!
78//! Paint clips to the viewport, publishes it as the visible rect
79//! ([`PaintCtx::constrain_visible_rect`]) so a `Flex` or a list inside a sliver
80//! can cull, and skips every child whose geometry is not
81//! [`visible`](SliverGeometry::visible) — a child laid out only because it
82//! sits in the cache area is never painted. Children paint last to first, so
83//! an earlier sliver paints over a later one it overlaps (Flutter's paint
84//! order), and are hit-tested first to last, each only within its
85//! [`hit_test_extent`](SliverGeometry::hit_test_extent) and the viewport's
86//! bounds. Semantics is a [`Role::ScrollView`] node carrying the scroll
87//! position and range along the axis, over the children that are visible or
88//! cached.
89//!
90//! # Which children are slivers
91//!
92//! A child is a sliver when its widget answers [`Widget::as_sliver`]. The
93//! transparent wrappers forward that answer to what they wrap, so a sliver
94//! stays a sliver inside them: [`Either`](crate::Either) and
95//! [`EitherOf3`](crate::EitherOf3) through [`EitherOf8`](crate::EitherOf8),
96//! [`hero`](crate::hero), [`shield`](crate::shield),
97//! [`auto_scroll_zone`](crate::auto_scroll_zone) and
98//! [`GestureDetector`](crate::GestureDetector) — and an [`AnyView`] or a
99//! component boundary, which forward through the boxed widget. Any other
100//! single-child wrapper (`Padding`, `Align`, `SizedBox`, a container, …)
101//! answers `None`, so the viewport lays it out as a box, exactly as
102//! [`crate::sliver_box`] would, whatever it wraps.
103//!
104//! # Axis
105//!
106//! [`CustomScrollView::axis`] picks vertical (the default) or horizontal
107//! scrolling. The shared core is built with the viewport's axis and reads
108//! pointer travel and wheel deltas along it, so every event reaches the core
109//! untouched. The one transposition left is the stretch overscroll effect: the
110//! core's stretch transform is vertical-only, so a horizontal viewport mirrors
111//! its origin and size through a swap around it.
112//!
113//! **Nested claims are axis-aware.** The ambient inner-scroll claim
114//! (`scroll.rs`'s *Nested scrolling*) carries no axis, and every other scroll
115//! surface is vertical. A viewport therefore registers into its nearest
116//! enclosing surface's claim only when the two scroll along the same axis, and
117//! discards a claim registered into its own cell by a surface on the other
118//! axis. A horizontal carousel inside a vertical list thus never makes the
119//! list defer a vertical drag to it, and vice versa.
120//!
121//! # Limits
122//!
123//! Growth is forward only: slivers are laid out from the leading edge, in the
124//! axis' natural direction ([`frust_core::GrowthDirection::Forward`]). There is
125//! no center anchor (Flutter's `center`/`anchor`), so nothing can grow toward
126//! the leading edge, and no overlap absorber (Flutter's
127//! `SliverOverlapAbsorber`/`NestedScrollView`).
128
129use std::cell::{Cell, RefCell};
130use std::rc::Rc;
131
132use frust_core::accesskit::Role;
133use frust_core::{
134    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
135    LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, ScrollAxis,
136    ScrollDirection, SemanticsCtx, SliverConstraints, SliverGeometry, View, ViewSeq, Widget,
137};
138use frust_theme::Theme;
139use kurbo::{Affine, Point, Rect, Size};
140
141use crate::authoring::presses;
142use crate::physics::effect::OverscrollEffect;
143use crate::physics::{ScrollPhysics, default_overscroll_effect};
144use crate::scroll::{InnerScrollState, ScrollInfo, ambient_scroll_claim};
145use crate::scroll_controller::ScrollController;
146use crate::scroll_core::{BallisticState, ScrollSurface, ScrollSurfaceCore};
147use crate::slivers::adapter::{
148    axis_point, box_child_origin, box_geometry, finite_extent, non_negative,
149};
150
151/// How far past each end of the visible region a [`CustomScrollView`] lays
152/// children out by default, in logical px — Flutter's
153/// `RenderAbstractViewport.defaultCacheExtent`.
154pub const DEFAULT_CACHE_EXTENT: f64 = 250.0;
155
156/// How many times one layout may restart for a
157/// [`scroll_offset_correction`](SliverGeometry::scroll_offset_correction)
158/// before the viewport gives up on converging (the module docs' *The layout
159/// loop*).
160const MAX_LAYOUT_CYCLES: usize = 10;
161
162/// A view-held scroll-observation callback (erased on build).
163type OnScroll<State> = Rc<dyn Fn(&mut State, ScrollInfo)>;
164
165/// A view-held pull-to-refresh release callback (erased on build).
166type OnRefresh<State> = Rc<dyn Fn(&mut State)>;
167
168/// A declarative scroll surface over a sequence of slivers. See the
169/// [module docs](self).
170pub struct CustomScrollView<State: 'static> {
171    slivers: Vec<AnyView<State>>,
172    axis: ScrollAxis,
173    cache_extent: f64,
174    on_scroll: Option<OnScroll<State>>,
175    on_refresh_release: Option<OnRefresh<State>>,
176    physics: Option<Rc<dyn ScrollPhysics>>,
177    effect: OverscrollEffect,
178    controller: Option<ScrollController>,
179}
180
181/// A vertical [`CustomScrollView`] over `slivers`.
182///
183/// `slivers` is any [`ViewSeq`]: a `Vec`/array of views, a tuple of mixed
184/// view types, an `Option` (`None` drops the slot), or `views(iter)`. A child
185/// that is not a sliver is laid out as a box through the
186/// [`sliver_box`](crate::sliver_box) geometry, so plain views mix freely with
187/// slivers.
188///
189/// ```
190/// use frust_widgets::{CustomScrollView, custom_scroll_view, sliver_box, text};
191/// let view: CustomScrollView<()> = custom_scroll_view((text("a"), sliver_box(text("b"))));
192/// # let _ = view;
193/// ```
194pub fn custom_scroll_view<State: 'static, M>(
195    slivers: impl ViewSeq<State, M>,
196) -> CustomScrollView<State> {
197    let mut list = Vec::new();
198    slivers.extend_views(&mut list);
199    CustomScrollView {
200        slivers: list,
201        axis: ScrollAxis::Vertical,
202        cache_extent: DEFAULT_CACHE_EXTENT,
203        on_scroll: None,
204        on_refresh_release: None,
205        physics: None,
206        effect: default_overscroll_effect(),
207        controller: None,
208    }
209}
210
211impl<State: 'static> CustomScrollView<State> {
212    /// Scroll along `axis` — [`ScrollAxis::Vertical`] by default. See the
213    /// module docs' *Axis*.
214    ///
215    /// ```
216    /// use frust_core::ScrollAxis;
217    /// use frust_widgets::{CustomScrollView, custom_scroll_view, text};
218    /// let view: CustomScrollView<()> =
219    ///     custom_scroll_view((text("a"), text("b"))).axis(ScrollAxis::Horizontal);
220    /// # let _ = view;
221    /// ```
222    pub fn axis(mut self, axis: ScrollAxis) -> Self {
223        self.axis = axis;
224        self
225    }
226
227    /// Lay out children up to `extent` logical px past each end of the visible
228    /// region ([`DEFAULT_CACHE_EXTENT`] unless set), so content about to
229    /// scroll in is already laid out. A cached child is laid out but not
230    /// painted. A negative, NaN or infinite value is treated as `0.0`.
231    pub fn cache_extent(mut self, extent: f64) -> Self {
232        self.cache_extent = extent;
233        self
234    }
235
236    /// Observe scroll position changes — the same contract as
237    /// [`ScrollView::on_scroll`](crate::ScrollView::on_scroll): fired on each
238    /// input-driven change, one event late for paint-driven motion, and
239    /// flushed on the next frame for a controller jump or tween.
240    pub fn on_scroll<F: Fn(&mut State, ScrollInfo) + 'static>(mut self, callback: F) -> Self {
241        self.on_scroll = Some(Rc::new(callback));
242        self
243    }
244
245    /// The pull-to-refresh trigger — the same contract as
246    /// [`ScrollView::on_refresh_release`](crate::ScrollView::on_refresh_release):
247    /// fires on pointer `Up` when the surface was pulled past its leading edge
248    /// by more than the shared trigger distance, never on a `Cancel`.
249    pub fn on_refresh_release<F: Fn(&mut State) + 'static>(mut self, callback: F) -> Self {
250        self.on_refresh_release = Some(Rc::new(callback));
251        self
252    }
253
254    /// Install a custom [`ScrollPhysics`] — the same build/rebuild contract as
255    /// [`ScrollView::physics`](crate::ScrollView::physics): a view carrying one
256    /// reinstalls it every rebuild, a view with none leaves the installed
257    /// physics alone, and a fresh build starts at the platform default.
258    ///
259    /// ```
260    /// use frust_widgets::{Clamping, CustomScrollView, custom_scroll_view, text};
261    /// let view: CustomScrollView<()> = custom_scroll_view(text("a")).physics(Clamping::new());
262    /// # let _ = view;
263    /// ```
264    pub fn physics(mut self, physics: impl ScrollPhysics + 'static) -> Self {
265        self.physics = Some(Rc::new(physics));
266        self
267    }
268
269    /// Select how past-edge pull is visualized — the same contract as
270    /// [`ScrollView::overscroll_effect`](crate::ScrollView::overscroll_effect).
271    /// Under [`OverscrollEffect::Translate`] the pull moves the slivers (they
272    /// are laid out at the out-of-range position); the other two lay them out
273    /// at the in-range one.
274    pub fn overscroll_effect(mut self, effect: OverscrollEffect) -> Self {
275        self.effect = effect;
276        self
277    }
278
279    /// Attach a [`ScrollController`] — the same build/rebuild contract as
280    /// [`ScrollView::controller`](crate::ScrollView::controller). Item commands
281    /// ([`ScrollController::scroll_to_item`]) are list-only and ignored here.
282    pub fn controller(mut self, controller: ScrollController) -> Self {
283        self.controller = Some(controller);
284        self
285    }
286}
287
288/// What the last layout decided for one child.
289#[derive(Clone, Copy, Debug)]
290struct Slot {
291    /// The geometry the child reported (or the box adapter computed).
292    geometry: SliverGeometry,
293    /// Where the child's paint starts along the scroll axis, in viewport space.
294    paint_offset: f64,
295    /// Whether the child was laid out as a box through the adapter geometry.
296    is_box: bool,
297}
298
299impl Default for Slot {
300    fn default() -> Self {
301        Self {
302            geometry: SliverGeometry::ZERO,
303            paint_offset: 0.0,
304            is_box: false,
305        }
306    }
307}
308
309/// The retained widget for a [`CustomScrollView`]: the children's pods and
310/// what the last layout decided for each, the scroll position, and the shared
311/// scroll core (the module docs).
312pub struct ViewportWidget {
313    children: Vec<ChildPod>,
314    slots: Vec<Slot>,
315    axis: ScrollAxis,
316    /// The view's cache extent, sanitised to a finite non-negative value.
317    cache_extent: f64,
318    /// The effective scroll position, past-edge displacement included —
319    /// `ScrollWidget`'s storage shape: a drag past an edge lets it run out of
320    /// range, and the clamped paths go through [`ViewportWidget::set_offset`].
321    offset: f64,
322    /// This widget's own size from the last layout.
323    viewport: Size,
324    /// The main-axis extent children were laid out against: the incoming
325    /// constraint, or `+∞` when shrink-wrapping.
326    main_extent: f64,
327    /// The cross-axis extent children were laid out against.
328    cross_extent: f64,
329    /// The finite main-axis extent the viewport occupies — what the physics
330    /// reads as the viewport dimension.
331    viewport_main: f64,
332    /// The largest in-range position from the last completed layout.
333    max_extent: f64,
334    /// The position the last layout laid the children out at
335    /// ([`ViewportWidget::layout_pixels`] at the time).
336    laid_out_pixels: f64,
337    /// The direction the position last moved in between two layouts.
338    direction: ScrollDirection,
339    /// Set for exactly the primary `Down` forward, so the forward installs this
340    /// viewport's axis frame around it (the module docs' *Axis*).
341    arming_down: bool,
342    /// The axis a nested viewport reported when it registered into this
343    /// viewport's claim cell on the last `Down` — `None` when nothing did, or
344    /// when the registrant was a (vertical) `ScrollView`/`ListView`.
345    down_inner_axis: Option<ScrollAxis>,
346    core: ScrollSurfaceCore,
347}
348
349impl ViewportWidget {
350    fn new(children: Vec<ChildPod>, axis: ScrollAxis, cache_extent: f64) -> Self {
351        let slots = vec![Slot::default(); children.len()];
352        Self {
353            children,
354            slots,
355            axis,
356            cache_extent: finite_extent(cache_extent),
357            offset: 0.0,
358            viewport: Size::ZERO,
359            main_extent: 0.0,
360            cross_extent: 0.0,
361            viewport_main: 0.0,
362            max_extent: 0.0,
363            laid_out_pixels: 0.0,
364            direction: ScrollDirection::Idle,
365            arming_down: false,
366            down_inner_axis: None,
367            core: ScrollSurfaceCore::with_axis(axis),
368        }
369    }
370
371    /// The current scroll position (in range unless a drag, settle or
372    /// simulation holds it past an edge).
373    pub fn offset(&self) -> f64 {
374        self.offset
375    }
376
377    /// The largest in-range scroll position: the children's summed scroll
378    /// extent less the viewport extent, never negative.
379    pub fn max_offset(&self) -> f64 {
380        self.max_extent
381    }
382
383    /// The axis this viewport scrolls along.
384    pub fn axis(&self) -> ScrollAxis {
385        self.core.axis()
386    }
387
388    /// How many children the viewport holds.
389    pub fn sliver_count(&self) -> usize {
390        self.children.len()
391    }
392
393    /// The geometry child `index` reported from the last layout — the box
394    /// adapter's geometry for a child laid out as a box — or `None` past the
395    /// last child.
396    pub fn sliver_geometry(&self, index: usize) -> Option<SliverGeometry> {
397        self.slots.get(index).map(|slot| slot.geometry)
398    }
399
400    /// Whether post-release motion is in flight (a fling or a simulation).
401    pub fn is_flinging(&self) -> bool {
402        self.core.is_flinging()
403    }
404
405    fn set_offset(&mut self, value: f64) {
406        self.offset = value.clamp(0.0, self.max_extent);
407    }
408
409    /// The position children are laid out at: the live position under
410    /// [`OverscrollEffect::Translate`], the in-range one under the other two
411    /// effects (which paint the pull differently or not at all). Never NaN.
412    fn layout_pixels(&self) -> f64 {
413        let pixels = match self.core.effect {
414            OverscrollEffect::Translate => self.offset,
415            OverscrollEffect::Stretch | OverscrollEffect::None => self.offset - self.displacement(),
416        };
417        if pixels.is_finite() { pixels } else { 0.0 }
418    }
419
420    /// Whether user input owns the current motion — a drag, a release fling,
421    /// settle or simulation — rather than a controller tween or nothing.
422    fn user_motion(&self) -> bool {
423        let core = &self.core;
424        core.scrolling
425            || core.settling
426            || core.fling.is_some()
427            || (core.ballistic.is_some() && !core.controller_animating)
428    }
429
430    /// The `user_scroll_direction` children are laid out with.
431    fn user_scroll_direction(&self) -> ScrollDirection {
432        if self.user_motion() {
433            self.direction
434        } else {
435            ScrollDirection::Idle
436        }
437    }
438
439    /// Record which way the position moved since the last layout.
440    fn observe_direction(&mut self) {
441        let pixels = self.layout_pixels();
442        if pixels < self.laid_out_pixels {
443            self.direction = ScrollDirection::Forward;
444        } else if pixels > self.laid_out_pixels {
445            self.direction = ScrollDirection::Reverse;
446        }
447    }
448
449    /// Run the layout loop, restarting it for each correction a child
450    /// reports, at most [`MAX_LAYOUT_CYCLES`] times.
451    fn layout_slivers(&mut self, ctx: &mut LayoutCtx) {
452        for cycle in 0..MAX_LAYOUT_CYCLES {
453            let last = cycle + 1 == MAX_LAYOUT_CYCLES;
454            match self.attempt_layout(ctx, !last) {
455                Some(correction) => self.apply_correction(correction),
456                None => return,
457            }
458        }
459    }
460
461    /// Shift the position by a child's `correction` before the loop restarts.
462    /// A live drag's accumulator moves with it; a release simulation restarts
463    /// from the corrected position at its current velocity (its curve is in
464    /// absolute positions and would otherwise drag the content straight back);
465    /// a controller tween keeps its absolute target.
466    fn apply_correction(&mut self, correction: f64) {
467        if !correction.is_finite() {
468            return;
469        }
470        self.offset += correction;
471        self.core.drag_position += correction;
472        if self.core.controller_animating {
473            return;
474        }
475        let Some(velocity) = self
476            .core
477            .ballistic
478            .as_ref()
479            .map(|state| state.sim.dx(state.elapsed_secs(self.core.last_frame_time)))
480        else {
481            return;
482        };
483        let metrics = self.metrics();
484        match self
485            .core
486            .physics
487            .create_ballistic_simulation(&metrics, velocity)
488        {
489            Some(sim) => self.core.ballistic = Some(BallisticState { sim, start: None }),
490            None => self.core.stop_ballistic(),
491        }
492    }
493
494    /// One pass of the layout loop (the module docs' *The layout loop*).
495    /// Returns the correction that ended it early, if any — never on the
496    /// final cycle (`allow_correction` false), which lays every child out and
497    /// keeps the result.
498    fn attempt_layout(&mut self, ctx: &mut LayoutCtx, allow_correction: bool) -> Option<f64> {
499        let axis = self.axis;
500        let main = self.main_extent;
501        let cross = self.cross_extent;
502        let cache = self.cache_extent;
503        let pixels = self.layout_pixels();
504        let direction = self.user_scroll_direction();
505
506        // The viewport's leading edge is its anchor: content starts `pixels`
507        // before it.
508        let center_offset = -pixels;
509        let reverse_remaining_paint = center_offset.max(0.0).min(main);
510        let remaining_paint = (main - center_offset).max(0.0).min(main);
511        let mut scroll_offset = pixels.max(0.0);
512        let mut layout_offset = if center_offset >= main {
513            center_offset
514        } else {
515            reverse_remaining_paint
516        };
517        let initial_layout_offset = layout_offset;
518        let mut max_paint_offset = layout_offset + pixels.min(0.0);
519        let mut preceding = 0.0;
520
521        for (pod, slot) in self.children.iter_mut().zip(self.slots.iter_mut()) {
522            let sliver_scroll_offset = scroll_offset.max(0.0);
523            // The cache window is `[pixels − cache, pixels + main + cache)` in
524            // scroll terms; in this child's own terms it starts `cache` before
525            // its scroll offset (never before its own start) and runs from
526            // there to the window's end.
527            let cache_origin = (-cache).max(-sliver_scroll_offset);
528            let remaining_cache =
529                scroll_offset + main + cache - sliver_scroll_offset - cache_origin;
530            let sc = sanitized(SliverConstraints {
531                axis,
532                scroll_offset: sliver_scroll_offset,
533                preceding_scroll_extent: preceding,
534                overlap: max_paint_offset - layout_offset,
535                remaining_paint_extent: remaining_paint - layout_offset + initial_layout_offset,
536                cross_axis_extent: cross,
537                viewport_main_axis_extent: main,
538                cache_origin,
539                remaining_cache_extent: remaining_cache,
540                user_scroll_direction: direction,
541            });
542            let (geometry, is_box) = match pod.layout_sliver_child(ctx, &sc) {
543                Some(geometry) => (geometry, false),
544                None => {
545                    let size = pod.layout_child(ctx, &sc.as_box_constraints());
546                    (box_geometry(axis.main_extent(size), &sc), true)
547                }
548            };
549            if let Some(correction) = geometry.scroll_offset_correction {
550                if allow_correction {
551                    return Some(correction);
552                }
553                debug_assert!(
554                    false,
555                    "CustomScrollView: a sliver still asked for a scroll-offset correction \
556                     ({correction}) after {MAX_LAYOUT_CYCLES} layout cycles"
557                );
558            }
559            let effective_offset = layout_offset + geometry.paint_origin;
560            let paint_offset = if geometry.visible || scroll_offset > 0.0 {
561                effective_offset
562            } else {
563                -scroll_offset + initial_layout_offset
564            };
565            let origin = axis_point(axis, paint_offset, 0.0);
566            pod.set_origin(if is_box {
567                origin + box_child_origin(&sc).to_vec2()
568            } else {
569                origin
570            });
571            *slot = Slot {
572                geometry,
573                paint_offset,
574                is_box,
575            };
576            max_paint_offset = (effective_offset + geometry.paint_extent).max(max_paint_offset);
577            scroll_offset -= geometry.scroll_extent;
578            preceding += geometry.scroll_extent;
579            layout_offset += geometry.layout_extent;
580        }
581
582        let total = finite_extent(preceding);
583        if main.is_finite() {
584            self.viewport_main = main;
585            self.max_extent = (total - main).max(0.0);
586        } else {
587            // Shrink-wrapping: the viewport is as long as its content.
588            self.viewport_main = total;
589            self.max_extent = 0.0;
590        }
591        self.laid_out_pixels = pixels;
592        None
593    }
594
595    /// Whether `position` (viewport space) falls inside child `index`'s hit
596    /// region: from its paint start for its hit-test extent, inside the
597    /// viewport.
598    fn hits(&self, index: usize, position: Point) -> bool {
599        let Some(slot) = self.slots.get(index) else {
600            return false;
601        };
602        let main = along(self.axis, position);
603        let across = along(transpose_axis(self.axis), position);
604        let local = main - slot.paint_offset;
605        main >= 0.0
606            && main < self.viewport_main
607            && across >= 0.0
608            && across < self.cross_extent
609            && local >= 0.0
610            && local < slot.geometry.hit_test_extent
611    }
612
613    /// Route a content event to the children: a broadcast, a focus-routed
614    /// event or a captured gesture through
615    /// [`crate::authoring::route_event`]'s own paths; anything else hit-tested
616    /// first child to last against each child's hit region
617    /// ([`ViewportWidget::hits`]), with `route_event`'s blur-on-outside-`Down`
618    /// sweep.
619    fn route_to_slivers(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
620        if event.is_broadcast()
621            || event.is_focus_routed()
622            || self.children.iter().any(ChildPod::is_active)
623        {
624            return crate::authoring::route_event(&mut self.children, ctx, event);
625        }
626        let position = event.position();
627        let mut handled = EventResult::Ignored;
628        let mut kept_focus = None;
629        for index in 0..self.children.len() {
630            if self.hits(index, position)
631                && self.children[index].event_child(ctx, event) == EventResult::Handled
632            {
633                if self.children[index].holds_live_focus() {
634                    kept_focus = Some(index);
635                }
636                handled = EventResult::Handled;
637                break;
638            }
639        }
640        if matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Down) {
641            for (index, pod) in self.children.iter_mut().enumerate() {
642                if Some(index) != kept_focus && pod.is_focused() {
643                    pod.set_focused(false);
644                }
645            }
646        }
647        handled
648    }
649
650    /// Hand a pointer event to the core, with the axis-aware claim rule around
651    /// a primary `Down` (the module docs' *Axis*).
652    fn pointer_on_axis(
653        &mut self,
654        ctx: &mut EventCtx,
655        event: &InputEvent,
656        p: &PointerEvent,
657        t_ms: f64,
658    ) -> EventResult {
659        let primary_down = p.phase == PointerPhase::Down && presses(p);
660        // A registration this viewport must not leave in its host's cell: the
661        // host scrolls along the other axis.
662        let mut restore = None;
663        if primary_down && let Some(host) = ambient_scroll_claim() {
664            match enclosing_viewport(&host) {
665                Some(frame) if frame.axis == self.axis => frame.inner_axis.set(Some(self.axis)),
666                Some(_) => restore = Some((host.get(), host)),
667                // The enclosing surface is a `ScrollView` or `ListView`.
668                None if self.axis == ScrollAxis::Vertical => {}
669                None => restore = Some((host.get(), host)),
670            }
671        }
672        self.arming_down = primary_down;
673        if primary_down {
674            self.down_inner_axis = None;
675        }
676        let result = self.handle_pointer(ctx, event, p, t_ms);
677        self.arming_down = false;
678        if let Some((saved, host)) = restore {
679            host.set(saved);
680        }
681        if primary_down
682            && self.core.inner_at_down.registered
683            && self.down_inner_axis.unwrap_or(ScrollAxis::Vertical) != self.axis
684        {
685            self.core.inner_at_down = InnerScrollState::default();
686        }
687        result
688    }
689
690    /// The event entry, parameterised on an explicit timestamp so velocity
691    /// math is deterministic in tests; [`Widget::event`] supplies the real
692    /// clock. Publishes whatever position the event left behind.
693    fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
694        let result = self.route_event_at(ctx, event, t_ms);
695        self.publish_to_controller();
696        result
697    }
698
699    /// The event body behind [`ViewportWidget::event_at`]: content events
700    /// are routed here, the wheel and pointer gesture by the shared core.
701    fn route_event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
702        if !matches!(
703            event,
704            InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel
705        ) {
706            self.deliver_pending_scroll(ctx);
707        }
708        match event {
709            InputEvent::Scroll { delta, .. } => self.handle_wheel(ctx, delta),
710            InputEvent::Pointer(p) => self.pointer_on_axis(ctx, event, p, t_ms),
711            // Broadcasts, focus-routed events and anything hit-tested (a
712            // `Scale`, a file drop) belong to the content.
713            _ => self.route_to_slivers(ctx, event),
714        }
715    }
716
717    /// The stretch overscroll transform for the viewport at `origin`/`size`
718    /// (absolute), along this viewport's axis.
719    fn stretch_transform(&self, origin: Point, size: Size) -> Option<Affine> {
720        match self.axis {
721            ScrollAxis::Vertical => self.core.stretch_transform(origin, size),
722            ScrollAxis::Horizontal => {
723                let swap = Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]);
724                self.core
725                    .stretch_transform(transpose(origin), Size::new(size.height, size.width))
726                    .map(|transform| swap * transform * swap)
727            }
728        }
729    }
730}
731
732/// `sc` with every caller-derived extent made safe for
733/// [`SliverConstraints::debug_assert_valid`]: NaN dropped, negatives clamped
734/// to zero (infinity kept), the cache origin kept non-positive.
735fn sanitized(sc: SliverConstraints) -> SliverConstraints {
736    let not_nan = |value: f64| if value.is_nan() { 0.0 } else { value };
737    SliverConstraints {
738        scroll_offset: non_negative(sc.scroll_offset),
739        preceding_scroll_extent: not_nan(sc.preceding_scroll_extent),
740        overlap: not_nan(sc.overlap),
741        remaining_paint_extent: non_negative(sc.remaining_paint_extent),
742        cross_axis_extent: non_negative(sc.cross_axis_extent),
743        viewport_main_axis_extent: non_negative(sc.viewport_main_axis_extent),
744        cache_origin: not_nan(sc.cache_origin).min(0.0),
745        remaining_cache_extent: non_negative(sc.remaining_cache_extent),
746        ..sc
747    }
748}
749
750/// `point`'s component along `axis`.
751fn along(axis: ScrollAxis, point: Point) -> f64 {
752    match axis {
753        ScrollAxis::Vertical => point.y,
754        ScrollAxis::Horizontal => point.x,
755    }
756}
757
758/// The other axis.
759fn transpose_axis(axis: ScrollAxis) -> ScrollAxis {
760    match axis {
761        ScrollAxis::Vertical => ScrollAxis::Horizontal,
762        ScrollAxis::Horizontal => ScrollAxis::Vertical,
763    }
764}
765
766/// `point` with its two components swapped.
767fn transpose(point: Point) -> Point {
768    Point::new(point.y, point.x)
769}
770
771// --- Axis-aware nested claims ------------------------------------------------
772
773/// A viewport currently forwarding a primary `Down`: the claim cell it pushed
774/// around that forward, its axis, and the axis of the nested viewport that
775/// registered into that cell (if one did).
776struct AxisFrame {
777    claim: Rc<Cell<InnerScrollState>>,
778    axis: ScrollAxis,
779    inner_axis: Cell<Option<ScrollAxis>>,
780}
781
782thread_local! {
783    /// The viewports currently forwarding a primary `Down`, innermost last —
784    /// what lets a nested viewport learn its enclosing surface's axis (the
785    /// module docs' *Axis*). UI-thread-affine like the claim stack it shadows.
786    static VIEWPORT_FRAMES: RefCell<Vec<Rc<AxisFrame>>> = const { RefCell::new(Vec::new()) };
787}
788
789/// Pops [`VIEWPORT_FRAMES`] on drop, so an unwinding dispatch cannot leave a
790/// stale frame behind.
791struct AxisFrameGuard;
792
793impl Drop for AxisFrameGuard {
794    fn drop(&mut self) {
795        VIEWPORT_FRAMES.with(|frames| {
796            frames.borrow_mut().pop();
797        });
798    }
799}
800
801/// Run `f` with `frame` installed as the innermost viewport frame.
802fn with_axis_frame<R>(frame: &Rc<AxisFrame>, f: impl FnOnce() -> R) -> R {
803    VIEWPORT_FRAMES.with(|frames| frames.borrow_mut().push(Rc::clone(frame)));
804    let _guard = AxisFrameGuard;
805    f()
806}
807
808/// The viewport whose claim cell is `host`, when `host` belongs to one — the
809/// innermost frame, if it pushed that very cell. `None` means the enclosing
810/// surface is a `ScrollView` or a `ListView`.
811fn enclosing_viewport(host: &Rc<Cell<InnerScrollState>>) -> Option<Rc<AxisFrame>> {
812    VIEWPORT_FRAMES.with(|frames| {
813        frames
814            .borrow()
815            .last()
816            .filter(|frame| Rc::ptr_eq(&frame.claim, host))
817            .cloned()
818    })
819}
820
821/// The viewport owner side of the core: one effective `offset`, exactly
822/// `ScrollWidget`'s storage shape, with the extent the layout loop measured.
823impl ScrollSurface for ViewportWidget {
824    fn core(&self) -> &ScrollSurfaceCore {
825        &self.core
826    }
827
828    fn core_mut(&mut self) -> &mut ScrollSurfaceCore {
829        &mut self.core
830    }
831
832    fn max_scroll_extent(&self) -> f64 {
833        self.max_extent
834    }
835
836    fn viewport_extent(&self) -> f64 {
837        self.viewport_main
838    }
839
840    fn base_offset(&self) -> f64 {
841        self.offset
842    }
843
844    fn set_base_offset(&mut self, value: f64) {
845        self.set_offset(value);
846    }
847
848    fn pixels(&self) -> f64 {
849        self.offset
850    }
851
852    fn displacement(&self) -> f64 {
853        self.offset - self.offset.clamp(0.0, self.max_extent)
854    }
855
856    fn set_allowed_position(&mut self, allowed: f64) {
857        self.offset = allowed;
858    }
859
860    fn decay_displacement(&mut self, retained: f64) {
861        let target = self.offset.clamp(0.0, self.max_extent);
862        let remaining = target - self.offset;
863        self.offset = target - remaining * retained;
864    }
865
866    fn settle_to_edge(&mut self) {
867        self.set_offset(self.offset);
868    }
869
870    /// Nothing to do here: placement is a layout product (the module docs'
871    /// *Placement is a layout product*). A position moved by input dirties
872    /// this pod through the handled event; one moved at paint requests the
873    /// layout from [`Widget::paint`].
874    fn sync_origins(&mut self) {}
875
876    fn scroll_info(&self) -> ScrollInfo {
877        ScrollInfo {
878            offset: self.offset.clamp(0.0, self.max_extent),
879            max_offset: self.max_extent,
880            overscroll: self.displacement(),
881        }
882    }
883
884    fn forward_pointer(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
885        if !std::mem::take(&mut self.arming_down) {
886            return self.route_to_slivers(ctx, event);
887        }
888        // The primary `Down`'s forward: the core has just pushed this
889        // viewport's claim cell, so it is the ambient one.
890        let Some(claim) = ambient_scroll_claim() else {
891            return self.route_to_slivers(ctx, event);
892        };
893        let frame = Rc::new(AxisFrame {
894            claim,
895            axis: self.axis,
896            inner_axis: Cell::new(None),
897        });
898        let result = with_axis_frame(&frame, || self.route_to_slivers(ctx, event));
899        self.down_inner_axis = frame.inner_axis.get();
900        result
901    }
902
903    fn take_over_from_content(&mut self, ctx: &mut EventCtx, position: Point) {
904        let cancel = InputEvent::Pointer(PointerEvent {
905            phase: PointerPhase::Cancel,
906            position,
907            button: PointerButton::Primary,
908        });
909        // A takeover, not the gesture's end: the captured child gets its
910        // `Cancel` and is then released through the context, which also ends
911        // a contact opt-in held inside it.
912        if let Some(pod) = self.children.iter_mut().find(|pod| pod.is_active()) {
913            pod.event_child(ctx, &cancel);
914            ctx.release_captured_child(pod);
915        } else {
916            crate::authoring::route_event(&mut self.children, ctx, &cancel);
917        }
918    }
919}
920
921impl<State: 'static> View<State> for CustomScrollView<State> {
922    type Element = ViewportWidget;
923
924    fn build(&self, ctx: &mut BuildCtx<'_>) -> ViewportWidget {
925        let children = self
926            .slivers
927            .iter()
928            .map(|view| crate::authoring::build_child(view, ctx))
929            .collect();
930        let mut widget = ViewportWidget::new(children, self.axis, self.cache_extent);
931        widget.core.on_scroll = self
932            .on_scroll
933            .as_ref()
934            .map(crate::authoring::erase_callback_arg);
935        widget.core.on_refresh_release = self
936            .on_refresh_release
937            .as_ref()
938            .map(crate::authoring::erase_callback);
939        widget.core.install(self.physics.as_ref(), self.effect);
940        widget.core.attach_controller(self.controller.as_ref());
941        widget
942    }
943
944    fn rebuild(
945        &self,
946        prev: &Self,
947        element: &mut ViewportWidget,
948        ctx: &mut BuildCtx<'_>,
949    ) -> ChangeFlags {
950        // Closures are not comparable — always reinstall the erased adapters.
951        element.core.on_scroll = self
952            .on_scroll
953            .as_ref()
954            .map(crate::authoring::erase_callback_arg);
955        element.core.on_refresh_release = self
956            .on_refresh_release
957            .as_ref()
958            .map(crate::authoring::erase_callback);
959        element.core.install(self.physics.as_ref(), self.effect);
960        let mut flags = crate::authoring::rebuild_children(
961            &prev.slivers,
962            &self.slivers,
963            &mut element.children,
964            ctx,
965            |view| view,
966            |_| None,
967        );
968        element
969            .slots
970            .resize(element.children.len(), Slot::default());
971        let cache_extent = finite_extent(self.cache_extent);
972        if element.axis != self.axis || element.cache_extent != cache_extent {
973            element.axis = self.axis;
974            element.core.set_axis(self.axis);
975            element.cache_extent = cache_extent;
976            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
977        }
978        // A new binding publishes, and a queued command applies, at the next
979        // layout or paint — make sure a frame-gated shell runs one.
980        if element.core.attach_controller(self.controller.as_ref()) {
981            flags |= ChangeFlags::PAINT;
982        }
983        // A queued command moves the children when it applies, and the layout
984        // drain is where it applies first: report LAYOUT so the layout skip
985        // runs that drain (`ScrollView`'s rebuild does the same).
986        if element.core.has_pending_commands() {
987            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
988        }
989        flags
990    }
991
992    fn teardown(&self, element: &mut ViewportWidget, ctx: &mut BuildCtx<'_>) {
993        for (view, pod) in self.slivers.iter().zip(element.children.iter_mut()) {
994            crate::authoring::teardown_child(view, pod, ctx);
995        }
996    }
997}
998
999impl Widget for ViewportWidget {
1000    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1001        let max = bc.max();
1002        self.cross_extent = finite_extent(self.axis.cross_extent(max));
1003        self.main_extent = non_negative(self.axis.main_extent(max));
1004        self.observe_direction();
1005        self.layout_slivers(ctx);
1006        // The content's extent may have moved under a resting position: clamp
1007        // it, as `ScrollView` does — never while a drag, settle or simulation
1008        // legitimately holds it past an edge.
1009        let core = &self.core;
1010        if !core.scrolling && !core.settling && core.ballistic.is_none() {
1011            self.set_offset(self.offset);
1012            if self.layout_pixels() != self.laid_out_pixels {
1013                self.layout_slivers(ctx);
1014            }
1015        }
1016        // A controller command clamps against the extent just measured.
1017        self.core.laid_out = true;
1018        let reduce_motion = Theme::from_layout_ctx(ctx)
1019            .map(|t| t.motion.reduce_motion)
1020            .unwrap_or(false);
1021        self.drain_controller(reduce_motion);
1022        if self.layout_pixels() != self.laid_out_pixels {
1023            self.layout_slivers(ctx);
1024        }
1025        self.publish_to_controller();
1026        self.viewport = self.axis.size(self.viewport_main, self.cross_extent);
1027        bc.constrain(self.viewport)
1028    }
1029
1030    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1031        // Record the frame clock, apply a command recorded since layout, pump
1032        // post-release motion and publish — see `ScrollSurface::paint_motion`.
1033        let reduce_motion = Theme::from_paint_ctx(ctx)
1034            .map(|t| t.motion.reduce_motion)
1035            .unwrap_or(false);
1036        self.paint_motion(ctx, reduce_motion);
1037        // Whatever moved the position here is shown by the next layout.
1038        if self.layout_pixels() != self.laid_out_pixels {
1039            ctx.request_layout();
1040        }
1041        let origin = ctx.origin();
1042        scene.push_clip(origin, ctx.size());
1043        ctx.constrain_visible_rect(Rect::from_origin_size(origin, ctx.size()));
1044        // Paint-only, inside the viewport clip, exactly as `ScrollView`'s.
1045        let stretch = self.stretch_transform(origin, ctx.size());
1046        if let Some(transform) = stretch {
1047            scene.push_transform(transform);
1048        }
1049        let axis = self.axis;
1050        let cross = self.cross_extent;
1051        for (pod, slot) in self.children.iter_mut().zip(&self.slots).rev() {
1052            if !slot.geometry.visible {
1053                continue;
1054            }
1055            // The box adapter clips a cut child to its paint extent; a sliver
1056            // clips itself.
1057            let clip = slot.is_box && slot.geometry.has_visual_overflow;
1058            if clip {
1059                let start = origin + axis_point(axis, slot.paint_offset, 0.0).to_vec2();
1060                scene.push_clip(start, axis.size(slot.geometry.paint_extent, cross));
1061            }
1062            pod.paint_child(ctx, scene);
1063            if clip {
1064                scene.pop_clip();
1065            }
1066        }
1067        if stretch.is_some() {
1068            scene.pop_transform();
1069        }
1070        scene.pop_clip();
1071    }
1072
1073    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1074        let t = self.core.event_time_ms();
1075        self.event_at(ctx, event, t)
1076    }
1077
1078    fn semantics(&self, ctx: &mut SemanticsCtx) {
1079        let offset = self.offset;
1080        let max_offset = self.max_extent;
1081        let axis = self.axis;
1082        ctx.push_container(
1083            Role::ScrollView,
1084            move |node| match axis {
1085                ScrollAxis::Vertical => {
1086                    node.set_scroll_y(offset);
1087                    node.set_scroll_y_min(0.0);
1088                    node.set_scroll_y_max(max_offset);
1089                }
1090                ScrollAxis::Horizontal => {
1091                    node.set_scroll_x(offset);
1092                    node.set_scroll_x_min(0.0);
1093                    node.set_scroll_x_max(max_offset);
1094                }
1095            },
1096            |ctx| {
1097                // Visible and cached children, so assistive scrolling can
1098                // reach what is just past the edge.
1099                for (pod, slot) in self.children.iter().zip(&self.slots) {
1100                    if slot.geometry.visible || slot.geometry.cache_extent > 0.0 {
1101                        pod.semantics_child(ctx);
1102                    }
1103                }
1104            },
1105        );
1106    }
1107
1108    crate::authoring::visit_children!(children);
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use frust_core::{FrameTime, RenderRoot};
1115    use std::any::Any;
1116
1117    use crate::test_support::{RecordingScene, leaf};
1118
1119    const WINDOW: Size = Size::new(400.0, 500.0);
1120
1121    fn widget(root: &RenderRoot<(), CustomScrollView<()>>) -> &ViewportWidget {
1122        let id = root.root_id().expect("root built");
1123        (root.tree().pod(id).expect("root pod").widget() as &dyn Any)
1124            .downcast_ref::<ViewportWidget>()
1125            .expect("root is a ViewportWidget")
1126    }
1127
1128    fn mount(
1129        logic: &mut impl FnMut(&mut ()) -> CustomScrollView<()>,
1130    ) -> RenderRoot<(), CustomScrollView<()>> {
1131        let mut root = RenderRoot::new();
1132        root.rebuild(logic, &mut ());
1133        root.layout(WINDOW);
1134        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1135        root
1136    }
1137
1138    #[test]
1139    fn sanitising_drops_nan_and_negative_extents() {
1140        let sc = sanitized(SliverConstraints {
1141            axis: ScrollAxis::Vertical,
1142            scroll_offset: f64::NAN,
1143            preceding_scroll_extent: f64::NAN,
1144            overlap: f64::NAN,
1145            remaining_paint_extent: -3.0,
1146            cross_axis_extent: f64::NAN,
1147            viewport_main_axis_extent: -1.0,
1148            cache_origin: 5.0,
1149            remaining_cache_extent: f64::NAN,
1150            user_scroll_direction: ScrollDirection::Idle,
1151        });
1152        sc.debug_assert_valid();
1153        assert_eq!(sc.remaining_paint_extent, 0.0);
1154        assert_eq!(sc.cache_origin, 0.0);
1155    }
1156
1157    #[test]
1158    fn a_zero_window_lays_out_without_tripping_the_constraint_checks() {
1159        let mut logic = |_: &mut ()| custom_scroll_view((leaf(100.0, 100.0), leaf(100.0, 50.0)));
1160        let mut root = RenderRoot::new();
1161        root.rebuild(&mut logic, &mut ());
1162        root.layout(Size::ZERO);
1163        let w = widget(&root);
1164        assert_eq!(w.max_offset(), 150.0);
1165        assert!(!w.sliver_geometry(0).expect("child 0").visible);
1166    }
1167
1168    #[test]
1169    fn an_unbounded_main_axis_shrink_wraps() {
1170        let mut logic = |_: &mut ()| custom_scroll_view((leaf(100.0, 100.0), leaf(100.0, 50.0)));
1171        let mut root = RenderRoot::new();
1172        root.rebuild(&mut logic, &mut ());
1173        root.layout(Size::new(400.0, f64::INFINITY));
1174        let w = widget(&root);
1175        assert_eq!(w.viewport.height, 150.0);
1176        assert_eq!(w.max_offset(), 0.0);
1177        assert_eq!(w.sliver_geometry(1).expect("child 1").paint_extent, 50.0);
1178    }
1179
1180    #[test]
1181    fn plain_box_children_are_adapted_and_placed_in_sequence() {
1182        let mut logic = |_: &mut ()| {
1183            custom_scroll_view((leaf(400.0, 300.0), leaf(400.0, 300.0), leaf(400.0, 300.0)))
1184                .cache_extent(0.0)
1185        };
1186        let root = mount(&mut logic);
1187        let w = widget(&root);
1188        assert_eq!(w.max_offset(), 400.0);
1189        let g = |i| w.sliver_geometry(i).expect("child");
1190        assert_eq!(g(0).paint_extent, 300.0);
1191        assert_eq!(g(1).paint_extent, 200.0);
1192        assert!(g(1).has_visual_overflow);
1193        assert!(!g(2).visible);
1194        assert_eq!(w.slots[1].paint_offset, 300.0);
1195        assert!(w.slots.iter().all(|slot| slot.is_box));
1196    }
1197}