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