Skip to main content

frust_material/carousel/
mod.rs

1//! The M3 Expressive **carousel**: a snapping row (or column) of items whose
2//! extents morph as they cross the viewport, in the three reference layouts —
3//! `hero`, `contained`, and `uncontained`.
4//!
5//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
6//! `lib/components/carousel/` — `m3e_carousel.dart`, `components/
7//! m3e_carousel_view.dart`, `m3e_carousel_wrapper.dart`,
8//! `m3e_sliver_weighted_carousel_layout.dart`,
9//! `m3e_sliver_fixed_extent_carousel.dart`, `m3e_carousel_scroll_physics.dart`,
10//! `m3e_carousel_position.dart`, `utils/m3e_carousel_scroll_helper.dart`,
11//! `styles/m3e_carousel_theme.dart`, `enums/m3e_carousel_type.dart` (retrieved
12//! 2026-08-19). Those files are themselves vendored from `m3_carousel` (MIT,
13//! © 2024 Paa) and derived from Flutter's `CarouselView` — **BSD-3-Clause,
14//! © 2014 The Flutter Authors**; both notices are in `plugins/material/NOTICE`.
15//! [`layout`] and [`physics`] carry the same header over the halves of the port
16//! they own.
17//!
18//! # The three layouts, and the weights behind them
19//!
20//! Every layout but `uncontained` is the same weighted solver with a different
21//! weight list — `M3ECarousel._applyLayoutWeights`' own table:
22//!
23//! | Layout | Weights | Shape |
24//! |---|---|---|
25//! | [`CarouselLayout::Hero`] + [`HeroAlignment::Start`] | `[8, 2]` | one large item, one small peek after it |
26//! | [`CarouselLayout::Hero`] + [`HeroAlignment::Center`] | `[2, 6, 2]` | a peek, the focal item, a peek |
27//! | [`CarouselLayout::Hero`] + [`HeroAlignment::End`] | `[2, 8]` | one small peek, then the large item |
28//! | [`CarouselLayout::Contained`] | `[5, 4, 1]` | large / medium / small, all inside the bounds |
29//! | [`CarouselLayout::Contained`] + [`CarouselView::extended`] | `[4, 3, 2, 1]` | four descending items |
30//! | [`CarouselLayout::Uncontained`] | — | uniform [`CarouselView::item_extent`] items, scrolling to the edge |
31//!
32//! A weight list is the *visible window*: each weight's share of the viewport is
33//! one slot, and an item **morphs between adjacent slots** as the leading item
34//! scrolls off — the signature M3E behavior, pinned by [`layout`]'s tests at
35//! several viewport widths and scroll offsets.
36//!
37//! # Items are laid out once, at the largest slot, and clipped
38//!
39//! A slot's extent changes continuously during a scroll, but re-laying every
40//! child out per frame would make a drag a relayout storm. Upstream solves this
41//! in `M3ECarouselWrapper` — "item content is laid out at the largest slot size
42//! and clipped to the current item bounds; scrolling only moves that clip
43//! window" — and this port takes the same route: every child is laid out at
44//! [`WeightedLayout::max_child_extent`](layout::WeightedLayout::max_child_extent)
45//! (or the fixed item extent) once per `layout`, and `paint` clips it to its
46//! current slot with a **rounded** clip, centring the content inside. The
47//! rounded clip is load-bearing, not decoration: baseline `Clip` does not
48//! visually clip, so `PaintScene::push_clip_rounded` is what actually shapes an
49//! item.
50//!
51//! # Snapping, and the velocity that picks the target
52//!
53//! A press captures the pointer and forwards to the item under it; crossing
54//! [`frust::input::TOUCH_SLOP`] takes the gesture over (cancelling the item's
55//! own press) and turns it into a scroll. The release picks a target through
56//! [`physics::snap_offset`] — the nearest item boundary, or one further along
57//! when the release velocity clears [`physics::SNAP_VELOCITY_TOLERANCE`] — and
58//! a [`SETTLE_DURATION`](physics::SETTLE_DURATION) ramp takes the content
59//! there, advanced from the shell frame clock in `paint` like every other
60//! motion in this crate. `Theme.motion.reduce_motion` jumps to the target
61//! instead and asks for no further frames.
62//!
63//! Velocity comes from a [`frust::input::VelocityTracker`] fed with the last
64//! *painted* frame time, since the event pass carries no clock of its own —
65//! the same seam baseline `ScrollView` uses, and the reason the event body is
66//! parameterised on an explicit timestamp ([`CarouselWidget::event_at`]).
67//!
68//! A mouse wheel over the carousel moves it and settles the same way. Upstream
69//! has no wheel path at all (its carousel is swipe-driven under
70//! `NeverScrollableScrollPhysics`); this is the port's desktop completion of
71//! it.
72//!
73//! # Controlled and uncontrolled
74//!
75//! Uncontrolled by default: a drag or a settle moves the carousel's own index
76//! and reports it through [`CarouselView::on_change`].
77//! [`CarouselView::selected`] flips it to this catalog's controlled contract —
78//! the carousel then reports the *requested* index and moves nothing until the
79//! next `rebuild` feeds a confirmed one back down. Both that and
80//! [`CarouselView::initial_item`] name the **focal** item (the one in the first
81//! max-weight slot), which is what upstream's `M3ECarouselController.initialItem`
82//! means for a weighted carousel.
83//!
84//! # Deliberately not ported
85//!
86//! * **Free scroll** (`M3ECarousel.freeScroll`) — upstream's own default is
87//!   off, and with it on the widget swaps in a `ScrollSpringSimulation` plus
88//!   Flutter's ballistic scroll machinery. Snapping is the only mode here.
89//! * **Infinite looping** (`infinite`) — needs the sliver's cycle-length
90//!   pixel correction; a finite item list is the whole surface here.
91//! * **Lazy item building** (`M3ECarouselView.builder`) — this view takes a
92//!   finite list of views like every other container in this catalog.
93//! * **The tap pulse** (`M3ECarouselWrapper`'s `fixedPulseDelta`) — it animates
94//!   a per-item clip window against content laid out with a pulse budget on
95//!   top of the max slot. A pressed item takes this crate's shared
96//!   [`state_layer`](crate::state_layer) overlay instead, which is the M3
97//!   interaction affordance the rest of the catalog uses.
98
99pub(crate) mod layout;
100pub(crate) mod physics;
101
102use std::rc::Rc;
103
104use frust::authoring::{
105    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
106    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent,
107    PointerPhase, Role, ScrollDelta, SemanticsCtx, TypedArgCallback, View, ViewSeq, Widget,
108};
109use frust::input::{TOUCH_SLOP, VelocityTracker, WHEEL_LINE_PX};
110use frust::{AnimationController, FrameTime, Theme};
111use kurbo::{Point, Rect, Size};
112use peniko::Color;
113
114use self::layout::{
115    CONTAINED_EXTENDED_WEIGHTS, CONTAINED_WEIGHTS, FixedLayout, HERO_CENTER_WEIGHTS,
116    HERO_END_WEIGHTS, HERO_START_WEIGHTS, Slot, WeightedLayout,
117};
118use crate::press::presses;
119use crate::state_layer::StateLayer;
120
121/// Space around each item, in logical px (`M3ECarouselTheme.itemPadding`,
122/// `EdgeInsets.all(4)`) — inset from the item's own slot, so adjacent items
123/// show an 8px gutter between them.
124pub const CAROUSEL_ITEM_PADDING: f64 = 4.0;
125
126/// Unthemed-fallback item corner radius, in logical px
127/// (`M3ECarouselTheme.defaultBorderRadiusValue`). A theme resolves this from
128/// `shape.extra_large`, which carries the same 28dp value.
129pub const CAROUSEL_ITEM_RADIUS: f64 = 28.0;
130
131/// Default main-axis extent of an [`CarouselLayout::Uncontained`] item, in
132/// logical px (`M3ECarouselTheme.defaultUncontainedItemExtent`).
133pub const UNCONTAINED_ITEM_EXTENT: f64 = 270.0;
134
135/// Default shrink floor for an [`CarouselLayout::Uncontained`] carousel, in
136/// logical px (`M3ECarouselTheme.defaultUncontainedShrinkExtent`) — how small
137/// an item may get at the viewport edge before it slides off instead.
138pub const UNCONTAINED_SHRINK_EXTENT: f64 = 150.0;
139
140/// Offset differences below which the content counts as settled, in logical px.
141const OFFSET_EPSILON: f64 = 1e-3;
142
143/// Unthemed-fallback state-layer content color for a pressed item (a theme
144/// resolves this from `colors.on_surface`).
145const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
146
147/// The carousel's structural layout (`M3ECarouselType`).
148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
149pub enum CarouselLayout {
150    /// One large, prominent item with small peeking neighbours — the
151    /// reference's own default. Positioned by [`HeroAlignment`].
152    #[default]
153    Hero,
154    /// Large / medium / small items, all kept inside the carousel's bounds.
155    /// [`CarouselView::extended`] adds a fourth, smaller step.
156    Contained,
157    /// Uniform items that scroll to the edge of the container, sized by
158    /// [`CarouselView::item_extent`].
159    Uncontained,
160}
161
162/// Where a [`CarouselLayout::Hero`] carousel puts its focal item
163/// (`M3ECarouselHeroAlignment`).
164///
165/// Named for the scroll axis rather than the screen: `Start` is the left edge
166/// of a horizontal carousel and the top of a vertical one (upstream's `left`),
167/// `End` the right/bottom (upstream's `right`).
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum HeroAlignment {
170    /// Focal item at the leading edge, one small peek after it (`[8, 2]`).
171    Start,
172    /// Focal item between two small peeks (`[2, 6, 2]`) — the reference default.
173    #[default]
174    Center,
175    /// One small peek, then the focal item at the trailing edge (`[2, 8]`).
176    End,
177}
178
179/// The axis a carousel scrolls along (`M3ECarousel.axis`).
180#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub enum CarouselAxis {
182    /// Items flow along the x axis.
183    #[default]
184    Horizontal,
185    /// Items flow along the y axis.
186    Vertical,
187}
188
189/// What a carousel reports when the item under the layout changes
190/// (`M3ECarouselChangeDetails`).
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub struct CarouselChange {
193    /// The first visible layout slot's item.
194    pub leading_index: usize,
195    /// The item in the largest (focal) slot — equal to
196    /// [`leading_index`](Self::leading_index) for an uncontained carousel,
197    /// which has no larger slot to be focal in.
198    pub focal_index: usize,
199    /// How many items the carousel holds.
200    pub item_count: usize,
201}
202
203impl CarouselChange {
204    /// Whether `index` is the current focal (largest) item.
205    pub fn is_focal(&self, index: usize) -> bool {
206        index == self.focal_index
207    }
208}
209
210/// A declarative M3E carousel. See the [module docs](self).
211pub struct CarouselView<State: 'static> {
212    items: Vec<AnyView<State>>,
213    layout: CarouselLayout,
214    hero_alignment: HeroAlignment,
215    extended: bool,
216    axis: CarouselAxis,
217    item_extent: f64,
218    shrink_extent: Option<f64>,
219    item_radius: Option<f64>,
220    width: Option<f64>,
221    height: Option<f64>,
222    consume_max_weight: bool,
223    initial_item: usize,
224    selected: Option<usize>,
225    on_change: Option<TypedArgCallback<State, CarouselChange>>,
226    on_tap: Option<TypedArgCallback<State, usize>>,
227}
228
229/// A hero carousel over `items` — the reference's own default layout, focal
230/// item centred. See the [module docs](self).
231pub fn carousel<State: 'static, M>(items: impl ViewSeq<State, M>) -> CarouselView<State> {
232    let mut erased = Vec::new();
233    items.extend_views(&mut erased);
234    CarouselView {
235        items: erased,
236        layout: CarouselLayout::default(),
237        hero_alignment: HeroAlignment::default(),
238        extended: false,
239        axis: CarouselAxis::default(),
240        item_extent: UNCONTAINED_ITEM_EXTENT,
241        shrink_extent: None,
242        item_radius: None,
243        width: None,
244        height: None,
245        consume_max_weight: false,
246        initial_item: 0,
247        selected: None,
248        on_change: None,
249        on_tap: None,
250    }
251}
252
253/// A [`CarouselLayout::Hero`] carousel over `items` — one large item plus
254/// peeks, placed by `alignment`.
255pub fn hero_carousel<State: 'static, M>(
256    items: impl ViewSeq<State, M>,
257    alignment: HeroAlignment,
258) -> CarouselView<State> {
259    carousel(items).hero_alignment(alignment)
260}
261
262/// A [`CarouselLayout::Contained`] carousel over `items` — large/medium/small
263/// items kept inside the bounds.
264pub fn contained_carousel<State: 'static, M>(items: impl ViewSeq<State, M>) -> CarouselView<State> {
265    carousel(items).layout(CarouselLayout::Contained)
266}
267
268/// A [`CarouselLayout::Uncontained`] carousel over `items` — uniform
269/// `item_extent`-wide items scrolling to the container's edge.
270pub fn uncontained_carousel<State: 'static, M>(
271    items: impl ViewSeq<State, M>,
272    item_extent: f64,
273) -> CarouselView<State> {
274    carousel(items)
275        .layout(CarouselLayout::Uncontained)
276        .item_extent(item_extent)
277}
278
279impl<State: 'static> CarouselView<State> {
280    /// Set the structural layout (`type`).
281    pub fn layout(mut self, layout: CarouselLayout) -> Self {
282        self.layout = layout;
283        self
284    }
285
286    /// Where a [`CarouselLayout::Hero`] carousel's focal item sits. Ignored by
287    /// the other two layouts.
288    pub fn hero_alignment(mut self, alignment: HeroAlignment) -> Self {
289        self.hero_alignment = alignment;
290        self
291    }
292
293    /// Show four descending items instead of three (`isExtended`). Applies to
294    /// [`CarouselLayout::Contained`] only.
295    pub fn extended(mut self, extended: bool) -> Self {
296        self.extended = extended;
297        self
298    }
299
300    /// Set the scroll axis (default [`CarouselAxis::Horizontal`]).
301    pub fn axis(mut self, axis: CarouselAxis) -> Self {
302        self.axis = axis;
303        self
304    }
305
306    /// Main-axis extent of an [`CarouselLayout::Uncontained`] item, in logical
307    /// px (default [`UNCONTAINED_ITEM_EXTENT`]). Capped at the viewport by the
308    /// solver; ignored by the weighted layouts, which derive every extent from
309    /// the viewport instead.
310    pub fn item_extent(mut self, extent: f64) -> Self {
311        self.item_extent = extent;
312        self
313    }
314
315    /// How small an item may shrink at the viewport edge before it slides off
316    /// instead, in logical px (`shrinkExtent`). Defaults to `0.0` for the
317    /// weighted layouts and [`UNCONTAINED_SHRINK_EXTENT`] for
318    /// [`CarouselLayout::Uncontained`], matching the reference's own two
319    /// defaults. Never exceeds the smallest slot.
320    pub fn shrink_extent(mut self, extent: f64) -> Self {
321        self.shrink_extent = Some(extent);
322        self
323    }
324
325    /// Item corner radius, in logical px (`childElementBorderRadius`).
326    /// Unset resolves `shape.extra_large` from the theme, then
327    /// [`CAROUSEL_ITEM_RADIUS`].
328    pub fn item_radius(mut self, radius: f64) -> Self {
329        self.item_radius = Some(radius);
330        self
331    }
332
333    /// Pin the carousel's own width, in logical px (`M3ECarousel.width`).
334    /// Unset takes the incoming constraint.
335    pub fn width(mut self, width: f64) -> Self {
336        self.width = Some(width);
337        self
338    }
339
340    /// Pin the carousel's own height, in logical px (`M3ECarousel.height`).
341    /// Unset takes the incoming constraint.
342    pub fn height(mut self, height: f64) -> Self {
343        self.height = Some(height);
344        self
345    }
346
347    /// Let the leading items expand into the largest slot
348    /// (`consumeMaxWeight`), leaving a phantom gap before item 0 while they do.
349    ///
350    /// Off by default, which is the choice upstream's own top-level
351    /// `M3ECarousel` makes: with it on, a theme rebuild can strand the extra
352    /// leading extent offstage.
353    pub fn consume_max_weight(mut self, consume: bool) -> Self {
354        self.consume_max_weight = consume;
355        self
356    }
357
358    /// The **focal** item to start on (`M3ECarouselController.initialItem`).
359    /// Only read on the first build; use [`selected`](Self::selected) to drive
360    /// the carousel afterwards.
361    pub fn initial_item(mut self, index: usize) -> Self {
362        self.initial_item = index;
363        self
364    }
365
366    /// Drive the carousel from the app: it shows exactly this **focal** item
367    /// and reports every requested index through
368    /// [`on_change`](Self::on_change) instead of moving itself (see the
369    /// [module docs](self)).
370    pub fn selected(mut self, focal_index: usize) -> Self {
371        self.selected = Some(focal_index);
372        self
373    }
374
375    /// Report the leading/focal item whenever either changes (`onChange`).
376    pub fn on_change<F: Fn(&mut State, CarouselChange) + 'static>(mut self, on_change: F) -> Self {
377        self.on_change = Some(Rc::new(on_change));
378        self
379    }
380
381    /// Report a tap on an item, by index (`onTap`).
382    pub fn on_tap<F: Fn(&mut State, usize) + 'static>(mut self, on_tap: F) -> Self {
383        self.on_tap = Some(Rc::new(on_tap));
384        self
385    }
386
387    /// The item-sizing rule this view's layout knobs resolve to.
388    fn sizing(&self) -> Sizing {
389        match self.layout {
390            CarouselLayout::Hero => Sizing::Weighted(match self.hero_alignment {
391                HeroAlignment::Start => HERO_START_WEIGHTS,
392                HeroAlignment::Center => HERO_CENTER_WEIGHTS,
393                HeroAlignment::End => HERO_END_WEIGHTS,
394            }),
395            CarouselLayout::Contained => Sizing::Weighted(if self.extended {
396                CONTAINED_EXTENDED_WEIGHTS
397            } else {
398                CONTAINED_WEIGHTS
399            }),
400            CarouselLayout::Uncontained => Sizing::Fixed {
401                item_extent: self.item_extent,
402            },
403        }
404    }
405
406    /// The shrink floor, defaulted per layout (see
407    /// [`shrink_extent`](Self::shrink_extent)).
408    fn resolved_shrink_extent(&self) -> f64 {
409        self.shrink_extent.unwrap_or(match self.layout {
410            CarouselLayout::Uncontained => UNCONTAINED_SHRINK_EXTENT,
411            _ => 0.0,
412        })
413    }
414}
415
416impl<State: 'static> View<State> for CarouselView<State> {
417    type Element = CarouselWidget;
418
419    fn build(&self, ctx: &mut BuildCtx<'_>) -> CarouselWidget {
420        let sizing = self.sizing();
421        let mut widget = CarouselWidget {
422            pods: self
423                .items
424                .iter()
425                .map(|item| frust::authoring::build_child(item, ctx))
426                .collect(),
427            sizing,
428            axis: self.axis,
429            shrink_extent: self.resolved_shrink_extent(),
430            item_radius: self.item_radius,
431            width: self.width,
432            height: self.height,
433            consume_max_weight: self.consume_max_weight,
434            controlled: self.selected.is_some(),
435            index: 0,
436            offset: 0.0,
437            settle: physics::settle_controller(),
438            settle_from: 0.0,
439            settle_to: 0.0,
440            main: 0.0,
441            cross: 0.0,
442            slots: Vec::new(),
443            drag: None,
444            pressed: None,
445            state: StateLayer::new(),
446            tracker: VelocityTracker::new(),
447            last_frame_time: FrameTime::ZERO,
448            reported: (0, 0),
449            on_change: self
450                .on_change
451                .as_ref()
452                .map(frust::authoring::erase_callback_arg),
453            on_tap: self
454                .on_tap
455                .as_ref()
456                .map(frust::authoring::erase_callback_arg),
457        };
458        widget.index = widget.leading_for_focal(self.selected.unwrap_or(self.initial_item));
459        widget.reported = widget.indices_for_leading(widget.index);
460        widget
461    }
462
463    fn rebuild(
464        &self,
465        prev: &Self,
466        element: &mut CarouselWidget,
467        ctx: &mut BuildCtx<'_>,
468    ) -> ChangeFlags {
469        let mut flags = frust::authoring::rebuild_children(
470            &prev.items,
471            &self.items,
472            &mut element.pods,
473            ctx,
474            |view: &AnyView<State>| view,
475            |_| None,
476        );
477        let sizing = self.sizing();
478        let shrink_extent = self.resolved_shrink_extent();
479        if element.sizing != sizing
480            || element.axis != self.axis
481            || element.shrink_extent != shrink_extent
482            || element.width != self.width
483            || element.height != self.height
484            || element.consume_max_weight != self.consume_max_weight
485        {
486            element.sizing = sizing;
487            element.axis = self.axis;
488            element.shrink_extent = shrink_extent;
489            element.width = self.width;
490            element.height = self.height;
491            element.consume_max_weight = self.consume_max_weight;
492            // A different weight list re-slots every item: the carousel returns
493            // to its own index rather than keeping a now-meaningless offset,
494            // exactly as `M3ECarousel.didUpdateWidget` jumps back to 0.
495            element.settle.stop();
496            element.offset = element.offset_for(element.index);
497            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
498        }
499        if element.item_radius != self.item_radius {
500            element.item_radius = self.item_radius;
501            flags |= ChangeFlags::PAINT;
502        }
503        element.controlled = self.selected.is_some();
504        let last = element.pods.len().saturating_sub(1);
505        if let Some(selected) = self.selected {
506            // Controlled: the app is the source of truth, so adopt the
507            // confirmed focal item and settle onto it.
508            let index = element.leading_for_focal(selected);
509            if element.index != index {
510                element.index = index;
511                element.reported = element.indices_for_leading(index);
512                element.start_settle();
513                flags |= ChangeFlags::PAINT;
514            }
515        } else if element.index > last {
516            // A shrunk item list cannot leave the index past the end.
517            element.index = last;
518            element.reported = element.indices_for_leading(last);
519            element.start_settle();
520            flags |= ChangeFlags::PAINT;
521        }
522        element.on_change = self
523            .on_change
524            .as_ref()
525            .map(frust::authoring::erase_callback_arg);
526        element.on_tap = self
527            .on_tap
528            .as_ref()
529            .map(frust::authoring::erase_callback_arg);
530        flags
531    }
532
533    fn teardown(&self, element: &mut CarouselWidget, ctx: &mut BuildCtx<'_>) {
534        for (view, pod) in self.items.iter().zip(element.pods.iter_mut()) {
535            frust::authoring::teardown_child(view, pod, ctx);
536        }
537    }
538}
539
540/// How a carousel sizes its items: by weight share of the viewport, or at one
541/// fixed extent.
542#[derive(Clone, Copy, Debug, PartialEq)]
543enum Sizing {
544    /// The weighted layouts (hero, contained) and their weight list.
545    Weighted(&'static [u16]),
546    /// The uncontained layout and its per-item main-axis extent.
547    Fixed { item_extent: f64 },
548}
549
550/// One of the two solvers in [`layout`], resolved against a live viewport and
551/// scroll offset.
552enum Solver {
553    Weighted(WeightedLayout<'static>),
554    Fixed(FixedLayout),
555}
556
557impl Solver {
558    /// Every visible item's viewport-space geometry, in index order.
559    fn slots(&self, item_count: usize) -> Vec<Slot> {
560        match self {
561            Solver::Weighted(l) => l.slots(item_count),
562            Solver::Fixed(l) => l.slots(item_count),
563        }
564    }
565
566    /// The scroll distance one item step moves the content.
567    fn stride(&self) -> f64 {
568        match self {
569            Solver::Weighted(l) => l.first_child_extent(),
570            Solver::Fixed(l) => l.stride(),
571        }
572    }
573
574    /// The largest extent an item can occupy — the size every child is laid
575    /// out at (see the [module docs](self)).
576    fn slot_extent(&self) -> f64 {
577        match self {
578            Solver::Weighted(l) => l.max_child_extent(),
579            Solver::Fixed(l) => l.stride(),
580        }
581    }
582
583    /// The furthest the content may scroll.
584    fn max_scroll_offset(&self, item_count: usize) -> f64 {
585        match self {
586            Solver::Weighted(l) => l.max_scroll_offset(item_count),
587            Solver::Fixed(l) => l.max_scroll_offset(item_count),
588        }
589    }
590}
591
592/// A live drag on the carousel.
593struct Drag {
594    /// Main-axis coordinate of the `Down`.
595    start: f64,
596    /// The scroll offset the drag started from.
597    from: f64,
598    /// The item the `Down` landed on, if any.
599    item: Option<usize>,
600    /// Whether the gesture has crossed the slop and become a scroll — until it
601    /// does, every move still belongs to the item under the pointer.
602    scrolling: bool,
603}
604
605/// The retained widget for a [`CarouselView`]. See the [module docs](self).
606pub struct CarouselWidget {
607    pods: Vec<ChildPod>,
608    sizing: Sizing,
609    axis: CarouselAxis,
610    shrink_extent: f64,
611    item_radius: Option<f64>,
612    width: Option<f64>,
613    height: Option<f64>,
614    consume_max_weight: bool,
615    /// Whether the app drives the index (see the [module docs](self)).
616    controlled: bool,
617    /// The **slot item** the carousel rests on (or is settling onto): the one
618    /// whose leading edge a resting offset sits at, so `offset == index *
619    /// stride`. Not always the item *reported* as leading — see
620    /// [`CarouselWidget::indices_for_leading`].
621    index: usize,
622    /// The current scroll offset, in logical px.
623    offset: f64,
624    settle: AnimationController,
625    /// The offset the running settle started from, and the one it ends at.
626    settle_from: f64,
627    settle_to: f64,
628    /// Main/cross extents resolved at layout.
629    main: f64,
630    cross: f64,
631    /// The visible items' geometry, recomputed whenever the offset moves.
632    slots: Vec<Slot>,
633    drag: Option<Drag>,
634    /// The item a `Down` armed, cleared on `Up`/`Cancel` or a slop takeover.
635    pressed: Option<usize>,
636    state: StateLayer,
637    tracker: VelocityTracker,
638    /// The most recent frame time seen at paint, reused as the event pass's
639    /// timestamp for velocity tracking (the event pass carries no clock).
640    last_frame_time: FrameTime,
641    /// The last `(leading, focal)` pair reported through `on_change`.
642    reported: (usize, usize),
643    on_change: Option<ErasedArgCallback<CarouselChange>>,
644    on_tap: Option<ErasedArgCallback<usize>>,
645}
646
647impl CarouselWidget {
648    /// The item in the first visible slot at rest — the
649    /// [`CarouselChange::leading_index`] the carousel would report.
650    pub fn leading_index(&self) -> usize {
651        self.indices_for_leading(self.index).0
652    }
653
654    /// The item in the largest (focal) slot at rest.
655    pub fn focal_index(&self) -> usize {
656        self.indices_for_leading(self.index).1
657    }
658
659    /// The current scroll offset, in logical px.
660    pub fn offset(&self) -> f64 {
661        self.offset
662    }
663
664    /// The solver for this carousel at `offset`.
665    fn solver(&self, offset: f64) -> Solver {
666        match self.sizing {
667            Sizing::Weighted(weights) => Solver::Weighted(WeightedLayout::new(
668                weights,
669                self.main,
670                self.shrink_extent,
671                offset,
672                self.consume_max_weight,
673            )),
674            Sizing::Fixed { item_extent } => Solver::Fixed(FixedLayout::new(
675                item_extent,
676                self.shrink_extent,
677                self.main,
678                offset,
679            )),
680        }
681    }
682
683    /// The index of the first maximum weight in the active weight list — the
684    /// distance from the leading item to the focal one. `0` for the
685    /// uncontained layout, which has no focal slot.
686    fn focal_slot(&self) -> usize {
687        match self.sizing {
688            Sizing::Weighted(weights) => {
689                let max = weights.iter().copied().max().unwrap_or(0);
690                weights.iter().position(|w| *w == max).unwrap_or(0)
691            }
692            Sizing::Fixed { .. } => 0,
693        }
694    }
695
696    /// The slot item that puts `focal` in the focal slot — the inverse of
697    /// [`Self::indices_for_leading`], and upstream's `_getInitialLeadingItem`.
698    fn leading_for_focal(&self, focal: usize) -> usize {
699        let last = self.pods.len().saturating_sub(1);
700        let raw = if self.consume_max_weight {
701            focal
702        } else {
703            focal.saturating_sub(self.focal_slot())
704        };
705        raw.min(last)
706    }
707
708    /// The `(leading, focal)` pair a **slot item** reads as — the item index
709    /// `offset / stride` names, which is not the reported leading item under
710    /// `consume_max_weight` (the leading slots then hold items *before* it).
711    ///
712    /// Upstream computes the focal item from the already-shifted leading index
713    /// (`_focalIndexForLeading(_reportedLeadingIndex(..))`), which double-counts
714    /// the shift for the first `focal_slot` items; the focal item is derived
715    /// from the unshifted slot item here instead, so item 0 reads as focal when
716    /// it is the one actually occupying the largest slot.
717    fn indices_for_leading(&self, raw: usize) -> (usize, usize) {
718        let last = self.pods.len().saturating_sub(1);
719        let raw = raw.min(last);
720        if self.consume_max_weight {
721            (raw.saturating_sub(self.focal_slot()), raw)
722        } else {
723            (raw, (raw + self.focal_slot()).min(last))
724        }
725    }
726
727    /// The `(leading, focal)` pair a scroll offset reads as — upstream's
728    /// `_reportedLeadingIndex`, which rounds so both swipe directions report at
729    /// the halfway mark.
730    fn indices_at(&self, offset: f64) -> (usize, usize) {
731        let raw = physics::nearest_item(offset, self.solver(offset).stride());
732        self.indices_for_leading(raw)
733    }
734
735    /// The scroll offset that rests on slot item `index`.
736    fn offset_for(&self, index: usize) -> f64 {
737        let solver = self.solver(self.offset);
738        let target = index as f64 * solver.stride();
739        // Ordered explicitly rather than `clamp`: `max_scroll_offset` is
740        // computed and floored to `0.0` here (`layout`'s clamp discipline).
741        target
742            .max(0.0)
743            .min(solver.max_scroll_offset(self.pods.len()).max(0.0))
744    }
745
746    /// The furthest the content may scroll at the current viewport.
747    fn max_offset(&self) -> f64 {
748        self.solver(self.offset)
749            .max_scroll_offset(self.pods.len())
750            .max(0.0)
751    }
752
753    /// Whether there is anything to scroll at all.
754    fn scrollable(&self) -> bool {
755        self.max_offset() > 0.0
756    }
757
758    /// Set the offset, bounded to the scrollable range, and re-slot.
759    fn set_offset(&mut self, offset: f64) {
760        let offset = if offset.is_finite() { offset } else { 0.0 };
761        // Ordered explicitly rather than `clamp` (see `offset_for`).
762        self.offset = offset.max(0.0).min(self.max_offset());
763        self.sync_geometry();
764    }
765
766    /// Recompute the visible slots for the current offset and place every pod.
767    ///
768    /// Called from `layout`, from the drag arm (so a drag moves the content
769    /// without a relayout) and from `paint` while settling.
770    fn sync_geometry(&mut self) {
771        self.slots = self.solver(self.offset).slots(self.pods.len());
772        let content = self.content_size();
773        let first_visible = self.slots.first().map(|s| s.index);
774        for (index, pod) in self.pods.iter_mut().enumerate() {
775            let origin = match self.slots.iter().find(|s| s.index == index) {
776                Some(slot) => {
777                    // The child is laid out at the largest slot and centred in
778                    // the current one, so only the clip window moves (module
779                    // docs). Half a slot of it can overhang either edge.
780                    let along =
781                        slot.offset + (slot.extent - content_main(self.axis, content)) / 2.0;
782                    let across = (self.cross - content_cross(self.axis, content)) / 2.0;
783                    axis_point(self.axis, along, across)
784                }
785                None => {
786                    // Not visible: parked wholly outside the carousel's own
787                    // bounds so no pointer inside it can ever hit the pod
788                    // (`route_event` hit-tests by pod bounds).
789                    let along = if first_visible.is_some_and(|first| index < first) {
790                        -content_main(self.axis, content) - 1.0
791                    } else {
792                        self.main + 1.0
793                    };
794                    axis_point(self.axis, along, 0.0)
795                }
796            };
797            pod.set_origin(origin);
798        }
799    }
800
801    /// The size every child is laid out at: the largest slot, inset by the item
802    /// padding on all four sides.
803    fn content_size(&self) -> Size {
804        let slot = self.solver(self.offset).slot_extent();
805        let main = (slot - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
806        let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
807        match self.axis {
808            CarouselAxis::Horizontal => Size::new(main, cross),
809            CarouselAxis::Vertical => Size::new(cross, main),
810        }
811    }
812
813    /// A visible item's clip window in widget-local coordinates: its slot,
814    /// inset by the item padding.
815    fn item_rect(&self, slot: &Slot) -> Rect {
816        let main = (slot.extent - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
817        let cross = (self.cross - CAROUSEL_ITEM_PADDING * 2.0).max(0.0);
818        let origin = axis_point(
819            self.axis,
820            slot.offset + CAROUSEL_ITEM_PADDING,
821            CAROUSEL_ITEM_PADDING,
822        );
823        let size = match self.axis {
824            CarouselAxis::Horizontal => Size::new(main, cross),
825            CarouselAxis::Vertical => Size::new(cross, main),
826        };
827        Rect::from_origin_size(origin, size)
828    }
829
830    /// The item whose clip window contains widget-local `pos`, if any.
831    fn item_at(&self, pos: Point) -> Option<usize> {
832        self.slots
833            .iter()
834            .find(|slot| self.item_rect(slot).contains(pos))
835            .map(|slot| slot.index)
836    }
837
838    /// A position's main-axis component under this carousel's axis.
839    fn main_of(&self, point: Point) -> f64 {
840        match self.axis {
841            CarouselAxis::Horizontal => point.x,
842            CarouselAxis::Vertical => point.y,
843        }
844    }
845
846    /// The last painted frame time in milliseconds — the event pass's
847    /// timestamp source for velocity tracking (see [`Self::last_frame_time`]).
848    fn event_time_ms(&self) -> f64 {
849        self.last_frame_time.as_secs_f64() * 1000.0
850    }
851
852    /// Start settling from wherever the content is onto `target`.
853    fn settle_to(&mut self, target: f64) {
854        self.settle_from = self.offset;
855        self.settle_to = target;
856        self.settle = physics::settle_controller();
857        self.settle.forward();
858    }
859
860    /// Start settling back onto the carousel's own index.
861    fn start_settle(&mut self) {
862        let target = self.offset_for(self.index);
863        self.settle_to(target);
864    }
865
866    /// Report `(leading, focal)` for `offset` if either changed since the last
867    /// report.
868    fn report(&mut self, ctx: &mut EventCtx, offset: f64) {
869        let pair = self.indices_at(offset);
870        if pair == self.reported {
871            return;
872        }
873        self.reported = pair;
874        let count = self.pods.len();
875        if let Some(on_change) = self.on_change.as_mut() {
876            on_change(
877                ctx,
878                CarouselChange {
879                    leading_index: pair.0,
880                    focal_index: pair.1,
881                    item_count: count,
882                },
883            );
884        }
885    }
886
887    /// Forward a synthesized `Cancel` to the item a drag has taken the gesture
888    /// away from.
889    fn cancel_item(&mut self, ctx: &mut EventCtx, item: usize, pos: Point) {
890        if let Some(pod) = self.pods.get_mut(item) {
891            let cancel = InputEvent::Pointer(PointerEvent {
892                phase: PointerPhase::Cancel,
893                position: pos,
894                button: PointerButton::Primary,
895            });
896            pod.event_child(ctx, &cancel);
897            pod.set_active(false);
898        }
899    }
900
901    /// Clear the recorded focus link on every item but the one a `Down` just
902    /// landed on and left focused — the blur
903    /// [`frust::authoring::route_event`] performs for containers that route
904    /// through it, which this widget's hand-rolled routing owes too
905    /// (`docs/CODE_STANDARDS.md`'s focus-routing rule).
906    fn blur_unhit(&mut self, hit: Option<usize>) {
907        let kept = hit.filter(|index| self.pods.get(*index).is_some_and(ChildPod::is_focused));
908        for (index, pod) in self.pods.iter_mut().enumerate() {
909            if Some(index) != kept && pod.is_focused() {
910                pod.set_focused(false);
911            }
912        }
913    }
914
915    /// The event body, parameterised on an explicit timestamp so the velocity
916    /// math is deterministic in tests; [`Widget::event`] supplies the real
917    /// clock (see the [module docs](self)).
918    fn event_at(&mut self, ctx: &mut EventCtx, event: &InputEvent, t_ms: f64) -> EventResult {
919        // A broadcast is not user input: it reaches every child ahead of the
920        // capture/hit-test branches and is never consumed.
921        if event.is_broadcast() {
922            return frust::authoring::route_event(&mut self.pods, ctx, event);
923        }
924        if let InputEvent::Scroll { delta, .. } = event
925            && self.drag.is_none()
926            && self.scrollable()
927        {
928            return self.wheel(ctx, delta);
929        }
930        let InputEvent::Pointer(p) = event else {
931            // Key/Ime are focus-routed; the carousel itself has no key handling.
932            return frust::authoring::route_event(&mut self.pods, ctx, event);
933        };
934
935        // A live gesture owns every pointer pass until it ends.
936        if self.drag.is_some() {
937            return self.drag_event(ctx, event, p, t_ms);
938        }
939
940        match p.phase {
941            PointerPhase::Down => {
942                let item = self.item_at(p.position);
943                if !presses(p) {
944                    // A secondary press is a context gesture: it reaches the
945                    // item but starts no drag and arms no press.
946                    let result = self.forward_to_item(ctx, event, item);
947                    self.blur_unhit(item);
948                    return result;
949                }
950                let main = self.main_of(p.position);
951                self.drag = Some(Drag {
952                    start: main,
953                    from: self.offset,
954                    item,
955                    scrolling: false,
956                });
957                self.tracker.clear();
958                self.tracker.record(t_ms, main);
959                self.settle.stop();
960                self.pressed = item;
961                self.state.set_pressed(item.is_some());
962                ctx.capture_pointer();
963                self.forward_to_item(ctx, event, item);
964                self.blur_unhit(item);
965                ctx.request_redraw();
966                EventResult::Handled
967            }
968            PointerPhase::Move => {
969                // An uncaptured move is a hover pass: the item under the
970                // pointer claims for itself.
971                self.forward_to_item(ctx, event, self.item_at(p.position));
972                EventResult::Ignored
973            }
974            PointerPhase::Up | PointerPhase::Cancel => EventResult::Ignored,
975        }
976    }
977
978    /// The captured arm: every pass between the `Down` that armed a drag and
979    /// the `Up`/`Cancel` that ends it.
980    fn drag_event(
981        &mut self,
982        ctx: &mut EventCtx,
983        event: &InputEvent,
984        p: &PointerEvent,
985        t_ms: f64,
986    ) -> EventResult {
987        let main = self.main_of(p.position);
988        match p.phase {
989            PointerPhase::Down => EventResult::Handled,
990            PointerPhase::Move => {
991                self.tracker.record(t_ms, main);
992                let Some(drag) = self.drag.as_ref() else {
993                    return EventResult::Handled;
994                };
995                let (from, start, item, scrolling) =
996                    (drag.from, drag.start, drag.item, drag.scrolling);
997                if scrolling {
998                    self.set_offset(from - (main - start));
999                    self.report(ctx, self.offset);
1000                    ctx.request_redraw();
1001                    return EventResult::Handled;
1002                }
1003                if self.scrollable() && (main - start).abs() > TOUCH_SLOP {
1004                    // Take the gesture over: cancel the item's own press and
1005                    // scroll from here.
1006                    if let Some(item) = item {
1007                        self.cancel_item(ctx, item, p.position);
1008                    }
1009                    self.pressed = None;
1010                    self.state.set_pressed(false);
1011                    if let Some(drag) = self.drag.as_mut() {
1012                        drag.scrolling = true;
1013                    }
1014                    ctx.request_redraw();
1015                    return EventResult::Handled;
1016                }
1017                self.forward_to_item(ctx, event, item);
1018                EventResult::Handled
1019            }
1020            PointerPhase::Up => {
1021                let Some(drag) = self.drag.take() else {
1022                    return EventResult::Handled;
1023                };
1024                self.pressed = None;
1025                self.state.set_pressed(false);
1026                if drag.scrolling {
1027                    self.release(ctx);
1028                } else {
1029                    self.forward_to_item(ctx, event, drag.item);
1030                    // Fire on up-inside: the release must land on the same item
1031                    // the press did.
1032                    if let Some(item) = drag.item
1033                        && self.item_at(p.position) == Some(item)
1034                        && let Some(on_tap) = self.on_tap.as_mut()
1035                    {
1036                        on_tap(ctx, item);
1037                    }
1038                }
1039                if let Some(item) = drag.item
1040                    && let Some(pod) = self.pods.get_mut(item)
1041                {
1042                    pod.set_active(false);
1043                }
1044                ctx.request_redraw();
1045                EventResult::Handled
1046            }
1047            PointerPhase::Cancel => {
1048                // Internal flags only — a `Cancel` arm never reaches app state
1049                // and never fires a callback.
1050                let drag = self.drag.take();
1051                self.pressed = None;
1052                self.state.set_pressed(false);
1053                if let Some(drag) = &drag
1054                    && let Some(item) = drag.item
1055                {
1056                    if !drag.scrolling {
1057                        self.forward_to_item(ctx, event, Some(item));
1058                    }
1059                    if let Some(pod) = self.pods.get_mut(item) {
1060                        pod.set_active(false);
1061                    }
1062                }
1063                self.start_settle();
1064                ctx.request_redraw();
1065                EventResult::Handled
1066            }
1067        }
1068    }
1069
1070    /// Choose and start the settle a released scroll lands on, reporting the
1071    /// item it will land on right away (the paint-driven ramp that follows
1072    /// carries no `EventCtx` to report from).
1073    fn release(&mut self, ctx: &mut EventCtx) {
1074        // The content moves opposite the finger, so a forward scroll is a
1075        // negative finger velocity (`physics`' sign convention).
1076        let scroll_velocity = -self.tracker.velocity();
1077        self.settle_snapped(ctx, scroll_velocity);
1078    }
1079
1080    /// Snap onto the item a scroll at `scroll_velocity` lands on, reporting it.
1081    fn settle_snapped(&mut self, ctx: &mut EventCtx, scroll_velocity: f64) {
1082        let stride = self.solver(self.offset).stride();
1083        let target = physics::snap_offset(self.offset, stride, scroll_velocity, self.max_offset());
1084        self.report(ctx, target);
1085        if self.controlled {
1086            // Controlled: the content still has to settle back onto the item
1087            // the app last confirmed.
1088            self.start_settle();
1089            return;
1090        }
1091        self.index = physics::nearest_item(target, stride).min(self.pods.len().saturating_sub(1));
1092        self.settle_to(target);
1093    }
1094
1095    /// The mouse-wheel arm: move the content by the wheel delta, then settle
1096    /// onto the nearest item boundary.
1097    ///
1098    /// Upstream has no wheel path of its own (its carousel is swipe-driven
1099    /// under `NeverScrollableScrollPhysics`); this is the port's desktop
1100    /// completion of it, taking [`crate::button_group`]'s per-axis delta
1101    /// selection and baseline `ScrollView`'s sign convention (a positive delta
1102    /// moves the content forward).
1103    fn wheel(&mut self, ctx: &mut EventCtx, delta: &ScrollDelta) -> EventResult {
1104        let (dx, dy) = match delta {
1105            ScrollDelta::Lines(x, y) => (x * WHEEL_LINE_PX, y * WHEEL_LINE_PX),
1106            ScrollDelta::Pixels(x, y) => (*x, *y),
1107        };
1108        // A horizontal carousel takes the wheel's horizontal axis when it
1109        // carries one, and its vertical axis otherwise (the usual shift-less
1110        // mouse over a horizontal strip).
1111        let main = match self.axis {
1112            CarouselAxis::Horizontal if dx != 0.0 => dx,
1113            CarouselAxis::Horizontal | CarouselAxis::Vertical => dy,
1114        };
1115        self.set_offset(self.offset + main);
1116        // A wheel tick carries no velocity of its own: it always settles onto
1117        // the nearest boundary.
1118        self.settle_snapped(ctx, 0.0);
1119        ctx.request_redraw();
1120        EventResult::Handled
1121    }
1122
1123    /// Forward an event to one item's pod, if there is one under the pointer.
1124    fn forward_to_item(
1125        &mut self,
1126        ctx: &mut EventCtx,
1127        event: &InputEvent,
1128        item: Option<usize>,
1129    ) -> EventResult {
1130        match item.and_then(|index| self.pods.get_mut(index)) {
1131            Some(pod) => pod.event_child(ctx, event),
1132            None => EventResult::Ignored,
1133        }
1134    }
1135
1136    /// Advance the settle ramp from the shell frame clock, moving the content
1137    /// and asking for the next frame while it runs.
1138    fn advance_settle(&mut self, ctx: &mut PaintCtx, reduce_motion: bool) {
1139        if self.drag.is_some() || !self.settle.is_animating() {
1140            return;
1141        }
1142        let next = if reduce_motion {
1143            self.settle.stop();
1144            self.settle_to
1145        } else {
1146            if self.settle.advance(ctx.frame_time()) {
1147                ctx.request_frame();
1148            }
1149            let ramp = self.settle.value_clamped();
1150            self.settle_from + (self.settle_to - self.settle_from) * ramp
1151        };
1152        if (next - self.offset).abs() > OFFSET_EPSILON {
1153            self.set_offset(next);
1154        }
1155    }
1156}
1157
1158/// The main-axis component of a size under `axis`.
1159fn content_main(axis: CarouselAxis, size: Size) -> f64 {
1160    match axis {
1161        CarouselAxis::Horizontal => size.width,
1162        CarouselAxis::Vertical => size.height,
1163    }
1164}
1165
1166/// The cross-axis component of a size under `axis`.
1167fn content_cross(axis: CarouselAxis, size: Size) -> f64 {
1168    match axis {
1169        CarouselAxis::Horizontal => size.height,
1170        CarouselAxis::Vertical => size.width,
1171    }
1172}
1173
1174/// A `(main, cross)` pair mapped onto `(x, y)` under `axis` — the one place
1175/// the vertical carousel differs from the horizontal one.
1176fn axis_point(axis: CarouselAxis, main: f64, cross: f64) -> Point {
1177    match axis {
1178        CarouselAxis::Horizontal => Point::new(main, cross),
1179        CarouselAxis::Vertical => Point::new(cross, main),
1180    }
1181}
1182
1183/// The resolved item corner radius: the explicit builder value, then the
1184/// theme's `shape.extra_large`, then [`CAROUSEL_ITEM_RADIUS`].
1185fn resolve_radius(explicit: Option<f64>, theme: Option<&Theme>) -> f64 {
1186    explicit.unwrap_or(match theme {
1187        Some(theme) => theme.shape.extra_large,
1188        None => CAROUSEL_ITEM_RADIUS,
1189    })
1190}
1191
1192/// The resolved state-layer content color for a pressed item. Themed:
1193/// `colors.on_surface`. Unthemed: [`ON_SURFACE`].
1194fn resolve_content_color(theme: Option<&Theme>) -> Color {
1195    match theme {
1196        Some(theme) => theme.scheme().on_surface,
1197        None => ON_SURFACE,
1198    }
1199}
1200
1201impl Widget for CarouselWidget {
1202    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1203        let max = bc.max();
1204        // A carousel derives every extent from its viewport, so an unbounded
1205        // axis has nothing to derive from: it takes the pinned width/height if
1206        // one was given and collapses otherwise (upstream requires a bounded
1207        // box too — `M3ECarousel` reads `width ?? constraints.maxWidth`).
1208        let width = self
1209            .width
1210            .unwrap_or(if max.width.is_finite() {
1211                max.width
1212            } else {
1213                0.0
1214            })
1215            .max(0.0);
1216        let height = self
1217            .height
1218            .unwrap_or(if max.height.is_finite() {
1219                max.height
1220            } else {
1221                0.0
1222            })
1223            .max(0.0);
1224        let size = Size::new(width, height);
1225        (self.main, self.cross) = match self.axis {
1226            CarouselAxis::Horizontal => (width, height),
1227            CarouselAxis::Vertical => (height, width),
1228        };
1229
1230        let content = self.content_size();
1231        for pod in self.pods.iter_mut() {
1232            pod.layout_child(ctx, &BoxConstraints::tight(content));
1233        }
1234
1235        // A resize rescales every extent, and the carousel must stay on its own
1236        // item unless a gesture or a settle currently owns the offset.
1237        if self.drag.is_none() && !self.settle.is_animating() {
1238            self.index = self.index.min(self.pods.len().saturating_sub(1));
1239            let target = self.offset_for(self.index);
1240            self.offset = target;
1241        }
1242        self.set_offset(self.offset);
1243        bc.constrain(size)
1244    }
1245
1246    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1247        // Record the shared frame clock so the between-frames event pass has a
1248        // timestamp for velocity tracking.
1249        self.last_frame_time = ctx.frame_time();
1250        let (radius, content_color, reduce_motion) = {
1251            let theme = Theme::from_paint_ctx(ctx);
1252            (
1253                resolve_radius(self.item_radius, theme),
1254                resolve_content_color(theme),
1255                theme.is_some_and(|t| t.motion.reduce_motion),
1256            )
1257        };
1258        self.advance_settle(ctx, reduce_motion);
1259
1260        let origin = ctx.origin();
1261        let size = ctx.size();
1262        scene.push_clip(origin, size);
1263        // `slots` is a snapshot; the pods it names are borrowed mutably below.
1264        let slots = std::mem::take(&mut self.slots);
1265        for slot in &slots {
1266            let local = self.item_rect(slot);
1267            if local.width() <= 0.0 || local.height() <= 0.0 {
1268                continue;
1269            }
1270            let at = Point::new(origin.x + local.x0, origin.y + local.y0);
1271            let item_size = local.size();
1272            // The rounded clip *is* the item's shape: baseline `Clip` does not
1273            // visually clip (module docs). The radius is deliberately not
1274            // capped here — `kurbo::RoundedRect::from_rect` clamps it to half
1275            // the shortest side, so an item shrinking past `2 × radius` reads
1276            // as a pill rather than as an artefact.
1277            scene.push_clip_rounded(at, item_size, radius);
1278            if let Some(pod) = self.pods.get_mut(slot.index) {
1279                pod.paint_child(ctx, scene);
1280            }
1281            if self.pressed == Some(slot.index) {
1282                self.state.paint(
1283                    ctx,
1284                    scene,
1285                    Rect::from_origin_size(at, item_size),
1286                    radius,
1287                    content_color,
1288                );
1289            }
1290            scene.pop_clip();
1291        }
1292        self.slots = slots;
1293        scene.pop_clip();
1294    }
1295
1296    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1297        self.event_at(ctx, event, self.event_time_ms())
1298    }
1299
1300    fn semantics(&self, ctx: &mut SemanticsCtx) {
1301        let pods = &self.pods;
1302        ctx.push_container(
1303            Role::Group,
1304            |_| {},
1305            |ctx| {
1306                for pod in pods {
1307                    pod.semantics_child(ctx);
1308                }
1309            },
1310        );
1311    }
1312
1313    frust::authoring::visit_children!(pods);
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use frust::authoring::{PointerButton, PointerEvent, PointerPhase, any};
1320    use std::any::Any;
1321
1322    /// A 600×200 carousel: three `[5, 4, 1]` slots of 300/240/60px.
1323    const WINDOW: Size = Size::new(600.0, 200.0);
1324    /// The contained layout's leading-slot extent at [`WINDOW`] — one item step.
1325    const STRIDE: f64 = 300.0;
1326
1327    #[derive(Default)]
1328    struct Reports {
1329        changes: Vec<CarouselChange>,
1330        taps: Vec<usize>,
1331        presses: usize,
1332    }
1333
1334    /// A leaf that fills whatever it is given and counts the presses that reach
1335    /// it, generic over the app state.
1336    struct Item;
1337
1338    /// The retained half of [`Item`].
1339    struct ItemWidget;
1340
1341    impl<S: 'static> View<S> for Item {
1342        type Element = ItemWidget;
1343        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ItemWidget {
1344            ItemWidget
1345        }
1346        fn rebuild(
1347            &self,
1348            _prev: &Self,
1349            _element: &mut ItemWidget,
1350            _ctx: &mut BuildCtx<'_>,
1351        ) -> ChangeFlags {
1352            ChangeFlags::NONE
1353        }
1354    }
1355
1356    impl Widget for ItemWidget {
1357        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1358            bc.max()
1359        }
1360        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1361        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1362            if let InputEvent::Pointer(p) = event
1363                && p.phase == PointerPhase::Down
1364            {
1365                ctx.state_mut::<Reports>().presses += 1;
1366                // An item with its own focusable content, so the carousel's
1367                // blur bookkeeping has something to clear.
1368                ctx.request_focus();
1369                return EventResult::Handled;
1370            }
1371            EventResult::Ignored
1372        }
1373    }
1374
1375    #[derive(Default)]
1376    struct RecordingScene {
1377        clips: Vec<(Point, Size)>,
1378        rounded: Vec<(Point, Size, f64)>,
1379        rrects: Vec<(Point, Size, f64, Color)>,
1380    }
1381
1382    impl PaintScene for RecordingScene {
1383        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1384        fn draw_text(&mut self, _o: Point, _t: &str) {}
1385        fn push_clip(&mut self, origin: Point, size: Size) {
1386            self.clips.push((origin, size));
1387        }
1388        fn push_clip_rounded(&mut self, origin: Point, size: Size, radius: f64) {
1389            self.rounded.push((origin, size, radius));
1390        }
1391        fn pop_clip(&mut self) {}
1392        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1393            self.rrects.push((o, s, radius, color));
1394        }
1395    }
1396
1397    fn items(count: usize) -> Vec<AnyView<Reports>> {
1398        (0..count).map(|_| any(Item)).collect()
1399    }
1400
1401    fn view(count: usize) -> CarouselView<Reports> {
1402        contained_carousel(items(count))
1403            .on_change(|s: &mut Reports, change: CarouselChange| s.changes.push(change))
1404            .on_tap(|s: &mut Reports, index: usize| s.taps.push(index))
1405    }
1406
1407    fn build(v: &CarouselView<Reports>) -> CarouselWidget {
1408        let mut counter = 0u64;
1409        View::<Reports>::build(v, &mut BuildCtx::new(&mut counter))
1410    }
1411
1412    fn laid_out(v: &CarouselView<Reports>) -> CarouselWidget {
1413        let mut w = build(v);
1414        let mut ctx = LayoutCtx::new();
1415        w.layout(&mut ctx, &BoxConstraints::loose(WINDOW));
1416        w
1417    }
1418
1419    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1420        InputEvent::Pointer(PointerEvent {
1421            phase,
1422            position: Point::new(x, y),
1423            button: PointerButton::Primary,
1424        })
1425    }
1426
1427    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1428        InputEvent::Pointer(PointerEvent {
1429            phase,
1430            position: Point::new(x, y),
1431            button: PointerButton::Secondary,
1432        })
1433    }
1434
1435    /// Dispatch one event at an explicit timestamp (ms), the seam the velocity
1436    /// math is parameterised on.
1437    fn dispatch(w: &mut CarouselWidget, state: &mut Reports, event: &InputEvent, t_ms: f64) {
1438        let state_any: &mut dyn Any = state;
1439        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
1440        w.event_at(&mut ctx, event, t_ms);
1441    }
1442
1443    fn paint(w: &mut CarouselWidget) -> RecordingScene {
1444        let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
1445        let mut scene = RecordingScene::default();
1446        w.paint(&mut ctx, &mut scene);
1447        scene
1448    }
1449
1450    /// Drive a started settle to completion without a shell clock, then apply
1451    /// the resulting offset the way `paint` would.
1452    fn finish_settle(w: &mut CarouselWidget) {
1453        w.settle.advance(FrameTime::from_nanos(0));
1454        w.settle.advance(FrameTime::from_nanos(2_000_000_000));
1455        let value = w.settle.value_clamped();
1456        let next = w.settle_from + (w.settle_to - w.settle_from) * value;
1457        w.set_offset(next);
1458    }
1459
1460    // ---- layout wiring ----------------------------------------------------
1461
1462    #[test]
1463    fn every_child_is_laid_out_at_the_largest_slot_less_its_padding() {
1464        let w = laid_out(&view(6));
1465        for pod in &w.pods {
1466            assert_eq!(
1467                pod.size(),
1468                Size::new(
1469                    STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
1470                    WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
1471                ),
1472                "a slot's extent changes with the scroll; the child's does not"
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn the_visible_slots_are_the_layouts_own_weights() {
1479        let w = laid_out(&view(6));
1480        assert_eq!(
1481            w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1482            vec![300.0, 240.0, 60.0]
1483        );
1484    }
1485
1486    #[test]
1487    fn items_beyond_the_viewport_are_parked_outside_the_carousels_bounds() {
1488        let w = laid_out(&view(6));
1489        for pod in &w.pods[3..] {
1490            assert!(
1491                pod.origin().x >= WINDOW.width || pod.origin().x + pod.size().width <= 0.0,
1492                "an invisible item must be unhittable, not merely unpainted"
1493            );
1494        }
1495    }
1496
1497    #[test]
1498    fn a_vertical_carousel_slots_along_the_y_axis() {
1499        let w = laid_out(&view(6).axis(CarouselAxis::Vertical));
1500        // 200px of main axis over `[5, 4, 1]`: 100/80/20.
1501        assert_eq!(
1502            w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1503            vec![100.0, 80.0, 20.0]
1504        );
1505        assert_eq!(w.pods[0].origin().y, CAROUSEL_ITEM_PADDING);
1506        assert_eq!(
1507            w.pods[0].size().width,
1508            WINDOW.width - CAROUSEL_ITEM_PADDING * 2.0
1509        );
1510    }
1511
1512    #[test]
1513    fn an_unbounded_axis_collapses_unless_a_size_is_pinned() {
1514        let mut w = build(&view(6));
1515        let mut ctx = LayoutCtx::new();
1516        let size = w.layout(
1517            &mut ctx,
1518            &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1519        );
1520        assert_eq!(size, Size::ZERO);
1521
1522        let mut w = build(&view(6).width(400.0).height(120.0));
1523        let mut ctx = LayoutCtx::new();
1524        let size = w.layout(
1525            &mut ctx,
1526            &BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY)),
1527        );
1528        assert_eq!(size, Size::new(400.0, 120.0));
1529    }
1530
1531    #[test]
1532    fn the_hero_layouts_focal_item_is_offset_from_the_leading_one() {
1533        let w = laid_out(&view(6).layout(CarouselLayout::Hero));
1534        assert_eq!(w.focal_slot(), 1, "`[2, 6, 2]`'s max weight is in slot 1");
1535        assert_eq!(w.leading_index(), 0);
1536        assert_eq!(w.focal_index(), 1);
1537
1538        let start = laid_out(
1539            &view(6)
1540                .layout(CarouselLayout::Hero)
1541                .hero_alignment(HeroAlignment::Start),
1542        );
1543        assert_eq!(start.focal_slot(), 0, "`[8, 2]` leads with its max weight");
1544    }
1545
1546    #[test]
1547    fn an_uncontained_carousel_uses_a_fixed_extent_and_the_list_scroll_bound() {
1548        let w = laid_out(&uncontained_carousel(items(6), 200.0));
1549        assert_eq!(
1550            w.slots.iter().map(|s| s.extent).collect::<Vec<_>>(),
1551            vec![200.0, 200.0, 200.0]
1552        );
1553        assert_eq!(w.max_offset(), 6.0 * 200.0 - WINDOW.width);
1554        assert_eq!(
1555            w.focal_index(),
1556            w.leading_index(),
1557            "no focal slot to shift to"
1558        );
1559    }
1560
1561    #[test]
1562    fn the_initial_item_names_the_focal_item_not_the_leading_one() {
1563        let w = laid_out(&view(8).layout(CarouselLayout::Hero).initial_item(3));
1564        assert_eq!(
1565            w.leading_index(),
1566            2,
1567            "`[2, 6, 2]` shifts the focal item by 1"
1568        );
1569        assert_eq!(w.focal_index(), 3);
1570        assert_eq!(
1571            w.offset(),
1572            2.0 * 120.0,
1573            "and rests on the leading slot item"
1574        );
1575    }
1576
1577    #[test]
1578    fn consume_max_weight_makes_the_slot_item_the_focal_one() {
1579        // With the leading items allowed to expand into the max slot, the item
1580        // the offset rests on is the focal one and the *leading* one is the
1581        // phantom-shifted index behind it.
1582        let w = laid_out(
1583            &view(8)
1584                .layout(CarouselLayout::Hero)
1585                .consume_max_weight(true)
1586                .initial_item(3),
1587        );
1588        assert_eq!(w.focal_index(), 3);
1589        assert_eq!(w.leading_index(), 2);
1590        assert_eq!(w.offset(), 3.0 * 120.0);
1591
1592        let at_rest = laid_out(
1593            &view(8)
1594                .layout(CarouselLayout::Hero)
1595                .consume_max_weight(true),
1596        );
1597        assert_eq!(
1598            at_rest.focal_index(),
1599            0,
1600            "item 0 occupies the largest slot at offset 0"
1601        );
1602    }
1603
1604    // ---- paint ------------------------------------------------------------
1605
1606    #[test]
1607    fn each_visible_item_is_painted_inside_its_own_rounded_clip() {
1608        let mut w = laid_out(&view(6));
1609        let scene = paint(&mut w);
1610        assert_eq!(scene.clips.len(), 1, "the carousel clips its own viewport");
1611        assert_eq!(scene.rounded.len(), 3, "one rounded clip per visible item");
1612        let (origin, size, radius) = scene.rounded[0];
1613        assert_eq!(
1614            origin,
1615            Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
1616        );
1617        assert_eq!(
1618            size,
1619            Size::new(
1620                STRIDE - CAROUSEL_ITEM_PADDING * 2.0,
1621                WINDOW.height - CAROUSEL_ITEM_PADDING * 2.0
1622            )
1623        );
1624        assert_eq!(radius, CAROUSEL_ITEM_RADIUS);
1625    }
1626
1627    #[test]
1628    fn a_theme_resolves_the_item_radius_and_an_explicit_value_wins() {
1629        let theme = crate::baseline();
1630        let mut w = laid_out(&view(6));
1631        let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
1632        let mut scene = RecordingScene::default();
1633        w.paint(&mut ctx, &mut scene);
1634        assert_eq!(scene.rounded[0].2, theme.shape.extra_large);
1635
1636        let mut w = laid_out(&view(6).item_radius(4.0));
1637        let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
1638        let mut scene = RecordingScene::default();
1639        w.paint(&mut ctx, &mut scene);
1640        assert_eq!(scene.rounded[0].2, 4.0);
1641    }
1642
1643    #[test]
1644    fn a_pressed_item_paints_the_state_layer_over_its_own_clip() {
1645        let mut w = laid_out(&view(6));
1646        let mut state = Reports::default();
1647        dispatch(
1648            &mut w,
1649            &mut state,
1650            &ev(PointerPhase::Down, 100.0, 100.0),
1651            0.0,
1652        );
1653        let scene = paint(&mut w);
1654        assert_eq!(scene.rrects.len(), 1, "exactly the pressed item's overlay");
1655        assert_eq!(
1656            scene.rrects[0].0,
1657            Point::new(CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_PADDING)
1658        );
1659        assert_eq!(
1660            scene.rrects[0].3.components[3],
1661            crate::interaction::PRESSED_OPACITY
1662        );
1663
1664        dispatch(
1665            &mut w,
1666            &mut state,
1667            &ev(PointerPhase::Up, 100.0, 100.0),
1668            16.0,
1669        );
1670        let scene = paint(&mut w);
1671        assert!(scene.rrects.is_empty(), "the overlay clears on release");
1672    }
1673
1674    // ---- gestures and snapping --------------------------------------------
1675
1676    #[test]
1677    fn a_press_reaches_the_item_under_it_and_a_tap_reports_that_item() {
1678        let mut w = laid_out(&view(6));
1679        let mut state = Reports::default();
1680        dispatch(
1681            &mut w,
1682            &mut state,
1683            &ev(PointerPhase::Down, 400.0, 100.0),
1684            0.0,
1685        );
1686        assert_eq!(
1687            state.presses, 1,
1688            "the item under the pointer sees the press"
1689        );
1690        assert_eq!(w.pressed, Some(1));
1691        dispatch(
1692            &mut w,
1693            &mut state,
1694            &ev(PointerPhase::Up, 400.0, 100.0),
1695            16.0,
1696        );
1697        assert_eq!(state.taps, vec![1]);
1698        assert!(w.pressed.is_none());
1699    }
1700
1701    #[test]
1702    fn a_release_outside_the_pressed_item_reports_no_tap() {
1703        let mut w = laid_out(&view(6));
1704        let mut state = Reports::default();
1705        dispatch(
1706            &mut w,
1707            &mut state,
1708            &ev(PointerPhase::Down, 100.0, 100.0),
1709            0.0,
1710        );
1711        dispatch(
1712            &mut w,
1713            &mut state,
1714            &ev(PointerPhase::Up, 400.0, 100.0),
1715            16.0,
1716        );
1717        assert!(state.taps.is_empty());
1718    }
1719
1720    #[test]
1721    fn a_secondary_press_reaches_the_item_but_arms_no_drag() {
1722        let mut w = laid_out(&view(6));
1723        let mut state = Reports::default();
1724        dispatch(
1725            &mut w,
1726            &mut state,
1727            &secondary_ev(PointerPhase::Down, 100.0, 100.0),
1728            0.0,
1729        );
1730        assert!(w.drag.is_none());
1731        assert!(w.pressed.is_none());
1732    }
1733
1734    #[test]
1735    fn a_drag_past_the_slop_takes_the_gesture_over_and_moves_the_content() {
1736        let mut w = laid_out(&view(6));
1737        let mut state = Reports::default();
1738        dispatch(
1739            &mut w,
1740            &mut state,
1741            &ev(PointerPhase::Down, 400.0, 100.0),
1742            0.0,
1743        );
1744        // Inside the slop: still the item's gesture.
1745        dispatch(
1746            &mut w,
1747            &mut state,
1748            &ev(PointerPhase::Move, 398.0, 100.0),
1749            8.0,
1750        );
1751        assert_eq!(w.offset, 0.0);
1752        assert_eq!(w.pressed, Some(1), "the press survives a sub-slop wobble");
1753        // Past it: the carousel takes over and the press is released.
1754        let past = 400.0 - TOUCH_SLOP - 5.0;
1755        dispatch(
1756            &mut w,
1757            &mut state,
1758            &ev(PointerPhase::Move, past, 100.0),
1759            16.0,
1760        );
1761        assert!(w.pressed.is_none());
1762        dispatch(
1763            &mut w,
1764            &mut state,
1765            &ev(PointerPhase::Move, 250.0, 100.0),
1766            24.0,
1767        );
1768        assert_eq!(w.offset, 150.0, "the content follows the finger");
1769    }
1770
1771    #[test]
1772    fn a_slow_release_mid_item_settles_onto_the_nearest_boundary() {
1773        let mut w = laid_out(&view(6));
1774        let mut state = Reports::default();
1775        // Drag 160px (past the 150px half-item mark) slowly enough that the
1776        // release velocity is below the tolerance.
1777        dispatch(
1778            &mut w,
1779            &mut state,
1780            &ev(PointerPhase::Down, 500.0, 100.0),
1781            0.0,
1782        );
1783        dispatch(
1784            &mut w,
1785            &mut state,
1786            &ev(PointerPhase::Move, 400.0, 100.0),
1787            400.0,
1788        );
1789        dispatch(
1790            &mut w,
1791            &mut state,
1792            &ev(PointerPhase::Move, 340.0, 100.0),
1793            900.0,
1794        );
1795        assert_eq!(w.offset, 160.0);
1796        dispatch(
1797            &mut w,
1798            &mut state,
1799            &ev(PointerPhase::Up, 340.0, 100.0),
1800            1000.0,
1801        );
1802        assert_eq!(w.settle_to, STRIDE, "settles forward to the item boundary");
1803        finish_settle(&mut w);
1804        assert_eq!(w.offset, STRIDE);
1805        assert_eq!(w.leading_index(), 1);
1806    }
1807
1808    #[test]
1809    fn a_slow_release_before_the_halfway_mark_settles_back() {
1810        let mut w = laid_out(&view(6));
1811        let mut state = Reports::default();
1812        dispatch(
1813            &mut w,
1814            &mut state,
1815            &ev(PointerPhase::Down, 500.0, 100.0),
1816            0.0,
1817        );
1818        dispatch(
1819            &mut w,
1820            &mut state,
1821            &ev(PointerPhase::Move, 440.0, 100.0),
1822            500.0,
1823        );
1824        dispatch(
1825            &mut w,
1826            &mut state,
1827            &ev(PointerPhase::Move, 420.0, 100.0),
1828            1000.0,
1829        );
1830        assert_eq!(w.offset, 80.0);
1831        dispatch(
1832            &mut w,
1833            &mut state,
1834            &ev(PointerPhase::Up, 420.0, 100.0),
1835            1100.0,
1836        );
1837        assert_eq!(w.settle_to, 0.0);
1838        finish_settle(&mut w);
1839        assert_eq!(w.offset, 0.0);
1840        assert_eq!(w.leading_index(), 0);
1841    }
1842
1843    #[test]
1844    fn a_short_fast_fling_still_advances_one_whole_item() {
1845        let mut w = laid_out(&view(6));
1846        let mut state = Reports::default();
1847        dispatch(
1848            &mut w,
1849            &mut state,
1850            &ev(PointerPhase::Down, 500.0, 100.0),
1851            0.0,
1852        );
1853        // The first move crosses the slop and only takes the gesture over; the
1854        // second scrolls. 60px in 16ms is 3750px/s — far past the fling
1855        // tolerance, and well short of the 150px a slow release would need.
1856        dispatch(
1857            &mut w,
1858            &mut state,
1859            &ev(PointerPhase::Move, 470.0, 100.0),
1860            8.0,
1861        );
1862        dispatch(
1863            &mut w,
1864            &mut state,
1865            &ev(PointerPhase::Move, 440.0, 100.0),
1866            16.0,
1867        );
1868        assert_eq!(w.offset, 60.0);
1869        dispatch(
1870            &mut w,
1871            &mut state,
1872            &ev(PointerPhase::Up, 440.0, 100.0),
1873            16.0,
1874        );
1875        assert_eq!(w.settle_to, STRIDE, "a fling advances one item");
1876        finish_settle(&mut w);
1877        assert_eq!(w.leading_index(), 1);
1878    }
1879
1880    #[test]
1881    fn a_backward_fling_returns_to_the_previous_item() {
1882        let mut w = laid_out(&view(6));
1883        w.set_offset(STRIDE);
1884        w.index = 1;
1885        let mut state = Reports::default();
1886        dispatch(
1887            &mut w,
1888            &mut state,
1889            &ev(PointerPhase::Down, 300.0, 100.0),
1890            0.0,
1891        );
1892        dispatch(
1893            &mut w,
1894            &mut state,
1895            &ev(PointerPhase::Move, 330.0, 100.0),
1896            8.0,
1897        );
1898        dispatch(
1899            &mut w,
1900            &mut state,
1901            &ev(PointerPhase::Move, 360.0, 100.0),
1902            16.0,
1903        );
1904        assert_eq!(
1905            w.offset, 240.0,
1906            "only 60px back — a slow release would stay"
1907        );
1908        dispatch(
1909            &mut w,
1910            &mut state,
1911            &ev(PointerPhase::Up, 360.0, 100.0),
1912            16.0,
1913        );
1914        assert_eq!(w.settle_to, 0.0);
1915        finish_settle(&mut w);
1916        assert_eq!(w.leading_index(), 0);
1917    }
1918
1919    #[test]
1920    fn a_scroll_never_leaves_the_scrollable_range() {
1921        let mut w = laid_out(&view(6));
1922        let mut state = Reports::default();
1923        dispatch(
1924            &mut w,
1925            &mut state,
1926            &ev(PointerPhase::Down, 500.0, 100.0),
1927            0.0,
1928        );
1929        dispatch(
1930            &mut w,
1931            &mut state,
1932            &ev(PointerPhase::Move, 100.0, 100.0),
1933            8.0,
1934        );
1935        dispatch(
1936            &mut w,
1937            &mut state,
1938            &ev(PointerPhase::Move, -5000.0, 100.0),
1939            16.0,
1940        );
1941        assert_eq!(w.offset, w.max_offset());
1942        dispatch(
1943            &mut w,
1944            &mut state,
1945            &ev(PointerPhase::Move, 9000.0, 100.0),
1946            24.0,
1947        );
1948        assert_eq!(w.offset, 0.0);
1949    }
1950
1951    #[test]
1952    fn a_carousel_with_nothing_to_scroll_never_takes_a_gesture_over() {
1953        // Three items in three slots: `max_scroll_offset` is zero.
1954        let mut w = laid_out(&view(3));
1955        assert!(!w.scrollable());
1956        let mut state = Reports::default();
1957        dispatch(
1958            &mut w,
1959            &mut state,
1960            &ev(PointerPhase::Down, 100.0, 100.0),
1961            0.0,
1962        );
1963        dispatch(
1964            &mut w,
1965            &mut state,
1966            &ev(PointerPhase::Move, -500.0, 100.0),
1967            16.0,
1968        );
1969        assert_eq!(w.offset, 0.0);
1970        assert_eq!(w.pressed, Some(0), "the press stays with the item");
1971    }
1972
1973    #[test]
1974    fn a_wheel_tick_moves_the_content_and_settles_onto_a_boundary() {
1975        let mut w = laid_out(&view(6));
1976        let mut state = Reports::default();
1977        let wheel = InputEvent::Scroll {
1978            position: Point::new(100.0, 100.0),
1979            delta: frust::authoring::ScrollDelta::Pixels(180.0, 0.0),
1980        };
1981        dispatch(&mut w, &mut state, &wheel, 0.0);
1982        assert_eq!(w.offset, 180.0, "the content follows the wheel");
1983        assert_eq!(w.settle_to, STRIDE, "and settles onto the nearest boundary");
1984        finish_settle(&mut w);
1985        assert_eq!(w.leading_index(), 1);
1986    }
1987
1988    #[test]
1989    fn a_press_on_another_item_clears_the_previous_ones_focus_link() {
1990        let mut w = laid_out(&view(6));
1991        let mut state = Reports::default();
1992        dispatch(
1993            &mut w,
1994            &mut state,
1995            &ev(PointerPhase::Down, 100.0, 100.0),
1996            0.0,
1997        );
1998        dispatch(
1999            &mut w,
2000            &mut state,
2001            &ev(PointerPhase::Up, 100.0, 100.0),
2002            16.0,
2003        );
2004        assert!(
2005            w.pods[0].is_focused(),
2006            "the item claimed focus on its press"
2007        );
2008        dispatch(
2009            &mut w,
2010            &mut state,
2011            &ev(PointerPhase::Down, 400.0, 100.0),
2012            32.0,
2013        );
2014        assert!(!w.pods[0].is_focused(), "a press elsewhere blurs it");
2015        assert!(w.pods[1].is_focused());
2016    }
2017
2018    #[test]
2019    fn a_cancel_clears_the_gesture_and_settles_back_without_reporting() {
2020        let mut w = laid_out(&view(6));
2021        let mut state = Reports::default();
2022        dispatch(
2023            &mut w,
2024            &mut state,
2025            &ev(PointerPhase::Down, 500.0, 100.0),
2026            0.0,
2027        );
2028        dispatch(
2029            &mut w,
2030            &mut state,
2031            &ev(PointerPhase::Move, 350.0, 100.0),
2032            16.0,
2033        );
2034        let reported = state.changes.len();
2035        dispatch(
2036            &mut w,
2037            &mut state,
2038            &ev(PointerPhase::Cancel, 350.0, 100.0),
2039            24.0,
2040        );
2041        assert!(w.drag.is_none());
2042        assert!(w.pressed.is_none());
2043        assert_eq!(w.settle_to, 0.0, "back onto the item it started from");
2044        assert_eq!(state.changes.len(), reported, "a cancel reports nothing");
2045        assert!(state.taps.is_empty());
2046    }
2047
2048    // ---- reporting and the controlled contract ----------------------------
2049
2050    #[test]
2051    fn a_scroll_reports_the_leading_and_focal_item_at_the_halfway_mark() {
2052        let mut w = laid_out(&view(8).layout(CarouselLayout::Hero));
2053        let mut state = Reports::default();
2054        // `[2, 6, 2]` over 600px: a 120px stride, so halfway is 60px.
2055        dispatch(
2056            &mut w,
2057            &mut state,
2058            &ev(PointerPhase::Down, 500.0, 100.0),
2059            0.0,
2060        );
2061        // Crosses the slop, taking the gesture over without scrolling yet.
2062        dispatch(
2063            &mut w,
2064            &mut state,
2065            &ev(PointerPhase::Move, 470.0, 100.0),
2066            100.0,
2067        );
2068        dispatch(
2069            &mut w,
2070            &mut state,
2071            &ev(PointerPhase::Move, 450.0, 100.0),
2072            200.0,
2073        );
2074        assert!(state.changes.is_empty(), "50px in: still nearest to item 0");
2075        dispatch(
2076            &mut w,
2077            &mut state,
2078            &ev(PointerPhase::Move, 420.0, 100.0),
2079            400.0,
2080        );
2081        assert_eq!(
2082            state.changes.last().copied(),
2083            Some(CarouselChange {
2084                leading_index: 1,
2085                focal_index: 2,
2086                item_count: 8
2087            })
2088        );
2089    }
2090
2091    #[test]
2092    fn a_controlled_carousel_reports_the_request_and_moves_nothing() {
2093        let mut w = laid_out(&view(6).selected(1));
2094        assert!(w.controlled);
2095        let mut state = Reports::default();
2096        dispatch(
2097            &mut w,
2098            &mut state,
2099            &ev(PointerPhase::Down, 500.0, 100.0),
2100            0.0,
2101        );
2102        dispatch(
2103            &mut w,
2104            &mut state,
2105            &ev(PointerPhase::Move, 400.0, 100.0),
2106            400.0,
2107        );
2108        dispatch(
2109            &mut w,
2110            &mut state,
2111            &ev(PointerPhase::Move, 340.0, 100.0),
2112            900.0,
2113        );
2114        dispatch(
2115            &mut w,
2116            &mut state,
2117            &ev(PointerPhase::Up, 340.0, 100.0),
2118            1000.0,
2119        );
2120        assert!(!state.changes.is_empty(), "the request is still reported");
2121        assert_eq!(
2122            w.settle_to,
2123            w.offset_for(w.index),
2124            "it settles back onto the app-confirmed item"
2125        );
2126    }
2127
2128    #[test]
2129    fn a_rebuild_with_a_new_selection_settles_onto_it() {
2130        let mut counter = 0u64;
2131        let mut ctx = BuildCtx::new(&mut counter);
2132        let first = view(8).selected(0);
2133        let mut w = View::<Reports>::build(&first, &mut ctx);
2134        let mut layout_ctx = LayoutCtx::new();
2135        w.layout(&mut layout_ctx, &BoxConstraints::loose(WINDOW));
2136        let next = view(8).selected(3);
2137        View::<Reports>::rebuild(&next, &first, &mut w, &mut ctx);
2138        assert_eq!(w.leading_index(), 3);
2139        finish_settle(&mut w);
2140        assert_eq!(w.offset, STRIDE * 3.0);
2141    }
2142
2143    #[test]
2144    fn reduce_motion_lands_on_the_target_without_asking_for_a_frame() {
2145        let mut theme = crate::baseline();
2146        theme.motion.reduce_motion = true;
2147        let mut w = laid_out(&view(6));
2148        let mut state = Reports::default();
2149        dispatch(
2150            &mut w,
2151            &mut state,
2152            &ev(PointerPhase::Down, 500.0, 100.0),
2153            0.0,
2154        );
2155        dispatch(
2156            &mut w,
2157            &mut state,
2158            &ev(PointerPhase::Move, 400.0, 100.0),
2159            400.0,
2160        );
2161        dispatch(
2162            &mut w,
2163            &mut state,
2164            &ev(PointerPhase::Move, 340.0, 100.0),
2165            900.0,
2166        );
2167        dispatch(
2168            &mut w,
2169            &mut state,
2170            &ev(PointerPhase::Up, 340.0, 100.0),
2171            1000.0,
2172        );
2173        let mut ctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
2174        let mut scene = RecordingScene::default();
2175        w.paint(&mut ctx, &mut scene);
2176        assert_eq!(w.offset, STRIDE, "jumped straight to the target");
2177        assert!(!ctx.needs_frame(), "and asked for no continuation frame");
2178    }
2179
2180    #[test]
2181    fn a_settle_asks_for_the_next_frame_while_it_runs() {
2182        let mut w = laid_out(&view(6));
2183        let mut state = Reports::default();
2184        dispatch(
2185            &mut w,
2186            &mut state,
2187            &ev(PointerPhase::Down, 500.0, 100.0),
2188            0.0,
2189        );
2190        dispatch(
2191            &mut w,
2192            &mut state,
2193            &ev(PointerPhase::Move, 400.0, 100.0),
2194            400.0,
2195        );
2196        dispatch(
2197            &mut w,
2198            &mut state,
2199            &ev(PointerPhase::Move, 340.0, 100.0),
2200            900.0,
2201        );
2202        dispatch(
2203            &mut w,
2204            &mut state,
2205            &ev(PointerPhase::Up, 340.0, 100.0),
2206            1000.0,
2207        );
2208        let mut ctx = PaintCtx::new(Point::ZERO, WINDOW);
2209        let mut scene = RecordingScene::default();
2210        w.paint(&mut ctx, &mut scene);
2211        assert!(ctx.needs_frame());
2212    }
2213
2214    #[test]
2215    fn an_empty_carousel_lays_out_and_paints_without_panicking() {
2216        let mut w = laid_out(&view(0));
2217        assert!(w.slots.is_empty());
2218        let scene = paint(&mut w);
2219        assert!(scene.rounded.is_empty());
2220        let mut state = Reports::default();
2221        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0), 0.0);
2222        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), 16.0);
2223        assert!(state.taps.is_empty());
2224    }
2225
2226    #[test]
2227    fn a_zero_sized_carousel_degrades_instead_of_dividing_by_its_viewport() {
2228        let mut w = build(&view(6));
2229        let mut ctx = LayoutCtx::new();
2230        let size = w.layout(&mut ctx, &BoxConstraints::tight(Size::ZERO));
2231        assert_eq!(size, Size::ZERO);
2232        assert!(w.slots.is_empty());
2233        assert_eq!(w.max_offset(), 0.0);
2234        let scene = paint(&mut w);
2235        assert!(scene.rounded.is_empty());
2236    }
2237}