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}