Skip to main content

armas_basic/components/
carousel.rs

1//! Carousel Component (shadcn/ui style)
2//!
3//! A scrollable content strip with prev/next navigation and snap-to-item behavior.
4//!
5//! ```rust,no_run
6//! # use egui::Ui;
7//! # fn example(ui: &mut Ui) {
8//! use armas_basic::prelude::*;
9//!
10//! let mut carousel = Carousel::new("demo");
11//! carousel.show(ui, 5, |ui, index| {
12//!     ui.label(format!("Slide {}", index + 1));
13//! });
14//! # }
15//! ```
16
17use crate::animation::SpringAnimation;
18use egui::{vec2, Id, Pos2, Rect, Sense, Stroke, Ui};
19
20// Constants
21const BUTTON_SIZE: f32 = 32.0;
22const BUTTON_RADIUS: f32 = 16.0;
23const BUTTON_ICON_SIZE: f32 = 16.0;
24const BUTTON_MARGIN: f32 = 12.0; // Space between button and content edge
25const DEFAULT_GAP: f32 = 16.0;
26const DEFAULT_HEIGHT: f32 = 200.0;
27
28/// Carousel orientation.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CarouselOrientation {
31    /// Horizontal scrolling (left/right).
32    Horizontal,
33    /// Vertical scrolling (up/down).
34    Vertical,
35}
36
37/// Carousel — a scrollable content strip with snap-to-item behavior.
38pub struct Carousel {
39    id: Id,
40    orientation: CarouselOrientation,
41    loop_mode: bool,
42    item_basis: f32,
43    gap: f32,
44    show_buttons: bool,
45    height: f32,
46}
47
48/// Response from a carousel.
49pub struct CarouselResponse {
50    /// The UI response.
51    pub response: egui::Response,
52    /// The currently active (centered) item index.
53    pub active_index: usize,
54    /// Whether the active index changed this frame.
55    pub changed: bool,
56}
57
58impl Carousel {
59    /// Create a new carousel with a unique ID.
60    pub fn new(id: impl Into<Id>) -> Self {
61        Self {
62            id: id.into(),
63            orientation: CarouselOrientation::Horizontal,
64            loop_mode: false,
65            item_basis: 1.0,
66            gap: DEFAULT_GAP,
67            show_buttons: true,
68            height: DEFAULT_HEIGHT,
69        }
70    }
71
72    /// Set the carousel orientation.
73    #[must_use]
74    pub const fn orientation(mut self, o: CarouselOrientation) -> Self {
75        self.orientation = o;
76        self
77    }
78
79    /// Enable loop mode (wraps around at ends).
80    #[must_use]
81    pub const fn loop_mode(mut self, l: bool) -> Self {
82        self.loop_mode = l;
83        self
84    }
85
86    /// Set the fraction of container width each item occupies (e.g. 0.33 = 3 visible items).
87    #[must_use]
88    pub const fn item_basis(mut self, basis: f32) -> Self {
89        self.item_basis = basis;
90        self
91    }
92
93    /// Set the gap between items in pixels.
94    #[must_use]
95    pub const fn gap(mut self, gap: f32) -> Self {
96        self.gap = gap;
97        self
98    }
99
100    /// Show or hide prev/next navigation buttons.
101    #[must_use]
102    pub const fn show_buttons(mut self, show: bool) -> Self {
103        self.show_buttons = show;
104        self
105    }
106
107    /// Set the carousel height in pixels.
108    #[must_use]
109    pub const fn height(mut self, height: f32) -> Self {
110        self.height = height;
111        self
112    }
113
114    /// Show the carousel.
115    pub fn show(
116        &mut self,
117        ui: &mut Ui,
118        item_count: usize,
119        mut content: impl FnMut(&mut Ui, usize),
120    ) -> CarouselResponse {
121        let theme = crate::ext::ArmasContextExt::armas_theme(ui.ctx());
122
123        if item_count == 0 {
124            let (_, response) = ui.allocate_exact_size(vec2(0.0, 0.0), Sense::hover());
125            return CarouselResponse {
126                response,
127                active_index: 0,
128                changed: false,
129            };
130        }
131
132        let is_horizontal = self.orientation == CarouselOrientation::Horizontal;
133        let available_width = ui.available_width();
134
135        // Reserve space for buttons outside the content area
136        let button_space = if self.show_buttons {
137            BUTTON_SIZE + BUTTON_MARGIN
138        } else {
139            0.0
140        };
141
142        // The outer rect includes buttons; the content rect is inset
143        let outer_size = vec2(available_width, self.height);
144        let (outer_rect, outer_response) = ui.allocate_exact_size(outer_size, Sense::hover());
145
146        // Content area is inset from the outer rect to leave room for buttons
147        let content_rect = if is_horizontal {
148            Rect::from_min_max(
149                Pos2::new(outer_rect.left() + button_space, outer_rect.top()),
150                Pos2::new(outer_rect.right() - button_space, outer_rect.bottom()),
151            )
152        } else {
153            Rect::from_min_max(
154                Pos2::new(outer_rect.left(), outer_rect.top() + button_space),
155                Pos2::new(outer_rect.right(), outer_rect.bottom() - button_space),
156            )
157        };
158
159        // Load state
160        let spring_id = self.id.with("spring");
161        let index_id = self.id.with("index");
162
163        let mut spring: SpringAnimation = ui.ctx().data_mut(|d| {
164            d.get_temp(spring_id)
165                .unwrap_or(SpringAnimation::new(0.0, 0.0))
166        });
167        let mut current_index: usize = ui.ctx().data_mut(|d| d.get_temp(index_id).unwrap_or(0));
168        let prev_index = current_index;
169
170        // Calculate item dimensions
171        let main_extent = if is_horizontal {
172            content_rect.width()
173        } else {
174            content_rect.height()
175        };
176        let item_extent = main_extent * self.item_basis - self.gap * (1.0 - self.item_basis);
177        let step = item_extent + self.gap;
178        let max_index = item_count.saturating_sub(1);
179
180        // Draw items with clipping to content_rect
181        for i in 0..item_count {
182            let offset = i as f32 * step - spring.value;
183
184            let item_rect = if is_horizontal {
185                Rect::from_min_size(
186                    Pos2::new(content_rect.left() + offset, content_rect.top()),
187                    vec2(item_extent, content_rect.height()),
188                )
189            } else {
190                Rect::from_min_size(
191                    Pos2::new(content_rect.left(), content_rect.top() + offset),
192                    vec2(content_rect.width(), item_extent),
193                )
194            };
195
196            // Only render visible items
197            if item_rect.intersects(content_rect) {
198                let mut child_ui = ui.new_child(
199                    egui::UiBuilder::new()
200                        .max_rect(item_rect)
201                        .layout(egui::Layout::top_down(egui::Align::LEFT)),
202                );
203                child_ui.set_clip_rect(content_rect);
204                content(&mut child_ui, i);
205            }
206        }
207
208        // Drag interaction AFTER items so it sits on top and captures drag
209        // before item widgets (like text selection) can consume it.
210        let drag_response = ui.interact(content_rect, self.id.with("drag"), Sense::drag());
211
212        if drag_response.dragged() {
213            let delta = if is_horizontal {
214                drag_response.drag_delta().x
215            } else {
216                drag_response.drag_delta().y
217            };
218            spring.value -= delta;
219            spring.velocity = 0.0;
220        }
221
222        if drag_response.drag_stopped() {
223            let raw_index = (spring.value / step).round().clamp(0.0, max_index as f32);
224            current_index = raw_index as usize;
225            spring.target = current_index as f32 * step;
226        }
227
228        // Draw buttons AFTER items so they render on top and get click priority
229        let mut prev_clicked = false;
230        let mut next_clicked = false;
231
232        if self.show_buttons {
233            let can_prev = self.loop_mode || current_index > 0;
234            let can_next = self.loop_mode || current_index < max_index;
235
236            if is_horizontal {
237                // Left button — centered vertically, to the left of content
238                if can_prev {
239                    let btn_rect = Rect::from_center_size(
240                        Pos2::new(
241                            outer_rect.left() + BUTTON_SIZE / 2.0,
242                            content_rect.center().y,
243                        ),
244                        vec2(BUTTON_SIZE, BUTTON_SIZE),
245                    );
246                    prev_clicked = self.draw_nav_button(ui, &theme, btn_rect, true);
247                }
248
249                // Right button — centered vertically, to the right of content
250                if can_next {
251                    let btn_rect = Rect::from_center_size(
252                        Pos2::new(
253                            outer_rect.right() - BUTTON_SIZE / 2.0,
254                            content_rect.center().y,
255                        ),
256                        vec2(BUTTON_SIZE, BUTTON_SIZE),
257                    );
258                    next_clicked = self.draw_nav_button(ui, &theme, btn_rect, false);
259                }
260            } else {
261                // Top button
262                if can_prev {
263                    let btn_rect = Rect::from_center_size(
264                        Pos2::new(
265                            content_rect.center().x,
266                            outer_rect.top() + BUTTON_SIZE / 2.0,
267                        ),
268                        vec2(BUTTON_SIZE, BUTTON_SIZE),
269                    );
270                    prev_clicked = self.draw_nav_button(ui, &theme, btn_rect, true);
271                }
272
273                // Bottom button
274                if can_next {
275                    let btn_rect = Rect::from_center_size(
276                        Pos2::new(
277                            content_rect.center().x,
278                            outer_rect.bottom() - BUTTON_SIZE / 2.0,
279                        ),
280                        vec2(BUTTON_SIZE, BUTTON_SIZE),
281                    );
282                    next_clicked = self.draw_nav_button(ui, &theme, btn_rect, false);
283                }
284            }
285        }
286
287        if prev_clicked {
288            if current_index > 0 {
289                current_index -= 1;
290            } else if self.loop_mode {
291                current_index = max_index;
292            }
293            spring.target = current_index as f32 * step;
294        }
295
296        if next_clicked {
297            if current_index < max_index {
298                current_index += 1;
299            } else if self.loop_mode {
300                current_index = 0;
301            }
302            spring.target = current_index as f32 * step;
303        }
304
305        // Update spring animation
306        let dt = ui.ctx().input(|i| i.unstable_dt);
307        spring.update(dt);
308
309        if !spring.is_settled(0.5, 0.5) {
310            ui.ctx().request_repaint();
311        }
312
313        let changed = current_index != prev_index;
314
315        // Save state
316        ui.ctx().data_mut(|d| {
317            d.insert_temp(spring_id, spring);
318            d.insert_temp(index_id, current_index);
319        });
320
321        CarouselResponse {
322            response: outer_response,
323            active_index: current_index,
324            changed,
325        }
326    }
327
328    fn draw_nav_button(
329        &self,
330        ui: &mut Ui,
331        theme: &crate::Theme,
332        rect: Rect,
333        is_prev: bool,
334    ) -> bool {
335        let response = ui.interact(
336            rect,
337            self.id.with(if is_prev { "prev" } else { "next" }),
338            Sense::click(),
339        );
340        let hovered = response.hovered();
341
342        // Button background — outline variant like shadcn
343        let bg = if hovered {
344            theme.accent()
345        } else {
346            theme.background()
347        };
348        let fg = if hovered {
349            theme.accent_foreground()
350        } else {
351            theme.foreground()
352        };
353
354        ui.painter().rect_filled(rect, BUTTON_RADIUS, bg);
355        ui.painter().rect_stroke(
356            rect,
357            BUTTON_RADIUS,
358            Stroke::new(1.0, theme.border()),
359            egui::epaint::StrokeKind::Inside,
360        );
361
362        // Chevron icon
363        let center = rect.center();
364        let half = BUTTON_ICON_SIZE * 0.3;
365        let is_horizontal = self.orientation == CarouselOrientation::Horizontal;
366        let stroke = Stroke::new(1.5, fg);
367
368        if is_horizontal {
369            if is_prev {
370                // Left chevron: <
371                let points = [
372                    Pos2::new(center.x + half * 0.5, center.y - half),
373                    Pos2::new(center.x - half * 0.5, center.y),
374                    Pos2::new(center.x + half * 0.5, center.y + half),
375                ];
376                ui.painter().line_segment([points[0], points[1]], stroke);
377                ui.painter().line_segment([points[1], points[2]], stroke);
378            } else {
379                // Right chevron: >
380                let points = [
381                    Pos2::new(center.x - half * 0.5, center.y - half),
382                    Pos2::new(center.x + half * 0.5, center.y),
383                    Pos2::new(center.x - half * 0.5, center.y + half),
384                ];
385                ui.painter().line_segment([points[0], points[1]], stroke);
386                ui.painter().line_segment([points[1], points[2]], stroke);
387            }
388        } else if is_prev {
389            // Up chevron: ^
390            let points = [
391                Pos2::new(center.x - half, center.y + half * 0.5),
392                Pos2::new(center.x, center.y - half * 0.5),
393                Pos2::new(center.x + half, center.y + half * 0.5),
394            ];
395            ui.painter().line_segment([points[0], points[1]], stroke);
396            ui.painter().line_segment([points[1], points[2]], stroke);
397        } else {
398            // Down chevron: v
399            let points = [
400                Pos2::new(center.x - half, center.y - half * 0.5),
401                Pos2::new(center.x, center.y + half * 0.5),
402                Pos2::new(center.x + half, center.y - half * 0.5),
403            ];
404            ui.painter().line_segment([points[0], points[1]], stroke);
405            ui.painter().line_segment([points[1], points[2]], stroke);
406        }
407
408        response.clicked()
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_carousel_creation() {
418        let carousel = Carousel::new("test");
419        assert_eq!(carousel.orientation, CarouselOrientation::Horizontal);
420        assert!(!carousel.loop_mode);
421        assert_eq!(carousel.item_basis, 1.0);
422        assert!(carousel.show_buttons);
423    }
424
425    #[test]
426    fn test_carousel_builder() {
427        let carousel = Carousel::new("test")
428            .orientation(CarouselOrientation::Vertical)
429            .loop_mode(true)
430            .item_basis(0.33)
431            .gap(8.0)
432            .show_buttons(false)
433            .height(300.0);
434        assert_eq!(carousel.orientation, CarouselOrientation::Vertical);
435        assert!(carousel.loop_mode);
436        assert_eq!(carousel.item_basis, 0.33);
437        assert_eq!(carousel.gap, 8.0);
438        assert!(!carousel.show_buttons);
439        assert_eq!(carousel.height, 300.0);
440    }
441}