Skip to main content

egui_material3/
carousel.rs

1use crate::get_global_color;
2use egui::{self, FontId, Pos2, Rect, Response, Sense, Ui, Vec2};
3use egui::epaint::CornerRadius;
4
5/// A Material Design 3 Carousel component.
6///
7/// Carousels display a horizontally scrollable list of items where edge items
8/// compress to a smaller size, creating a peek effect.
9///
10/// # Example
11/// ```rust,no_run
12/// # egui::__run_test_ui(|ui| {
13/// let mut offset = 0.0f32;
14/// ui.add(MaterialCarousel::new(&mut offset)
15///     .item_text("Item 0")
16///     .item_text("Item 1")
17///     .item_text("Item 2"));
18/// # });
19/// ```
20#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
21pub struct MaterialCarousel<'a> {
22    /// Items to display in the carousel
23    items: Vec<CarouselItem<'a>>,
24    /// Width of each item at full size
25    item_extent: f32,
26    /// Minimum width for edge items (shrink size)
27    shrink_extent: f32,
28    /// Height of the carousel
29    height: f32,
30    /// Padding around each item
31    padding: f32,
32    /// Corner radius for item shapes
33    corner_radius: f32,
34    /// Whether items snap to boundaries when scrolling stops
35    item_snapping: bool,
36    /// Persistent scroll offset state
37    scroll_offset: &'a mut f32,
38    /// Optional salt for unique widget IDs
39    id_salt: Option<String>,
40}
41
42/// A single item in a carousel.
43pub struct CarouselItem<'a> {
44    content: Box<dyn FnOnce(&mut Ui, Rect) + 'a>,
45}
46
47impl<'a> MaterialCarousel<'a> {
48    /// Create a new carousel widget.
49    ///
50    /// # Arguments
51    /// * `scroll_offset` - Mutable reference to persistent scroll state
52    pub fn new(scroll_offset: &'a mut f32) -> Self {
53        Self {
54            items: Vec::new(),
55            item_extent: 180.0,
56            shrink_extent: 100.0,
57            height: 150.0,
58            padding: 4.0,
59            corner_radius: 10.0,
60            item_snapping: false,
61            scroll_offset,
62            id_salt: None,
63        }
64    }
65
66    /// Add a custom item with a rendering closure.
67    ///
68    /// The closure receives `(&mut Ui, Rect)` where `Rect` is the available area.
69    pub fn item(mut self, content: impl FnOnce(&mut Ui, Rect) + 'a) -> Self {
70        self.items.push(CarouselItem {
71            content: Box::new(content),
72        });
73        self
74    }
75
76    /// Add a simple text-label item.
77    pub fn item_text(self, label: impl Into<String>) -> Self {
78        let label = label.into();
79        self.item(move |ui, rect| {
80            let on_surface = get_global_color("onSurface");
81            let center = rect.center();
82            ui.painter().text(
83                center,
84                egui::Align2::CENTER_CENTER,
85                &label,
86                FontId::proportional(14.0),
87                on_surface,
88            );
89        })
90    }
91
92    /// Set the full-size width of each item (default: 180.0).
93    pub fn item_extent(mut self, extent: f32) -> Self {
94        self.item_extent = extent;
95        self
96    }
97
98    /// Set the minimum width for edge items (default: 100.0).
99    pub fn shrink_extent(mut self, extent: f32) -> Self {
100        self.shrink_extent = extent;
101        self
102    }
103
104    /// Set the height of the carousel (default: 150.0).
105    pub fn height(mut self, height: f32) -> Self {
106        self.height = height;
107        self
108    }
109
110    /// Set the padding around each item (default: 4.0).
111    pub fn padding(mut self, padding: f32) -> Self {
112        self.padding = padding;
113        self
114    }
115
116    /// Set the corner radius for item shapes (default: 10.0).
117    pub fn corner_radius(mut self, radius: f32) -> Self {
118        self.corner_radius = radius;
119        self
120    }
121
122    /// Enable or disable item snapping (default: false).
123    pub fn item_snapping(mut self, snapping: bool) -> Self {
124        self.item_snapping = snapping;
125        self
126    }
127
128    /// Set an ID salt for unique widget identification.
129    pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
130        self.id_salt = Some(salt.into());
131        self
132    }
133
134    /// Compute the width of an item based on its position relative to viewport edges.
135    ///
136    /// Items fully within the viewport get `item_extent`.
137    /// Items partially outside shrink towards `shrink_extent`.
138    fn compute_item_width(&self, item_left: f32, item_right: f32, viewport_left: f32, viewport_right: f32) -> f32 {
139        let full = self.item_extent;
140        let min = self.shrink_extent;
141
142        // How much of the item is clipped on the left
143        let left_clip = (viewport_left - item_left).max(0.0);
144        // How much of the item is clipped on the right
145        let right_clip = (item_right - viewport_right).max(0.0);
146
147        let total_clip = left_clip + right_clip;
148        if total_clip <= 0.0 {
149            return full;
150        }
151
152        // Proportion clipped
153        let clip_ratio = (total_clip / full).min(1.0);
154        // Lerp from full to min
155        let width = full - (full - min) * clip_ratio;
156        width.max(min)
157    }
158}
159
160impl<'a> egui::Widget for MaterialCarousel<'a> {
161    fn ui(self, ui: &mut Ui) -> Response {
162        let id_salt = self.id_salt.as_deref().unwrap_or("material_carousel");
163        let _id = ui.make_persistent_id(id_salt);
164
165        let available_width = ui.available_width();
166        let desired_size = Vec2::new(available_width, self.height);
167
168        let (outer_rect, response) = ui.allocate_exact_size(desired_size, Sense::click_and_drag());
169
170        if !ui.is_rect_visible(outer_rect) {
171            return response;
172        }
173
174        // Theme colors
175        let outline_color = get_global_color("outline");
176        let surface_color = get_global_color("surface");
177
178        let item_count = self.items.len();
179        if item_count == 0 {
180            return response;
181        }
182
183        // Total content width (all items at full extent + padding)
184        let item_step = self.item_extent + self.padding * 2.0;
185        let total_content_width = item_step * item_count as f32;
186        let max_scroll = (total_content_width - available_width).max(0.0);
187
188        // Handle scroll input (wheel + drag)
189        let mut scroll_delta = ui.input(|i| {
190            // Horizontal scroll or shift+vertical scroll
191            let mut delta = 0.0;
192            if let Some(hover_pos) = i.pointer.hover_pos() {
193                if outer_rect.contains(hover_pos) {
194                    delta -= i.smooth_scroll_delta.y;
195                    delta -= i.smooth_scroll_delta.x;
196                }
197            }
198            delta
199        });
200
201        // Add drag input (reverse direction for natural scrolling)
202        if response.dragged() {
203            scroll_delta -= response.drag_delta().x;
204        }
205
206        *self.scroll_offset = (*self.scroll_offset + scroll_delta).clamp(0.0, max_scroll);
207
208        // Item snapping: animate towards nearest item boundary
209        if self.item_snapping && scroll_delta == 0.0 {
210            let nearest_item = (*self.scroll_offset / item_step).round();
211            let target = (nearest_item * item_step).clamp(0.0, max_scroll);
212            let diff = target - *self.scroll_offset;
213            if diff.abs() > 0.5 {
214                *self.scroll_offset += diff * 0.15;
215                ui.ctx().request_repaint();
216            } else {
217                *self.scroll_offset = target;
218            }
219        }
220
221        let scroll = *self.scroll_offset;
222        let viewport_left = scroll;
223        let viewport_right = scroll + available_width;
224        let painter = ui.painter_at(outer_rect);
225
226        // Store values before moving self.items
227        let item_extent = self.item_extent;
228        let shrink_extent = self.shrink_extent;
229        let padding = self.padding;
230        let height = self.height;
231        let corner_radius = self.corner_radius;
232
233        // Determine visible item range
234        let first_visible = ((scroll / item_step).floor() as i32).max(0) as usize;
235        let last_visible = (((scroll + available_width) / item_step).ceil() as usize).min(item_count);
236
237        // We need to consume items, so iterate with index tracking
238        let mut items_vec: Vec<Option<CarouselItem<'a>>> = self.items.into_iter().map(Some).collect();
239
240        for i in first_visible..last_visible {
241            let item_content_left = i as f32 * item_step + padding;
242            let item_content_right = item_content_left + item_extent;
243
244            // Compute compressed width based on edge proximity
245            let left_clip_calc = (viewport_left - item_content_left).max(0.0);
246            let right_clip_calc = (item_content_right - viewport_right).max(0.0);
247            let total_clip = left_clip_calc + right_clip_calc;
248            let display_width = if total_clip <= 0.0 {
249                item_extent
250            } else {
251                let clip_ratio = (total_clip / item_extent).min(1.0);
252                let width = item_extent - (item_extent - shrink_extent) * clip_ratio;
253                width.max(shrink_extent)
254            };
255
256            // Position relative to the outer_rect
257            let screen_x = item_content_left - scroll + outer_rect.left();
258
259            // Adjust position for clipped items:
260            // If item is being clipped on the left, shift it right
261            let left_clip = (viewport_left - item_content_left).max(0.0);
262            let adjusted_x = if left_clip > 0.0 {
263                outer_rect.left()
264            } else {
265                screen_x
266            };
267
268            let item_rect = Rect::from_min_size(
269                Pos2::new(adjusted_x, outer_rect.top() + padding),
270                Vec2::new(display_width, height - padding * 2.0),
271            );
272
273            // Clip to the outer rect
274            let clipped_rect = item_rect.intersect(outer_rect);
275            if clipped_rect.width() <= 0.0 || clipped_rect.height() <= 0.0 {
276                continue;
277            }
278
279            let rounding = CornerRadius::same(corner_radius as u8);
280
281            // Draw item background
282            painter.rect_filled(clipped_rect, rounding, surface_color);
283
284            // Draw item border
285            painter.rect_stroke(
286                clipped_rect,
287                rounding,
288                egui::Stroke::new(1.0, outline_color),
289                egui::epaint::StrokeKind::Outside,
290            );
291
292            // Render content
293            if let Some(item) = items_vec[i].take() {
294                // Create a child UI clipped to the item rect
295                let mut child_ui = ui.new_child(
296                    egui::UiBuilder::new()
297                        .max_rect(clipped_rect)
298                        .layout(egui::Layout::centered_and_justified(egui::Direction::TopDown)),
299                );
300                child_ui.set_clip_rect(clipped_rect);
301                (item.content)(&mut child_ui, clipped_rect);
302            }
303        }
304
305        response
306    }
307}
308
309/// Create a new carousel widget.
310///
311/// # Arguments
312/// * `scroll_offset` - Mutable reference to persistent scroll state
313///
314/// # Example
315/// ```rust,no_run
316/// # egui::__run_test_ui(|ui| {
317/// let mut offset = 0.0f32;
318/// ui.add(carousel(&mut offset)
319///     .item_text("First")
320///     .item_text("Second"));
321/// # });
322/// ```
323pub fn carousel<'a>(scroll_offset: &'a mut f32) -> MaterialCarousel<'a> {
324    MaterialCarousel::new(scroll_offset)
325}