Skip to main content

gpui_kit_material/components/
carousel.rs

1//! M3 carousel: multi-browse, uncontained, hero, center-aligned hero and
2//! full-screen layouts whose items change size as they scroll along
3//! keylines, snapping to items, with keyboard scrolling.
4//!
5//! Visuals follow the Figma "Material 3 Design Kit" `Carousel`: items are
6//! surface-container-high with 28dp corners, 8dp apart inside 8/16dp
7//! padding, with a title-medium label and label-small supporting text over
8//! a scrim gradient at the bottom of large items. Small items are 56dp
9//! (M3: 40–56dp).
10//!
11//! The layout follows M3's keyline model (as Material Components' carousel
12//! implements it): a strategy turns the viewport width into a row of
13//! keylines (each a position and an item width — large, medium, small).
14//! Every item has the same extent in scroll space; its on-screen position
15//! and width interpolate between the keylines it is passing, so an item
16//! grows from small through medium to large as it scrolls in and shrinks as
17//! it leaves. Content is laid out at the large width and masked by the item,
18//! so it stays centered while the mask morphs. Near the end, the keylines
19//! shift to the mirrored arrangement so the last items become large.
20//!
21//! Scrolling is driven by the wheel or trackpad and, while the carousel is
22//! focused, Left/Right (one item), Home/End. When scrolling rests, the
23//! carousel snaps to the nearest item on the default spatial curve;
24//! reduced motion snaps at once.
25use crate::{
26    color::ColorRole,
27    motion::{self, MotionToken},
28    theme::color,
29    tokens::typography::{BASE_PX, TypeRole, rem},
30};
31use gpui::{prelude::*, *};
32use std::time::Duration;
33
34#[cfg(not(target_family = "wasm"))]
35use std::time::Instant;
36#[cfg(target_family = "wasm")]
37use web_time::Instant;
38
39/// Item corners.
40pub const CAROUSEL_ITEM_RADIUS: Pixels = px(28.);
41/// The smallest item a keyline strategy shows.
42pub const CAROUSEL_SMALL_ITEM: f32 = 56.;
43const GAP: f32 = 8.;
44const PAD_X: f32 = 16.;
45const PAD_Y: f32 = 8.;
46/// How long scrolling must rest before the carousel snaps.
47const SNAP_IDLE: Duration = Duration::from_millis(160);
48
49/// A carousel's keyline strategy.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum CarouselLayout {
52    /// Large items, then one medium and one small item.
53    #[default]
54    MultiBrowse,
55    /// Items keep one size and run off the trailing edge.
56    Uncontained,
57    /// One large item and a small one at the trailing edge.
58    Hero,
59    /// One large item between two small ones.
60    CenteredHero,
61    /// One item fills the carousel; no padding or gaps.
62    FullScreen,
63}
64
65/// A caller's current-item change handler.
66type ChangeHandler = std::rc::Rc<dyn Fn(&usize, &mut Window, &mut App)>;
67
68/// One keyline: an item's left edge and width on screen.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct Keyline {
71    pub x: f32,
72    pub width: f32,
73}
74
75/// The keylines of one arrangement plus the number of focal (large) items.
76#[derive(Clone, Debug, PartialEq)]
77pub struct Keylines {
78    pub lines: Vec<Keyline>,
79    pub focal: usize,
80    /// Scroll-space extent of one item (large width plus the gap).
81    pub step: f32,
82    /// The space between items, in dp.
83    pub gap: f32,
84}
85
86/// A carousel's spacing in dp: between items and at the leading and
87/// trailing edges. Full-screen carousels use neither.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct CarouselSpacing {
90    pub gap: f32,
91    pub padding: f32,
92}
93
94impl Default for CarouselSpacing {
95    /// The kit's 8dp gap and 16dp padding.
96    fn default() -> Self {
97        Self {
98            gap: GAP,
99            padding: PAD_X,
100        }
101    }
102}
103
104fn lay_out(widths: &[f32], start: f32, gap: f32) -> Vec<Keyline> {
105    let mut x = start;
106    widths
107        .iter()
108        .map(|&width| {
109            let line = Keyline { x, width };
110            x += width + gap;
111            line
112        })
113        .collect()
114}
115
116/// The start-aligned keylines of `layout` in a carousel `width` wide whose
117/// large items are `large` wide.
118#[cfg(test)]
119pub(crate) fn keylines(layout: CarouselLayout, width: f32, large: f32) -> Keylines {
120    spaced_keylines(layout, width, large, CarouselSpacing::default())
121}
122
123/// [`keylines`] with caller spacing.
124pub(crate) fn spaced_keylines(
125    layout: CarouselLayout,
126    width: f32,
127    large: f32,
128    spacing: CarouselSpacing,
129) -> Keylines {
130    let (pad, gap) = if layout == CarouselLayout::FullScreen {
131        (0., 0.)
132    } else {
133        (spacing.padding.max(0.), spacing.gap.max(0.))
134    };
135    let available = (width - 2. * pad).max(CAROUSEL_SMALL_ITEM);
136    let small = CAROUSEL_SMALL_ITEM;
137    let (widths, focal, large) = match layout {
138        CarouselLayout::FullScreen => (vec![available], 1, available),
139        CarouselLayout::Hero => {
140            let large = (available - small - gap).max(small);
141            (vec![large, small], 1, large)
142        }
143        CarouselLayout::CenteredHero => {
144            let large = (available - 2. * (small + gap)).max(small);
145            (vec![small, large, small], 1, large)
146        }
147        CarouselLayout::Uncontained => {
148            let large = large.min(available);
149            // Items run off the trailing edge: keylines cover the viewport
150            // past the trailing padding.
151            let count = ((width - pad + gap) / (large + gap)).ceil().max(1.) as usize;
152            (vec![large; count], count, large)
153        }
154        CarouselLayout::MultiBrowse => {
155            let large = large.min(available).max(small);
156            // As many large items as leave room for a medium (at least
157            // small) and a small item.
158            let mut count = ((available - 2. * (small + gap) + gap) / (large + gap))
159                .floor()
160                .max(1.) as usize;
161            let medium = |count: usize| available - count as f32 * (large + gap) - small - gap;
162            while count > 1 && medium(count) < small {
163                count -= 1;
164            }
165            let medium = medium(count).clamp(small, large);
166            let mut widths = vec![large; count];
167            widths.extend([medium, small]);
168            (widths, count, large)
169        }
170    };
171    Keylines {
172        lines: lay_out(&widths, pad, gap),
173        focal,
174        step: large + gap,
175        gap,
176    }
177}
178
179/// The mirrored arrangement used at the end of the scroll range.
180fn end_keylines(start: &Keylines, layout: CarouselLayout) -> Keylines {
181    if matches!(
182        layout,
183        CarouselLayout::Uncontained | CarouselLayout::FullScreen | CarouselLayout::CenteredHero
184    ) {
185        return start.clone();
186    }
187    let widths: Vec<f32> = start.lines.iter().rev().map(|line| line.width).collect();
188    let first = start.lines.first().map_or(0., |line| line.x);
189    Keylines {
190        lines: lay_out(&widths, first, start.gap),
191        focal: start.focal,
192        step: start.step,
193        gap: start.gap,
194    }
195}
196
197/// The furthest scroll offset for `count` items in a carousel `width` wide.
198pub(crate) fn max_scroll(
199    keylines: &Keylines,
200    count: usize,
201    layout: CarouselLayout,
202    width: f32,
203) -> f32 {
204    let on_screen = match layout {
205        // Hero layouts let every item reach the focal keyline.
206        CarouselLayout::CenteredHero => return (count.saturating_sub(1)) as f32 * keylines.step,
207        // Uncontained items keep one size, so the end is where the last
208        // item's trailing edge meets the trailing padding. Its keylines run
209        // past the viewport, so their count says nothing about the end.
210        CarouselLayout::Uncontained => {
211            let pad = keylines.lines.first().map_or(0., |line| line.x);
212            let content = count as f32 * keylines.step - keylines.gap;
213            return (content - (width - 2. * pad)).max(0.);
214        }
215        _ => keylines.lines.len(),
216    };
217    count.saturating_sub(on_screen) as f32 * keylines.step
218}
219
220/// The on-screen keyline of an item at scroll-space position `p` (its index
221/// times the step, minus the scroll), interpolating between keylines and
222/// continuing past the edges at the edge items' size.
223pub(crate) fn place(
224    lines: &[Keyline],
225    step: f32,
226    p: f32,
227    leading_index: usize,
228    gap: f32,
229) -> Keyline {
230    // Scroll-space keyline j sits at (j - leading_index) * step.
231    let j = p / step + leading_index as f32;
232    let last = lines.len() - 1;
233    if j <= 0. {
234        let edge = lines[0];
235        let small = CAROUSEL_SMALL_ITEM.min(edge.width);
236        // Leaving the leading edge: shrink to small while moving off.
237        let t = (-j).min(1.);
238        let width = edge.width + (small - edge.width) * t;
239        // One step fully clears the leading padding.
240        let x = edge.x + j * (small + gap + edge.x);
241        return Keyline { x, width };
242    }
243    if j >= last as f32 {
244        let edge = lines[last];
245        // Past the trailing keyline an item waits beyond the trailing
246        // padding (the leading padding mirrors it), so none peeks in.
247        let x = edge.x + (j - last as f32) * (edge.width + gap + lines[0].x);
248        return Keyline {
249            x,
250            width: edge.width,
251        };
252    }
253    let i = j.floor() as usize;
254    let t = j - i as f32;
255    let (a, b) = (lines[i], lines[i + 1]);
256    Keyline {
257        x: a.x + (b.x - a.x) * t,
258        width: a.width + (b.width - a.width) * t,
259    }
260}
261
262/// Blend `a` toward `b` into `out`, reusing its allocation.
263fn blend_into(out: &mut Vec<Keyline>, a: &[Keyline], b: &[Keyline], t: f32) {
264    out.clear();
265    out.extend(a.iter().zip(b).map(|(a, b)| Keyline {
266        x: a.x + (b.x - a.x) * t,
267        width: a.width + (b.width - a.width) * t,
268    }));
269}
270
271/// One carousel item: caller content (e.g. an image) masked by the item,
272/// with an optional label and supporting text on large items.
273pub struct CarouselItem {
274    content: Option<AnyElement>,
275    label: Option<SharedString>,
276    supporting: Option<SharedString>,
277}
278
279impl CarouselItem {
280    pub fn new() -> Self {
281        Self {
282            content: None,
283            label: None,
284            supporting: None,
285        }
286    }
287    pub fn content(mut self, content: impl IntoElement) -> Self {
288        self.content = Some(content.into_any_element());
289        self
290    }
291    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
292        self.label = Some(label.into());
293        self
294    }
295    pub fn supporting(mut self, supporting: impl Into<SharedString>) -> Self {
296        self.supporting = Some(supporting.into());
297        self
298    }
299}
300
301impl Default for CarouselItem {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307#[derive(Clone, Copy)]
308struct ScrollAnimation {
309    from: f32,
310    to: f32,
311    started: Instant,
312}
313
314/// The start and end keylines for one layout, width and large size.
315struct KeylineCache {
316    key: (CarouselLayout, u32, u32, u32, u32),
317    start: Keylines,
318    end: Keylines,
319}
320
321#[derive(Default)]
322struct CarouselState {
323    /// Whether the start position has been applied.
324    started: bool,
325    scroll: f32,
326    width: Option<f32>,
327    animation: Option<ScrollAnimation>,
328    /// Keylines change only with the layout, width or large size; render
329    /// rebuilds them when that key changes.
330    keylines: Option<KeylineCache>,
331    /// The blended keylines of the current frame, reused between frames.
332    lines: Vec<Keyline>,
333    /// When resting scroll should snap; each wheel event pushes it back.
334    snap_due: Option<Instant>,
335    /// The snap step and end of the latest wheel event.
336    snap_to: (f32, f32),
337    /// Whether the one snap task is waiting for `snap_due`.
338    snap_pending: bool,
339    /// The caller's current item last applied, and the item last reported.
340    applied: Option<usize>,
341    reported: Option<usize>,
342    /// The caller's change handler, for the snap task.
343    on_change: Option<ChangeHandler>,
344    /// While the pointer drags: its x and the scroll when the drag began.
345    drag: Option<(Pixels, f32)>,
346}
347
348#[cfg(test)]
349thread_local! {
350    /// Snap tasks spawned on this thread, for tests.
351    static SNAP_TASKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
352}
353
354impl CarouselState {
355    /// The scroll the carousel is at or heading to.
356    fn destination(&self) -> f32 {
357        self.animation.map_or(self.scroll, |animation| animation.to)
358    }
359
360    /// The item a scroll position settles on.
361    fn item_at(scroll: f32, step: f32) -> usize {
362        if step <= 0. {
363            0
364        } else {
365            (scroll / step).round().max(0.) as usize
366        }
367    }
368
369    fn animate_to(&mut self, to: f32, reduced: bool) {
370        if reduced || (to - self.scroll).abs() < 0.5 {
371            self.scroll = to;
372            self.animation = None;
373        } else {
374            self.animation = Some(ScrollAnimation {
375                from: self.scroll,
376                to,
377                started: Instant::now(),
378            });
379        }
380    }
381}
382
383/// Tell the caller the item `to` settles on, once per change.
384fn report(state: &Entity<CarouselState>, to: f32, step: f32, window: &mut Window, cx: &mut App) {
385    let item = CarouselState::item_at(to, step);
386    let handler = state.update(cx, |state, _| {
387        if state.reported == Some(item) {
388            return None;
389        }
390        state.reported = Some(item);
391        state.applied = Some(item);
392        state.on_change.clone()
393    });
394    if let Some(handler) = handler {
395        handler(&item, window, cx);
396    }
397}
398
399/// The nearest item boundary to `scroll`, or the end of the range when that
400/// is nearer (an uncontained carousel's end need not fall on a boundary).
401pub(crate) fn snap(scroll: f32, step: f32, max: f32) -> f32 {
402    let max = max.max(0.);
403    if step <= 0. {
404        return 0.;
405    }
406    let boundary = ((scroll / step).round() * step).clamp(0., max);
407    if (max - scroll).abs() < (boundary - scroll).abs() {
408        max
409    } else {
410        boundary
411    }
412}
413
414/// The next item boundary after `current` (or before it, backward), within
415/// `0..=max`; the end of the range counts as a boundary.
416pub(crate) fn neighbor(current: f32, step: f32, max: f32, forward: bool) -> f32 {
417    let max = max.max(0.);
418    if step <= 0. {
419        return 0.;
420    }
421    let to = if forward {
422        (((current + 0.5) / step).floor() + 1.) * step
423    } else {
424        (((current - 0.5) / step).ceil() - 1.) * step
425    };
426    to.clamp(0., max)
427}
428
429/// The kit paints carousel labels in untokenized white (`#FFFFFF`, Figma
430/// "Material 3 Design Kit" › Carousel › Label) over the scrim gradient on
431/// caller imagery, in both themes; no color role is white in both.
432const LABEL_ON_IMAGERY: Rgba = Rgba {
433    r: 1.,
434    g: 1.,
435    b: 1.,
436    a: 1.,
437};
438
439/// An M3 carousel. `label` names it for assistive technology.
440#[derive(gpui::IntoElement)]
441pub struct MaterialCarousel {
442    id: ElementId,
443    label: SharedString,
444    layout: CarouselLayout,
445    items: Vec<CarouselItem>,
446    large: f32,
447    height: f32,
448    width: Option<f32>,
449    spacing: CarouselSpacing,
450    radius: Pixels,
451    snap: bool,
452    current: Option<usize>,
453    on_change: Option<ChangeHandler>,
454}
455
456impl MaterialCarousel {
457    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
458        Self {
459            id: id.into(),
460            label: label.into(),
461            layout: CarouselLayout::MultiBrowse,
462            items: Vec::new(),
463            large: 188.,
464            height: 205.,
465            width: None,
466            spacing: CarouselSpacing::default(),
467            radius: CAROUSEL_ITEM_RADIUS,
468            snap: true,
469            current: None,
470            on_change: None,
471        }
472    }
473    /// The space between items, in dp (the kit's 8dp by default).
474    pub fn gap(mut self, gap: f32) -> Self {
475        self.spacing.gap = gap.max(0.);
476        self
477    }
478    /// The leading and trailing padding, in dp (the kit's 16dp by default).
479    pub fn padding(mut self, padding: f32) -> Self {
480        self.spacing.padding = padding.max(0.);
481        self
482    }
483    /// Item corners (the kit's 28dp extra-large shape by default).
484    pub fn corner_radius(mut self, radius: Pixels) -> Self {
485        self.radius = radius.max(px(0.));
486        self
487    }
488    /// Whether resting scroll settles on an item boundary (on by default).
489    pub fn snap(mut self, snap: bool) -> Self {
490        self.snap = snap;
491        self
492    }
493    /// The current item: the leading focal one (the hero, centered). A new
494    /// value scrolls there on the default spatial curve.
495    pub fn current(mut self, index: usize) -> Self {
496        self.current = Some(index);
497        self
498    }
499    /// Called with the new current item when scrolling, dragging or the
500    /// keyboard moves the carousel to another one.
501    pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
502        self.on_change = Some(std::rc::Rc::new(handler));
503        self
504    }
505    pub fn layout(mut self, layout: CarouselLayout) -> Self {
506        self.layout = layout;
507        self
508    }
509    pub fn items(mut self, items: impl IntoIterator<Item = CarouselItem>) -> Self {
510        self.items.extend(items);
511        self
512    }
513    /// The large item's width in dp (multi-browse and uncontained).
514    pub fn large_item_width(mut self, width: f32) -> Self {
515        self.large = width.max(CAROUSEL_SMALL_ITEM);
516        self
517    }
518    /// The item height in dp.
519    pub fn item_height(mut self, height: f32) -> Self {
520        self.height = height;
521        self
522    }
523    /// A fixed carousel width in dp; otherwise it fills its parent and uses
524    /// the width measured in the previous frame.
525    pub fn width(mut self, width: f32) -> Self {
526        self.width = Some(width);
527        self
528    }
529}
530
531fn typed<E: Styled>(element: E, role: TypeRole) -> E {
532    let scale = role.scale();
533    element
534        .font_family(scale.font_family)
535        .text_size(scale.font_size)
536        .line_height(scale.line_height)
537        .font_weight(FontWeight(scale.font_weight as f32))
538}
539
540/// Snap once scrolling has rested for [`SNAP_IDLE`]. Each wheel event only
541/// pushes the deadline back; one task waits for it.
542fn schedule_snap(
543    state: &Entity<CarouselState>,
544    step: f32,
545    max: f32,
546    window: &mut Window,
547    cx: &mut App,
548) {
549    let due = cx.background_executor().now() + SNAP_IDLE;
550    let spawn = state.update(cx, |state, _| {
551        state.snap_due = Some(due);
552        state.snap_to = (step, max);
553        !std::mem::replace(&mut state.snap_pending, true)
554    });
555    if !spawn {
556        return;
557    }
558    #[cfg(test)]
559    SNAP_TASKS.with(|tasks| tasks.set(tasks.get() + 1));
560    let state = state.downgrade();
561    window
562        .spawn(cx, async move |cx| {
563            loop {
564                let wait = cx.update(|_, cx| {
565                    let due = state.upgrade()?.read(cx).snap_due?;
566                    Some(due.saturating_duration_since(cx.background_executor().now()))
567                });
568                match wait {
569                    Ok(Some(wait)) if !wait.is_zero() => cx.background_executor().timer(wait).await,
570                    Ok(Some(_)) => break,
571                    _ => return,
572                }
573            }
574            let _ = cx.update(|window, cx| {
575                let Some(state) = state.upgrade() else { return };
576                let reduced = cx.reduce_motion();
577                let (to, step) = state.update(cx, |state, _| {
578                    state.snap_pending = false;
579                    state.snap_due = None;
580                    let (step, max) = state.snap_to;
581                    let to = snap(state.scroll, step, max);
582                    state.animate_to(to, reduced);
583                    (to, step)
584                });
585                report(&state, to, step, window, cx);
586                window.refresh();
587            });
588        })
589        .detach();
590}
591
592impl RenderOnce for MaterialCarousel {
593    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
594        let id = self.id.clone();
595        let state = window.use_keyed_state((id.clone(), "carousel"), cx, |_, _| {
596            CarouselState::default()
597        });
598        let focus = window
599            .use_keyed_state((id.clone(), "carousel-focus"), cx, |_, cx| {
600                cx.focus_handle().tab_stop(true)
601            })
602            .read(cx)
603            .clone();
604        let layout = self.layout;
605        // Window px per design dp (`rem` sizes from a 14px base).
606        let rem_size = f32::from(window.rem_size()) / BASE_PX;
607        let width = self
608            .width
609            .or(state.read(cx).width.map(|w| w / rem_size))
610            .unwrap_or(412.);
611        let spacing = self.spacing;
612        let key = (
613            layout,
614            width.to_bits(),
615            self.large.to_bits(),
616            spacing.gap.to_bits(),
617            spacing.padding.to_bits(),
618        );
619        if state.read(cx).keylines.as_ref().map(|cache| cache.key) != Some(key) {
620            let start = spaced_keylines(layout, width, self.large, spacing);
621            let end = end_keylines(&start, layout);
622            state.update(cx, |state, _| {
623                state.keylines = Some(KeylineCache { key, start, end })
624            });
625        }
626        let count = self.items.len();
627        let (step, max, shift_len, large, gap) = {
628            let start = &state
629                .read(cx)
630                .keylines
631                .as_ref()
632                .expect("keylines were just cached")
633                .start;
634            let step = start.step;
635            let max = max_scroll(start, count, layout, width);
636            let shift_len = ((start.lines.len() - start.focal) as f32 * step).min(max);
637            let large = start.lines.iter().map(|line| line.width).fold(0., f32::max);
638            (step, max, shift_len, large, start.gap)
639        };
640        let reduced = cx.reduce_motion();
641
642        // Advance a snap or keyboard scroll animation.
643        if !state.read(cx).started {
644            // A center-aligned hero starts with its first item at the
645            // leading small keyline and the second at the hero.
646            let start = if layout == CarouselLayout::CenteredHero {
647                step.min(max)
648            } else {
649                0.
650            };
651            state.update(cx, |state, _| {
652                state.started = true;
653                state.scroll = start;
654            });
655        }
656        // Follow the caller's current item when it changes.
657        let on_change = self.on_change.clone();
658        state.update(cx, |state, _| state.on_change = on_change);
659        if let Some(current) = self.current
660            && state.read(cx).applied != Some(current)
661        {
662            let to = (current as f32 * step).clamp(0., max);
663            let first = state.read(cx).applied.is_none();
664            state.update(cx, |state, _| {
665                state.applied = Some(current);
666                state.reported = Some(current);
667                if CarouselState::item_at(state.destination(), step) != current {
668                    // The first frame starts there; later changes move.
669                    state.animate_to(to, reduced || first);
670                }
671            });
672            if state.read(cx).animation.is_some() {
673                window.request_animation_frame();
674            }
675        }
676        let mut scroll = state.read(cx).scroll.clamp(0., max);
677        if let Some(animation) = state.read(cx).animation {
678            let curve = motion::curve(MotionToken::DefaultSpatial);
679            let duration = curve.duration().as_secs_f32().max(f32::EPSILON);
680            let t = animation.started.elapsed().as_secs_f32() / duration;
681            if t >= 1. || reduced {
682                scroll = animation.to;
683                state.update(cx, |state, _| {
684                    state.scroll = animation.to;
685                    state.animation = None;
686                });
687            } else {
688                scroll = animation.from + (animation.to - animation.from) * curve.ease()(t);
689                window.request_animation_frame();
690            }
691        }
692
693        // Shift toward the mirrored keylines over the final steps.
694        let shift = if shift_len > 0. {
695            ((scroll - (max - shift_len)) / shift_len).clamp(0., 1.)
696        } else {
697            0.
698        };
699        // Blend into the state's buffer; it is handed back after the items
700        // are built, so animating frames allocate no keylines.
701        let lines = state.update(cx, |state, _| {
702            let mut lines = std::mem::take(&mut state.lines);
703            if let Some(cache) = &state.keylines {
704                blend_into(&mut lines, &cache.start.lines, &cache.end.lines, shift);
705            }
706            lines
707        });
708        let leading = if layout == CarouselLayout::CenteredHero {
709            1
710        } else {
711            0
712        };
713        let pad_y = if layout == CarouselLayout::FullScreen {
714            0.
715        } else {
716            PAD_Y
717        };
718
719        let radius = self.radius;
720        let items = self
721            .items
722            .into_iter()
723            .enumerate()
724            .filter_map(|(index, item)| {
725                let p = index as f32 * step - scroll;
726                let line = place(&lines, step, p, leading, gap);
727                if line.x + line.width < 0. || line.x > width {
728                    return None;
729                }
730                // Labels fade in as an item approaches the large size.
731                let text = ((line.width - CAROUSEL_SMALL_ITEM)
732                    / (large - CAROUSEL_SMALL_ITEM).max(1.))
733                .clamp(0., 1.);
734                let text = (text - 0.5).max(0.) * 2.;
735                let has_text = item.label.is_some() || item.supporting.is_some();
736                // Each item is a list item named by the caller's label.
737                let name = item.label.clone();
738                let description = item.supporting.clone();
739                Some(
740                    div()
741                        .id(("carousel-item", index))
742                        .role(Role::ListItem)
743                        .when_some(name, |tile, name| tile.aria_label(name))
744                        .when_some(description, |tile, text| tile.aria_description(text))
745                        .absolute()
746                        .top(rem(pad_y))
747                        .left(rem(line.x))
748                        .w(rem(line.width))
749                        .h(rem(self.height))
750                        .overflow_hidden()
751                        .rounded(radius)
752                        .bg(color(ColorRole::SURFACE_CONTAINER_HIGH))
753                        .child(
754                            // Content keeps the large width, centered under the
755                            // morphing mask.
756                            div()
757                                .absolute()
758                                .top_0()
759                                .left(rem((line.width - large) / 2.))
760                                .w(rem(large))
761                                .h_full()
762                                .flex()
763                                .items_center()
764                                .justify_center()
765                                .children(item.content),
766                        )
767                        .when(has_text && text > 0., |tile| {
768                            let ink = LABEL_ON_IMAGERY;
769                            tile.child(
770                                div()
771                                    .absolute()
772                                    .left_0()
773                                    .right_0()
774                                    .bottom_0()
775                                    .flex()
776                                    .flex_col()
777                                    .gap(rem(4.))
778                                    .p(rem(16.))
779                                    .opacity(text)
780                                    .bg(linear_gradient(
781                                        180.,
782                                        linear_color_stop(color(ColorRole::SCRIM).opacity(0.), 0.),
783                                        linear_color_stop(color(ColorRole::SCRIM).opacity(0.4), 1.),
784                                    ))
785                                    .text_color(ink)
786                                    .when_some(item.label, |text, label| {
787                                        text.child(
788                                            typed(div(), TypeRole::TitleMedium)
789                                                .truncate()
790                                                .child(label),
791                                        )
792                                    })
793                                    .when_some(item.supporting, |text, supporting| {
794                                        text.child(
795                                            typed(div(), TypeRole::LabelSmall)
796                                                .truncate()
797                                                .child(supporting),
798                                        )
799                                    }),
800                            )
801                        }),
802                )
803            });
804
805        let wheel = state.clone();
806        let keys = state.clone();
807        let measure = state.clone();
808        let grab = state.clone();
809        let snaps = self.snap;
810        let dragging = state.read(cx).drag.is_some();
811        let fixed = self.width.is_some();
812        let keyboard_focus = focus.is_focused(window) && window.last_input_was_keyboard();
813        let carousel = div()
814            .id(id)
815            .role(Role::List)
816            .aria_label(self.label)
817            .track_focus(&focus)
818            .relative()
819            .overflow_hidden()
820            .map(|carousel| {
821                if fixed {
822                    carousel.w(rem(width))
823                } else {
824                    carousel.w_full()
825                }
826            })
827            .h(rem(self.height + 2. * pad_y))
828            .on_scroll(move |event, window, cx| {
829                let delta = event.delta.pixel_delta(px(16.));
830                // Horizontal gestures scroll; vertical wheels scroll too when
831                // they dominate, as a horizontal list has no other use.
832                let horizontal = delta.x.abs() > delta.y.abs();
833                let amount = if horizontal {
834                    -f32::from(delta.x)
835                } else {
836                    -f32::from(delta.y)
837                } / rem_size;
838                if amount == 0. || max <= 0. {
839                    return;
840                }
841                let before = wheel.read(cx).scroll;
842                let after = (before + amount).clamp(0., max);
843                let moved = after != before;
844                // A vertical wheel the carousel cannot follow (it is at that
845                // end) scrolls the page instead.
846                if horizontal || moved {
847                    cx.stop_propagation();
848                }
849                if !moved {
850                    return;
851                }
852                wheel.update(cx, |state, _| {
853                    state.animation = None;
854                    state.scroll = after;
855                });
856                window.refresh();
857                if snaps {
858                    schedule_snap(&wheel, step, max, window, cx);
859                } else {
860                    report(&wheel, after, step, window, cx);
861                }
862            })
863            .on_key_down(move |event, window, cx| {
864                let current = keys
865                    .read(cx)
866                    .animation
867                    .map_or(keys.read(cx).scroll, |a| a.to);
868                let to = match event.keystroke.key.as_str() {
869                    "right" => neighbor(current, step, max, true),
870                    "left" => neighbor(current, step, max, false),
871                    "home" => 0.,
872                    "end" => max,
873                    _ => return,
874                };
875                cx.stop_propagation();
876                let reduced = cx.reduce_motion();
877                keys.update(cx, |state, _| state.animate_to(to, reduced));
878                report(&keys, to, step, window, cx);
879                window.refresh();
880            })
881            .on_mouse_down(MouseButton::Left, move |event, window, cx| {
882                // Swipe: the pointer drags the items.
883                grab.update(cx, |state, _| {
884                    state.animation = None;
885                    state.drag = Some((event.position.x, state.scroll));
886                });
887                window.refresh();
888            })
889            .when(dragging, |carousel| {
890                let follow = state.clone();
891                carousel.cursor_grabbing().child(
892                    canvas(
893                        |_, _, _| (),
894                        move |_, _, window, _| {
895                            let moving = follow.clone();
896                            window.on_mouse_event(
897                                move |event: &MouseMoveEvent, phase, window, cx| {
898                                    if phase != DispatchPhase::Capture {
899                                        return;
900                                    }
901                                    moving.update(cx, |state, _| {
902                                        if let Some((x, from)) = state.drag {
903                                            let delta = f32::from(event.position.x - x) / rem_size;
904                                            state.scroll = (from - delta).clamp(0., max);
905                                        }
906                                    });
907                                    window.refresh();
908                                },
909                            );
910                            let dropping = follow.clone();
911                            window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
912                                if phase != DispatchPhase::Capture {
913                                    return;
914                                }
915                                let reduced = cx.reduce_motion();
916                                let to = dropping.update(cx, |state, _| {
917                                    state.drag = None;
918                                    let to = if snaps {
919                                        snap(state.scroll, step, max)
920                                    } else {
921                                        state.scroll
922                                    };
923                                    state.animate_to(to, reduced);
924                                    to
925                                });
926                                report(&dropping, to, step, window, cx);
927                                window.refresh();
928                            });
929                        },
930                    )
931                    .absolute()
932                    .size_0(),
933                )
934            })
935            .children(items)
936            .when(keyboard_focus, |carousel| {
937                // The M3 focus indicator: a 3dp secondary ring.
938                carousel.child(
939                    div()
940                        .absolute()
941                        .inset_0()
942                        .rounded(radius)
943                        .border(px(3.))
944                        .border_color(color(ColorRole::SECONDARY)),
945                )
946            })
947            .child(
948                canvas(
949                    move |bounds, window, cx| {
950                        let width = f32::from(bounds.size.width);
951                        if measure.read(cx).width != Some(width) {
952                            measure.update(cx, |state, _| state.width = Some(width));
953                            // This frame laid items out for the old width; draw
954                            // again with the new one (`refresh` is ignored while
955                            // drawing).
956                            if !fixed {
957                                window.request_animation_frame();
958                            }
959                        }
960                    },
961                    |_, _, _, _| {},
962                )
963                .absolute()
964                .top_0()
965                .left_0()
966                .size_full(),
967            );
968        state.update(cx, |state, _| state.lines = lines);
969        carousel
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use core::prelude::v1::test;
977
978    fn widths(keylines: &Keylines) -> Vec<f32> {
979        keylines
980            .lines
981            .iter()
982            .map(|line| line.width.round())
983            .collect()
984    }
985
986    #[test]
987    fn strategies_match_the_kit_on_mobile_and_tablet() {
988        // Mobile (412dp): 188, 120, 56 — the kit's multi-browse.
989        assert_eq!(
990            widths(&keylines(CarouselLayout::MultiBrowse, 412., 188.)),
991            [188., 120., 56.]
992        );
993        // Tablet (600dp): 184, 184, 120, 56.
994        assert_eq!(
995            widths(&keylines(CarouselLayout::MultiBrowse, 600., 184.)),
996            [184., 184., 120., 56.]
997        );
998        // Hero (412dp): 316 and 56; center-aligned hero 56, 252, 56.
999        assert_eq!(
1000            widths(&keylines(CarouselLayout::Hero, 412., 188.)),
1001            [316., 56.]
1002        );
1003        assert_eq!(
1004            widths(&keylines(CarouselLayout::CenteredHero, 412., 188.)),
1005            [56., 252., 56.]
1006        );
1007        let full = keylines(CarouselLayout::FullScreen, 412., 188.);
1008        assert_eq!(widths(&full), [412.]);
1009        assert_eq!(full.lines[0].x, 0.);
1010    }
1011
1012    #[test]
1013    fn items_morph_between_keylines() {
1014        let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1015        let step = start.step;
1016        // On a keyline an item takes its size.
1017        assert_eq!(place(&start.lines, step, step, 0, GAP).width, 120.);
1018        // Halfway from the medium to the small keyline it is in between.
1019        let half = place(&start.lines, step, 1.5 * step, 0, GAP);
1020        assert!((half.width - 88.).abs() < 0.01);
1021        // Leaving the leading edge it shrinks.
1022        assert!(place(&start.lines, step, -0.5 * step, 0, GAP).width < 188.);
1023    }
1024
1025    #[test]
1026    fn scrolling_snaps_to_items_within_range() {
1027        assert_eq!(snap(130., 196., 1000.), 196.);
1028        assert_eq!(snap(90., 196., 1000.), 0.);
1029        assert_eq!(snap(5000., 196., 392.), 392.);
1030        let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1031        assert_eq!(max_scroll(&start, 3, CarouselLayout::MultiBrowse, 412.), 0.);
1032        assert_eq!(
1033            max_scroll(&start, 5, CarouselLayout::MultiBrowse, 412.),
1034            2. * start.step
1035        );
1036    }
1037
1038    #[test]
1039    fn an_uncontained_carousel_reaches_its_last_item() {
1040        let layout = CarouselLayout::Uncontained;
1041        let start = keylines(layout, 412., 188.);
1042        let max = max_scroll(&start, 6, layout, 412.);
1043        // Six 188dp items 8dp apart, in 380dp between the paddings.
1044        assert_eq!(max, 6. * 196. - 8. - (412. - 2. * PAD_X));
1045        // At the end the last item meets the trailing padding.
1046        let last = place(&start.lines, start.step, 5. * start.step - max, 0, GAP);
1047        assert!((last.x + last.width - (412. - PAD_X)).abs() < 0.01);
1048        // Snapping and stepping reach that end, though it is off the grid.
1049        assert_eq!(snap(max - 1., start.step, max), max);
1050        assert_eq!(neighbor(3. * start.step, start.step, max, true), 4. * 196.);
1051        assert_eq!(neighbor(4. * start.step, start.step, max, true), max);
1052        assert_eq!(neighbor(max, start.step, max, false), 4. * 196.);
1053        // Too few items to scroll.
1054        assert_eq!(max_scroll(&start, 1, layout, 412.), 0.);
1055    }
1056
1057    #[test]
1058    fn blending_reuses_the_keyline_buffer() {
1059        let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1060        let end = end_keylines(&start, CarouselLayout::MultiBrowse);
1061        let mut lines = Vec::new();
1062        blend_into(&mut lines, &start.lines, &end.lines, 0.25);
1063        let buffer = lines.as_ptr();
1064        blend_into(&mut lines, &start.lines, &end.lines, 0.75);
1065        assert_eq!(lines.as_ptr(), buffer, "the next frame reallocated");
1066        blend_into(&mut lines, &start.lines, &end.lines, 1.);
1067        assert_eq!(lines, end.lines);
1068    }
1069
1070    #[test]
1071    fn the_end_arrangement_mirrors_the_start() {
1072        let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1073        let end = end_keylines(&start, CarouselLayout::MultiBrowse);
1074        assert_eq!(widths(&end), [56., 120., 188.]);
1075        let last = end.lines.last().unwrap();
1076        assert!((last.x + last.width - (412. - PAD_X)).abs() < 0.01);
1077    }
1078
1079    struct Harness;
1080    impl gpui::Render for Harness {
1081        fn render(&mut self, window: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1082            window.set_rem_size(px(16.));
1083            div().child(
1084                MaterialCarousel::new("carousel", "Photos")
1085                    .width(412.)
1086                    .items((0..6).map(|i| {
1087                        CarouselItem::new().content(
1088                            div()
1089                                .debug_selector(move || format!("item-{i}"))
1090                                .size(px(10.)),
1091                        )
1092                    })),
1093            )
1094        }
1095    }
1096
1097    #[gpui::test]
1098    fn arrow_keys_scroll_one_item(cx: &mut gpui::TestAppContext) {
1099        cx.update(|cx| {
1100            gpui_base::init(cx);
1101            cx.set_reduce_motion(true);
1102        });
1103        let (_, cx) = cx.add_window_view(|_, _| Harness);
1104        let draw = |cx: &mut gpui::VisualTestContext| {
1105            cx.update(|window, cx| {
1106                window.refresh();
1107                window.draw(cx).clear(cx);
1108            })
1109        };
1110        draw(cx);
1111        let before = cx.debug_bounds("item-1").unwrap();
1112        cx.update(|window, cx| window.focus_next(cx));
1113        let keystroke = cx.update(|_, cx| {
1114            gpui::Keystroke::parse_with_os("right", cx.platform_info().os).unwrap()
1115        });
1116        cx.simulate_event(gpui::KeyDownEvent {
1117            keystroke,
1118            is_held: false,
1119            prefer_character_input: false,
1120        });
1121        draw(cx);
1122        let after = cx.debug_bounds("item-1").unwrap();
1123        assert!(
1124            after.left() < before.left(),
1125            "the next item moved toward the start"
1126        );
1127        assert!(
1128            cx.debug_bounds("item-3").is_some(),
1129            "the next item scrolled in"
1130        );
1131        assert!(
1132            cx.debug_bounds("item-4").is_none(),
1133            "items beyond the keylines stay hidden"
1134        );
1135    }
1136
1137    /// A carousel inside a page that counts the scroll events reaching it.
1138    struct Page {
1139        layout: CarouselLayout,
1140        fixed: Option<f32>,
1141        page_scrolls: std::rc::Rc<std::cell::Cell<usize>>,
1142    }
1143    impl gpui::Render for Page {
1144        fn render(&mut self, window: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1145            window.set_rem_size(px(BASE_PX));
1146            let page_scrolls = self.page_scrolls.clone();
1147            let carousel = MaterialCarousel::new("carousel", "Photos")
1148                .layout(self.layout)
1149                .items((0..6).map(|i| {
1150                    CarouselItem::new().content(
1151                        div()
1152                            .debug_selector(move || format!("item-{i}"))
1153                            .size_full(),
1154                    )
1155                }));
1156            div()
1157                .id("page")
1158                .w(px(600.))
1159                .on_scroll(move |_, _, _| page_scrolls.set(page_scrolls.get() + 1))
1160                .child(match self.fixed {
1161                    Some(width) => carousel.width(width),
1162                    None => carousel,
1163                })
1164        }
1165    }
1166
1167    fn page(
1168        cx: &mut gpui::TestAppContext,
1169        layout: CarouselLayout,
1170        fixed: Option<f32>,
1171    ) -> (gpui::Entity<Page>, &mut gpui::VisualTestContext) {
1172        cx.update(|cx| {
1173            gpui_base::init(cx);
1174            cx.set_reduce_motion(true);
1175        });
1176        cx.add_window_view(move |_, _| Page {
1177            layout,
1178            fixed,
1179            page_scrolls: Default::default(),
1180        })
1181    }
1182
1183    fn redraw(cx: &mut gpui::VisualTestContext) {
1184        cx.update(|window, cx| {
1185            window.refresh();
1186            window.draw(cx).clear(cx);
1187        })
1188    }
1189
1190    #[gpui::test]
1191    fn the_end_key_brings_the_last_uncontained_item_in(cx: &mut gpui::TestAppContext) {
1192        let (_, cx) = page(cx, CarouselLayout::Uncontained, Some(412.));
1193        redraw(cx);
1194        cx.update(|window, cx| window.focus_next(cx));
1195        cx.simulate_keystrokes("end");
1196        redraw(cx);
1197        let last = cx.debug_bounds("item-5").expect("the last item is shown");
1198        assert!(
1199            (f32::from(last.right()) - (412. - PAD_X)).abs() < 0.5,
1200            "the last item ends at the trailing padding: {last:?}"
1201        );
1202    }
1203
1204    #[gpui::test]
1205    fn a_vertical_wheel_at_an_end_scrolls_the_page(cx: &mut gpui::TestAppContext) {
1206        let (view, cx) = page(cx, CarouselLayout::MultiBrowse, Some(412.));
1207        redraw(cx);
1208        let position = cx.debug_bounds("item-0").unwrap().center();
1209        let scrolls = view.read_with(cx, |page, _| page.page_scrolls.clone());
1210        let wheel = |cx: &mut gpui::VisualTestContext, x: f32, y: f32| {
1211            cx.simulate_event(gpui::ScrollEvent {
1212                position,
1213                delta: gpui::ScrollDelta::Pixels(point(px(x), px(y))),
1214                touch_phase: TouchPhase::Moved,
1215                ..Default::default()
1216            });
1217        };
1218        // At the start, scrolling up cannot move the carousel.
1219        wheel(cx, 0., 40.);
1220        assert_eq!(scrolls.get(), 1, "the page did not receive the wheel");
1221        // Scrolling down moves it, so it keeps the wheel.
1222        wheel(cx, 0., -40.);
1223        assert_eq!(scrolls.get(), 1);
1224        // A horizontal gesture always belongs to the carousel.
1225        wheel(cx, 400., 0.);
1226        wheel(cx, 400., 0.);
1227        assert_eq!(scrolls.get(), 1);
1228    }
1229
1230    #[gpui::test]
1231    fn wheel_events_share_one_snap_task(cx: &mut gpui::TestAppContext) {
1232        let (_, cx) = page(cx, CarouselLayout::MultiBrowse, Some(412.));
1233        redraw(cx);
1234        let position = cx.debug_bounds("item-0").unwrap().center();
1235        for _ in 0..5 {
1236            cx.simulate_event(gpui::ScrollEvent {
1237                position,
1238                delta: gpui::ScrollDelta::Pixels(point(px(-30.), px(0.))),
1239                touch_phase: TouchPhase::Moved,
1240                ..Default::default()
1241            });
1242        }
1243        assert_eq!(
1244            SNAP_TASKS.with(|tasks| tasks.get()),
1245            1,
1246            "every wheel event spawned a snap task"
1247        );
1248        // 150dp scrolled rests nearer the second item, which snaps to the
1249        // leading keyline.
1250        cx.executor().advance_clock(SNAP_IDLE * 2);
1251        cx.run_until_parked();
1252        redraw(cx);
1253        let second = cx.debug_bounds("item-1").unwrap();
1254        assert!((f32::from(second.left()) - PAD_X).abs() < 0.5, "{second:?}");
1255    }
1256
1257    #[gpui::test]
1258    fn a_new_measured_width_draws_again(cx: &mut gpui::TestAppContext) {
1259        let (_, cx) = page(cx, CarouselLayout::MultiBrowse, None);
1260        cx.update(|window, cx| {
1261            window.refresh();
1262            window.draw(cx).clear(cx);
1263        });
1264        let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1265        assert!(frames > 0, "the 600px width was stored without a new frame");
1266        redraw(cx);
1267        // The next frame lays out at 600dp: four large items (tablet).
1268        assert!(cx.debug_bounds("item-3").is_some());
1269        let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1270        assert_eq!(frames, 0, "an unchanged width requests nothing");
1271    }
1272
1273    /// A carousel whose current item the test controls and observes.
1274    struct Controlled {
1275        current: usize,
1276        snap: bool,
1277        reported: std::rc::Rc<std::cell::Cell<Option<usize>>>,
1278    }
1279    impl gpui::Render for Controlled {
1280        fn render(
1281            &mut self,
1282            window: &mut Window,
1283            cx: &mut gpui::Context<Self>,
1284        ) -> impl IntoElement {
1285            window.set_rem_size(px(BASE_PX));
1286            let reported = self.reported.clone();
1287            MaterialCarousel::new("controlled", "Photos")
1288                .width(412.)
1289                .gap(12.)
1290                .padding(24.)
1291                .snap(self.snap)
1292                .current(self.current)
1293                .on_change(cx.listener(move |this, item: &usize, _, cx| {
1294                    reported.set(Some(*item));
1295                    this.current = *item;
1296                    cx.notify();
1297                }))
1298                .items((0..8).map(|i| {
1299                    CarouselItem::new().content(
1300                        div()
1301                            .debug_selector(move || format!("item-{i}"))
1302                            .size_full(),
1303                    )
1304                }))
1305        }
1306    }
1307
1308    fn controlled(
1309        cx: &mut gpui::TestAppContext,
1310        current: usize,
1311        snap: bool,
1312    ) -> (gpui::Entity<Controlled>, &mut gpui::VisualTestContext) {
1313        cx.update(|cx| {
1314            gpui_base::init(cx);
1315            cx.set_reduce_motion(true);
1316        });
1317        cx.add_window_view(move |_, _| Controlled {
1318            current,
1319            snap,
1320            reported: Default::default(),
1321        })
1322    }
1323
1324    #[test]
1325    fn spacing_sets_the_padding_and_gap_of_the_keylines() {
1326        let spacing = CarouselSpacing {
1327            gap: 12.,
1328            padding: 24.,
1329        };
1330        let lines = spaced_keylines(CarouselLayout::MultiBrowse, 412., 188., spacing);
1331        assert_eq!(lines.lines[0].x, 24.);
1332        assert_eq!(lines.lines[1].x, 24. + lines.lines[0].width + 12.);
1333        assert_eq!(lines.step, 188. + 12.);
1334        // Full screen ignores both.
1335        let full = spaced_keylines(CarouselLayout::FullScreen, 412., 188., spacing);
1336        assert_eq!((full.lines[0].x, full.gap), (0., 0.));
1337    }
1338
1339    #[gpui::test]
1340    fn the_current_item_starts_and_follows_the_caller(cx: &mut gpui::TestAppContext) {
1341        let (view, cx) = controlled(cx, 2, true);
1342        redraw(cx);
1343        // Item 2 starts at the leading keyline, inside the 24dp padding.
1344        let third = cx.debug_bounds("item-2").unwrap();
1345        assert!((f32::from(third.left()) - 24.).abs() < 0.5, "{third:?}");
1346        view.update(cx, |view, cx| {
1347            view.current = 3;
1348            cx.notify();
1349        });
1350        redraw(cx);
1351        // (Item 3 is the last before the end arrangement shifts in.)
1352        let fourth = cx.debug_bounds("item-3").unwrap();
1353        assert!((f32::from(fourth.left()) - 24.).abs() < 0.5, "{fourth:?}");
1354        // The keyboard moves on and reports the new item.
1355        cx.update(|window, cx| window.focus_next(cx));
1356        cx.simulate_keystrokes("left");
1357        redraw(cx);
1358        let reported = view.read_with(cx, |view, _| view.reported.get());
1359        assert_eq!(reported, Some(2));
1360    }
1361
1362    #[gpui::test]
1363    fn dragging_swipes_and_snaps_to_the_nearest_item(cx: &mut gpui::TestAppContext) {
1364        use gpui::{Modifiers, MouseButton};
1365        let (view, cx) = controlled(cx, 0, true);
1366        redraw(cx);
1367        let start = cx.debug_bounds("item-0").unwrap().center();
1368        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
1369        redraw(cx);
1370        // 260dp left: past the middle of the second step (200dp).
1371        let to = start - point(px(260.), px(0.));
1372        cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
1373        redraw(cx);
1374        cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
1375        redraw(cx);
1376        let reported = view.read_with(cx, |view, _| view.reported.get());
1377        assert_eq!(reported, Some(1), "a 260dp drag settles on item 1");
1378        let second = cx.debug_bounds("item-1").unwrap();
1379        assert!((f32::from(second.left()) - 24.).abs() < 0.5, "{second:?}");
1380    }
1381
1382    #[gpui::test]
1383    fn without_snapping_a_drag_rests_where_released(cx: &mut gpui::TestAppContext) {
1384        use gpui::{Modifiers, MouseButton};
1385        let (_, cx) = controlled(cx, 0, false);
1386        redraw(cx);
1387        let home = cx.debug_bounds("item-1").unwrap();
1388        let start = cx.debug_bounds("item-0").unwrap().center();
1389        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
1390        let to = start - point(px(60.), px(0.));
1391        cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
1392        cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
1393        redraw(cx);
1394        let moved = cx.debug_bounds("item-1").unwrap();
1395        assert!(moved.left() < home.left(), "the drag moved the items");
1396        assert!(
1397            (f32::from(moved.left()) - 24.).abs() > 1.,
1398            "it did not snap to an item"
1399        );
1400    }
1401}