Skip to main content

ui/components/
carousel.rs

1//! Horizontal carousel with next/prev controls and drag-to-snap.
2//!
3//! Snap uses instant positioning on drag-release (no inertia physics).
4
5use gpui::{Context, Empty, Hsla, Render};
6
7use crate::prelude::*;
8
9/// Drag payload for carousel pointer tracking.
10#[derive(Debug)]
11struct CarouselDrag {
12    start_x: Pixels,
13}
14
15/// Horizontal carousel with next/prev navigation and drag-to-snap.
16///
17/// Create with `cx.new(|_| Carousel::new(slides))`.
18pub struct Carousel {
19    slides: Vec<(SharedString, Hsla)>,
20    active_index: usize,
21    drag_offset: Pixels,
22}
23
24impl Carousel {
25    pub fn new(slides: impl IntoIterator<Item = (impl Into<SharedString>, Hsla)>) -> Self {
26        Self {
27            slides: slides
28                .into_iter()
29                .map(|(label, color)| (label.into(), color))
30                .collect(),
31            active_index: 0,
32            drag_offset: px(0.),
33        }
34    }
35
36    pub fn active_index(&self) -> usize {
37        self.active_index
38    }
39
40    fn snap_to_nearest(&mut self, cx: &mut Context<Self>) {
41        if self.slides.is_empty() {
42            return;
43        }
44        let threshold = px(48.);
45        if self.drag_offset < -threshold && self.active_index + 1 < self.slides.len() {
46            self.active_index += 1;
47        } else if self.drag_offset > threshold && self.active_index > 0 {
48            self.active_index -= 1;
49        }
50        self.drag_offset = px(0.);
51        cx.notify();
52    }
53}
54
55impl Render for Carousel {
56    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
57        let count = self.slides.len();
58        let active = self.active_index.min(count.saturating_sub(1));
59        let drag_offset = self.drag_offset;
60        let (label, color) = self
61            .slides
62            .get(active)
63            .cloned()
64            .unwrap_or_else(|| ("No items".into(), palette::neutral(100)));
65
66        let track = div()
67            .id("carousel-track")
68            .w_full()
69            .h(px(200.))
70            .overflow_hidden()
71            .cursor_pointer()
72            .on_drag(
73                CarouselDrag {
74                    start_x: window.mouse_position().x,
75                },
76                |_, _, _, cx| cx.new(|_| Empty),
77            )
78            .on_drag_move::<CarouselDrag>(cx.listener(
79                |this, event: &gpui::DragMoveEvent<CarouselDrag>, _, cx| {
80                    let drag = event.drag(cx);
81                    this.drag_offset = event.event.position.x - drag.start_x;
82                    cx.notify();
83                },
84            ))
85            .on_drop::<CarouselDrag>(cx.listener(|this, _, _, cx| {
86                this.snap_to_nearest(cx);
87            }))
88            .child(
89                div()
90                    .w_full()
91                    .h_full()
92                    .ml(drag_offset)
93                    .flex()
94                    .items_center()
95                    .justify_center()
96                    .bg(color)
97                    .child(Headline::new(label).size(HeadlineSize::Small)),
98            );
99
100        let prev_disabled = active == 0;
101        let next_disabled = active + 1 >= count;
102
103        v_flex()
104            .id("carousel")
105            .w_full()
106            .max_w(px(480.))
107            .gap_3()
108            .child(track)
109            .child(
110                h_flex()
111                    .items_center()
112                    .justify_between()
113                    .child(
114                        // Wrapping `div` only exists so integration tests can
115                        // locate the prev button's real rendered pixel bounds
116                        // via `VisualTestContext::debug_bounds` (test-only,
117                        // no-op in release builds — mirrors the
118                        // `ActionPanel`/`Tab` `debug_selector` precedent).
119                        // `IconButton::new` already sets its own
120                        // `"ICON-{icon:?}"` debug_selector, but `Calendar`'s
121                        // prev/next buttons use the same `ChevronLeft`/
122                        // `ChevronRight` icons and both components can render
123                        // simultaneously on the gallery's Layout page, so a
124                        // distinct selector is needed to avoid ambiguity. The
125                        // wrapping div does not intercept the click: the
126                        // inner `IconButton` still owns the only click
127                        // handler.
128                        div().debug_selector(|| "CAROUSEL-PREV".into()).child(
129                            IconButton::new("carousel-prev", IconName::ChevronLeft)
130                                .disabled(prev_disabled)
131                                .when(!prev_disabled, |this| {
132                                    this.on_click(cx.listener(|this, _, _, cx| {
133                                        if this.active_index > 0 {
134                                            this.active_index -= 1;
135                                            this.drag_offset = px(0.);
136                                            cx.notify();
137                                        }
138                                    }))
139                                }),
140                        ),
141                    )
142                    .child(
143                        Label::new(format!("{} / {}", active + 1, count.max(1)))
144                            .size(LabelSize::Small)
145                            .color(Color::Muted),
146                    )
147                    .child(
148                        // See the prev button wrapper's comment above; same
149                        // rationale.
150                        div().debug_selector(|| "CAROUSEL-NEXT".into()).child(
151                            IconButton::new("carousel-next", IconName::ChevronRight)
152                                .disabled(next_disabled)
153                                .when(!next_disabled, |this| {
154                                    this.on_click(cx.listener(|this, _, _, cx| {
155                                        if this.active_index + 1 < this.slides.len() {
156                                            this.active_index += 1;
157                                            this.drag_offset = px(0.);
158                                            cx.notify();
159                                        }
160                                    }))
161                                }),
162                        ),
163                    ),
164            )
165    }
166}
167
168/// Gallery catalog entry for [`Carousel`].
169#[derive(IntoElement, RegisterComponent)]
170pub struct CarouselPreview;
171
172impl RenderOnce for CarouselPreview {
173    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
174        cx.new(|_| {
175            Carousel::new([
176                ("Slide 1", palette::primary(100)),
177                ("Slide 2", palette::success(100)),
178                ("Slide 3", palette::warning(100)),
179            ])
180        })
181    }
182}
183
184impl Component for CarouselPreview {
185    fn scope() -> ComponentScope {
186        ComponentScope::DataDisplay
187    }
188
189    fn description() -> Option<&'static str> {
190        Some("Horizontal carousel with next/prev navigation and instant drag-snap.")
191    }
192
193    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
194        CarouselPreview.render(window, cx).into_any_element().into()
195    }
196}