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
133        .update(cx, |carousel, cx| {
134          if carousel.epoch == epoch {
135            carousel.next(cx);
136          }
137        })
138        .ok();
139    })
140    .detach();
141  }
142}
143
144impl Render for Carousel {
145  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
146    let t = theme(cx);
147    let radius = t.radius(t.default_radius);
148    let surface = t.surface().hsla();
149    let surface_hover = t.surface_hover().hsla();
150    let border = t.border().hsla();
151    let dimmed = t.dimmed().hsla();
152    let accent = t.primary().hsla();
153
154    let count = self.slides.len();
155    let content: AnyElement = match self.slides.get(self.current.min(count.saturating_sub(1))) {
156      Some(builder) => builder.clone()(window, cx),
157      None => div().into_any_element(),
158    };
159
160    let mut arrows = Vec::new();
161    for (key, icon, forward) in [
162      ("guise-carousel-prev", IconName::ChevronLeft, false),
163      ("guise-carousel-next", IconName::ChevronRight, true),
164    ] {
165      arrows.push(
166        div()
167          .id(key)
168          .flex()
169          .items_center()
170          .justify_center()
171          .w(px(28.0))
172          .h(px(28.0))
173          .rounded_full()
174          .bg(surface)
175          .border_1()
176          .border_color(border)
177          .text_color(dimmed)
178          .hover(move |s| s.bg(surface_hover))
179          .child(Icon::new(icon).size(Size::Sm))
180          .on_click(cx.listener(move |this, _ev, _window, cx| {
181            if forward {
182              this.next(cx);
183            } else {
184              this.prev(cx);
185            }
186          })),
187      );
188    }
189    let mut arrows = arrows.into_iter();
190
191    let stage = div()
192      .relative()
193      .w_full()
194      .h(px(self.height))
195      .rounded(px(radius))
196      .border_1()
197      .border_color(border)
198      .bg(surface)
199      .overflow_hidden()
200      .child(content)
201      .child(
202        div()
203          .absolute()
204          .inset_0()
205          .flex()
206          .items_center()
207          .justify_between()
208          .px(px(8.0))
209          .child(arrows.next().expect("prev arrow"))
210          .child(arrows.next().expect("next arrow")),
211      );
212
213    let mut dots = div().flex().justify_center().gap(px(6.0)).pt(px(8.0));
214    for i in 0..count {
215      let active = i == self.current;
216      dots = dots.child(
217        div()
218          .id(("guise-carousel-dot", i))
219          .w(px(if active { 18.0 } else { 8.0 }))
220          .h(px(8.0))
221          .rounded_full()
222          .bg(if active { accent } else { border })
223          .on_click(cx.listener(move |this, _ev, _window, cx| this.go_to(i, cx))),
224      );
225    }
226
227    div()
228      .flex()
229      .flex_col()
230      .w_full()
231      .child(stage)
232      .child(dots)
233      .probe("Carousel")
234  }
235}
236
237#[cfg(test)]
238mod tests {
239  use super::step;
240
241  #[test]
242  fn wrapping_steps_cycle() {
243    assert_eq!(step(0, 3, 1, true), 1);
244    assert_eq!(step(2, 3, 1, true), 0);
245    assert_eq!(step(0, 3, -1, true), 2);
246  }
247
248  #[test]
249  fn clamped_steps_stop_at_the_edges() {
250    assert_eq!(step(2, 3, 1, false), 2);
251    assert_eq!(step(0, 3, -1, false), 0);
252    assert_eq!(step(1, 3, 1, false), 2);
253  }
254
255  #[test]
256  fn empty_deck_is_safe() {
257    assert_eq!(step(0, 0, 1, true), 0);
258    assert_eq!(step(5, 0, -1, false), 0);
259  }
260}