Skip to main content

guise/
carousel.rs

1//! `Carousel` — a slide deck with arrows, dots, and optional autoplay
2//! (gpui entity).
3//!
4//! Slides are content builders re-invoked every frame (live data, same rule
5//! as Tabs panels). Emits [`CarouselEvent`] with the new slide index.
6
7use std::rc::Rc;
8use std::time::Duration;
9
10use gpui::prelude::*;
11use gpui::{div, px, AnyElement, App, Context, EventEmitter, IntoElement, Window};
12
13use crate::devtools::Probed;
14use crate::icon::{Icon, IconName};
15use crate::theme::{theme, Size};
16
17/// Emitted when the visible slide changes. Carries the slide index.
18#[derive(Debug, Clone, Copy)]
19pub struct CarouselEvent(pub usize);
20
21type SlideBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>;
22
23/// The slide index after moving by `delta` from `current`, wrapping (or
24/// clamping when `wrap` is off).
25fn step(current: usize, len: usize, delta: isize, wrap: bool) -> usize {
26    if len == 0 {
27        return 0;
28    }
29    let last = len as isize - 1;
30    let target = current as isize + delta;
31    if wrap {
32        target.rem_euclid(len as isize) as usize
33    } else {
34        target.clamp(0, last) as usize
35    }
36}
37
38/// A slide deck. Create with `cx.new(|cx| Carousel::new(cx).slide(..).slide(..))`.
39pub struct Carousel {
40    slides: Vec<SlideBuilder>,
41    current: usize,
42    wrap: bool,
43    height: f32,
44    autoplay: Option<Duration>,
45    /// Bumped on every slide change so stale autoplay timers give up.
46    epoch: usize,
47}
48
49impl EventEmitter<CarouselEvent> for Carousel {}
50
51impl Carousel {
52    pub fn new(_cx: &mut Context<Self>) -> Self {
53        Carousel {
54            slides: Vec::new(),
55            current: 0,
56            wrap: true,
57            height: 220.0,
58            autoplay: None,
59            epoch: 0,
60        }
61    }
62
63    /// Append a slide (re-invoked every frame while visible).
64    pub fn slide<E>(mut self, builder: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
65    where
66        E: IntoElement,
67    {
68        self.slides.push(Rc::new(move |window, cx| {
69            builder(window, cx).into_any_element()
70        }));
71        self
72    }
73
74    /// Stop at the ends instead of wrapping.
75    pub fn no_wrap(mut self) -> Self {
76        self.wrap = false;
77        self
78    }
79
80    pub fn height(mut self, height: f32) -> Self {
81        self.height = height.max(40.0);
82        self
83    }
84
85    /// Advance automatically on this interval (pauses forever on any manual
86    /// navigation reset — each change reschedules).
87    pub fn autoplay(mut self, every: Duration, cx: &mut Context<Self>) -> Self {
88        self.autoplay = Some(every);
89        self.schedule(cx);
90        self
91    }
92
93    pub fn current(&self) -> usize {
94        self.current
95    }
96
97    pub fn go_to(&mut self, index: usize, cx: &mut Context<Self>) {
98        if index < self.slides.len() && index != self.current {
99            self.current = index;
100            self.changed(cx);
101        }
102    }
103
104    pub fn next(&mut self, cx: &mut Context<Self>) {
105        let target = step(self.current, self.slides.len(), 1, self.wrap);
106        if target != self.current {
107            self.current = target;
108            self.changed(cx);
109        }
110    }
111
112    pub fn prev(&mut self, cx: &mut Context<Self>) {
113        let target = step(self.current, self.slides.len(), -1, self.wrap);
114        if target != self.current {
115            self.current = target;
116            self.changed(cx);
117        }
118    }
119
120    fn changed(&mut self, cx: &mut Context<Self>) {
121        cx.emit(CarouselEvent(self.current));
122        self.schedule(cx);
123        cx.notify();
124    }
125
126    fn schedule(&mut self, cx: &mut Context<Self>) {
127        let Some(every) = self.autoplay else { return };
128        self.epoch += 1;
129        let epoch = self.epoch;
130        cx.spawn(async move |this, cx| {
131            cx.background_executor().timer(every).await;
132            this.update(cx, |carousel, cx| {
133                if carousel.epoch == epoch {
134                    carousel.next(cx);
135                }
136            })
137            .ok();
138        })
139        .detach();
140    }
141}
142
143impl Render for Carousel {
144    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
145        let t = theme(cx);
146        let radius = t.radius(t.default_radius);
147        let surface = t.surface().hsla();
148        let surface_hover = t.surface_hover().hsla();
149        let border = t.border().hsla();
150        let dimmed = t.dimmed().hsla();
151        let accent = t.primary().hsla();
152
153        let count = self.slides.len();
154        let content: AnyElement = match self.slides.get(self.current.min(count.saturating_sub(1))) {
155            Some(builder) => builder.clone()(window, cx),
156            None => div().into_any_element(),
157        };
158
159        let mut arrows = Vec::new();
160        for (key, icon, forward) in [
161            ("guise-carousel-prev", IconName::ChevronLeft, false),
162            ("guise-carousel-next", IconName::ChevronRight, true),
163        ] {
164            arrows.push(
165                div()
166                    .id(key)
167                    .flex()
168                    .items_center()
169                    .justify_center()
170                    .w(px(28.0))
171                    .h(px(28.0))
172                    .rounded_full()
173                    .bg(surface)
174                    .border_1()
175                    .border_color(border)
176                    .text_color(dimmed)
177                    .hover(move |s| s.bg(surface_hover))
178                    .child(Icon::new(icon).size(Size::Sm))
179                    .on_click(cx.listener(move |this, _ev, _window, cx| {
180                        if forward {
181                            this.next(cx);
182                        } else {
183                            this.prev(cx);
184                        }
185                    })),
186            );
187        }
188        let mut arrows = arrows.into_iter();
189
190        let stage = div()
191            .relative()
192            .w_full()
193            .h(px(self.height))
194            .rounded(px(radius))
195            .border_1()
196            .border_color(border)
197            .bg(surface)
198            .overflow_hidden()
199            .child(content)
200            .child(
201                div()
202                    .absolute()
203                    .inset_0()
204                    .flex()
205                    .items_center()
206                    .justify_between()
207                    .px(px(8.0))
208                    .child(arrows.next().expect("prev arrow"))
209                    .child(arrows.next().expect("next arrow")),
210            );
211
212        let mut dots = div().flex().justify_center().gap(px(6.0)).pt(px(8.0));
213        for i in 0..count {
214            let active = i == self.current;
215            dots = dots.child(
216                div()
217                    .id(("guise-carousel-dot", i))
218                    .w(px(if active { 18.0 } else { 8.0 }))
219                    .h(px(8.0))
220                    .rounded_full()
221                    .bg(if active { accent } else { border })
222                    .on_click(cx.listener(move |this, _ev, _window, cx| this.go_to(i, cx))),
223            );
224        }
225
226        div()
227            .flex()
228            .flex_col()
229            .w_full()
230            .child(stage)
231            .child(dots)
232            .probe("Carousel")
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::step;
239
240    #[test]
241    fn wrapping_steps_cycle() {
242        assert_eq!(step(0, 3, 1, true), 1);
243        assert_eq!(step(2, 3, 1, true), 0);
244        assert_eq!(step(0, 3, -1, true), 2);
245    }
246
247    #[test]
248    fn clamped_steps_stop_at_the_edges() {
249        assert_eq!(step(2, 3, 1, false), 2);
250        assert_eq!(step(0, 3, -1, false), 0);
251        assert_eq!(step(1, 3, 1, false), 2);
252    }
253
254    #[test]
255    fn empty_deck_is_safe() {
256        assert_eq!(step(0, 0, 1, true), 0);
257        assert_eq!(step(5, 0, -1, false), 0);
258    }
259}