Skip to main content

liora_components/
carousel.rs

1use gpui::{
2    AnyElement, App, Component, Hsla, IntoElement, Pixels, RenderOnce, SharedString, Window, div,
3    prelude::*, px,
4};
5use liora_core::Config;
6use liora_icons::Icon;
7use liora_icons_lucide::IconName;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum CarouselDirection {
11    #[default]
12    Horizontal,
13    Vertical,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum CarouselIndicatorPosition {
18    #[default]
19    Inside,
20    Outside,
21    None,
22}
23
24pub struct CarouselItem {
25    title: SharedString,
26    description: Option<SharedString>,
27    accent: Option<Hsla>,
28    content: Option<AnyElement>,
29}
30
31impl CarouselItem {
32    pub fn new(title: impl Into<SharedString>) -> Self {
33        Self {
34            title: title.into(),
35            description: None,
36            accent: None,
37            content: None,
38        }
39    }
40
41    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
42        self.description = Some(description.into());
43        self
44    }
45
46    pub fn accent(mut self, color: Hsla) -> Self {
47        self.accent = Some(color);
48        self
49    }
50
51    pub fn content(mut self, content: impl IntoElement) -> Self {
52        self.content = Some(content.into_any_element());
53        self
54    }
55}
56
57pub struct Carousel {
58    items: Vec<CarouselItem>,
59    active_index: usize,
60    direction: CarouselDirection,
61    indicator_position: CarouselIndicatorPosition,
62    height: Pixels,
63    autoplay: bool,
64    interval_ms: u64,
65    show_arrows: bool,
66    pause_on_hover: bool,
67    on_change: Option<Box<dyn Fn(usize, &mut Window, &mut App) + 'static>>,
68}
69
70impl Carousel {
71    pub fn new(items: Vec<CarouselItem>) -> Self {
72        Self {
73            items,
74            active_index: 0,
75            direction: CarouselDirection::Horizontal,
76            indicator_position: CarouselIndicatorPosition::Inside,
77            height: px(220.0),
78            autoplay: false,
79            interval_ms: 3000,
80            show_arrows: true,
81            pause_on_hover: true,
82            on_change: None,
83        }
84    }
85
86    pub fn active_index(mut self, index: usize) -> Self {
87        self.active_index = index;
88        self
89    }
90    pub fn direction(mut self, direction: CarouselDirection) -> Self {
91        self.direction = direction;
92        self
93    }
94    pub fn vertical(self) -> Self {
95        self.direction(CarouselDirection::Vertical)
96    }
97    pub fn horizontal(self) -> Self {
98        self.direction(CarouselDirection::Horizontal)
99    }
100    pub fn indicator_position(mut self, position: CarouselIndicatorPosition) -> Self {
101        self.indicator_position = position;
102        self
103    }
104    pub fn indicators_outside(self) -> Self {
105        self.indicator_position(CarouselIndicatorPosition::Outside)
106    }
107    pub fn hide_indicators(self) -> Self {
108        self.indicator_position(CarouselIndicatorPosition::None)
109    }
110    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
111        self.height = height.into();
112        self
113    }
114    pub fn autoplay(mut self, enabled: bool) -> Self {
115        self.autoplay = enabled;
116        self
117    }
118    pub fn interval_ms(mut self, ms: u64) -> Self {
119        self.interval_ms = ms.max(250);
120        self
121    }
122    pub fn show_arrows(mut self, show: bool) -> Self {
123        self.show_arrows = show;
124        self
125    }
126    pub fn pause_on_hover(mut self, pause: bool) -> Self {
127        self.pause_on_hover = pause;
128        self
129    }
130    pub fn on_change(mut self, cb: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
131        self.on_change = Some(Box::new(cb));
132        self
133    }
134    pub fn item_count(&self) -> usize {
135        self.items.len()
136    }
137    pub fn resolved_active_index(&self) -> Option<usize> {
138        (!self.items.is_empty()).then(|| self.active_index.min(self.items.len() - 1))
139    }
140    pub fn next_index(&self) -> Option<usize> {
141        self.resolved_active_index()
142            .map(|idx| (idx + 1) % self.items.len())
143    }
144    pub fn previous_index(&self) -> Option<usize> {
145        self.resolved_active_index().map(|idx| {
146            if idx == 0 {
147                self.items.len() - 1
148            } else {
149                idx - 1
150            }
151        })
152    }
153}
154
155impl RenderOnce for Carousel {
156    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
157        let theme = cx.global::<Config>().theme.clone();
158        let active_index = self.resolved_active_index();
159        let count = self.items.len();
160        let mut items = self.items;
161        let active_item =
162            active_index.and_then(|idx| (idx < items.len()).then(|| items.remove(idx)));
163        let accent = active_item
164            .as_ref()
165            .and_then(|item| item.accent)
166            .unwrap_or(theme.primary.base);
167        let empty = active_item.is_none();
168        let mut frame = div()
169            .id(liora_core::unique_id("carousel"))
170            .relative()
171            .overflow_hidden()
172            .rounded_lg()
173            .border_1()
174            .border_color(theme.neutral.border)
175            .bg(theme.neutral.card)
176            .h(self.height)
177            .w_full();
178
179        frame = frame.child(
180            div()
181                .absolute()
182                .top_0()
183                .left_0()
184                .right_0()
185                .bottom_0()
186                .bg(accent.opacity(0.12)),
187        );
188
189        if let Some(item) = active_item {
190            frame = frame.child(
191                div()
192                    .relative()
193                    .size_full()
194                    .flex()
195                    .flex_col()
196                    .justify_center()
197                    .gap_3()
198                    .p_6()
199                    .text_color(theme.neutral.text_1)
200                    .when_some(item.content, |s, content| s.child(content))
201                    .child(
202                        div()
203                            .text_size(px(30.0))
204                            .font_weight(gpui::FontWeight::BOLD)
205                            .child(item.title),
206                    )
207                    .when_some(item.description, |s, description| {
208                        s.child(
209                            div()
210                                .max_w(px(560.0))
211                                .text_size(px(15.0))
212                                .text_color(theme.neutral.text_2)
213                                .child(description),
214                        )
215                    }),
216            );
217        } else {
218            frame = frame.child(
219                div()
220                    .relative()
221                    .size_full()
222                    .flex()
223                    .items_center()
224                    .justify_center()
225                    .text_color(theme.neutral.text_3)
226                    .child("No carousel items"),
227            );
228        }
229
230        if self.show_arrows && count > 1 {
231            let arrow = |icon| {
232                div()
233                    .w(px(34.0))
234                    .h(px(34.0))
235                    .rounded_full()
236                    .bg(theme.neutral.card.opacity(0.82))
237                    .border_1()
238                    .border_color(theme.neutral.border)
239                    .shadow_sm()
240                    .flex()
241                    .items_center()
242                    .justify_center()
243                    .cursor_pointer()
244                    .hover(|s| s.bg(theme.neutral.hover))
245                    .child(Icon::new(icon).size(px(18.0)).color(theme.neutral.text_1))
246            };
247            frame = frame
248                .child(
249                    div()
250                        .absolute()
251                        .left(px(14.0))
252                        .top_1_2()
253                        .child(arrow(IconName::ChevronLeft)),
254                )
255                .child(
256                    div()
257                        .absolute()
258                        .right(px(14.0))
259                        .top_1_2()
260                        .child(arrow(IconName::ChevronRight)),
261                );
262        }
263
264        let make_dots = || {
265            div()
266                .flex()
267                .items_center()
268                .justify_center()
269                .gap_2()
270                .children((0..count).map(|idx| {
271                    let active_dot = Some(idx) == active_index;
272                    div()
273                        .w(if active_dot { px(22.0) } else { px(7.0) })
274                        .h(px(7.0))
275                        .rounded_full()
276                        .bg(if active_dot {
277                            accent
278                        } else {
279                            theme.neutral.border
280                        })
281                        .into_any_element()
282                }))
283        };
284
285        let caption = if self.autoplay {
286            format!(
287                "auto {}ms · {:?} · pause_on_hover={}",
288                self.interval_ms, self.direction, self.pause_on_hover
289            )
290        } else {
291            format!("manual · {:?}", self.direction)
292        };
293
294        let mut body = div().flex().flex_col().gap_2().child(frame);
295        if !empty && self.indicator_position == CarouselIndicatorPosition::Outside {
296            body = body.child(make_dots());
297        }
298        if !empty && self.indicator_position == CarouselIndicatorPosition::Inside {
299            body = body.child(div().mt(px(-34.0)).pb_3().relative().child(make_dots()));
300        }
301        body.child(
302            div()
303                .text_xs()
304                .text_color(theme.neutral.text_3)
305                .child(caption),
306        )
307    }
308}
309
310impl IntoElement for Carousel {
311    type Element = Component<Self>;
312    fn into_element(self) -> Self::Element {
313        Component::new(self)
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use gpui::rgb;
321
322    fn items() -> Vec<CarouselItem> {
323        vec![
324            CarouselItem::new("A"),
325            CarouselItem::new("B"),
326            CarouselItem::new("C").accent(rgb(0x16a34a).into()),
327        ]
328    }
329
330    #[test]
331    fn carousel_wraps_next_and_previous_indices() {
332        let carousel = Carousel::new(items()).active_index(2);
333        assert_eq!(carousel.resolved_active_index(), Some(2));
334        assert_eq!(carousel.next_index(), Some(0));
335        assert_eq!(carousel.previous_index(), Some(1));
336    }
337
338    #[test]
339    fn carousel_tracks_display_options() {
340        let carousel = Carousel::new(items())
341            .vertical()
342            .indicators_outside()
343            .autoplay(true)
344            .interval_ms(1200)
345            .pause_on_hover(false);
346        assert_eq!(carousel.direction, CarouselDirection::Vertical);
347        assert_eq!(
348            carousel.indicator_position,
349            CarouselIndicatorPosition::Outside
350        );
351        assert!(carousel.autoplay);
352        assert_eq!(carousel.interval_ms, 1200);
353        assert!(!carousel.pause_on_hover);
354    }
355}