egui_material3/
carousel.rs1use crate::get_global_color;
2use egui::{self, FontId, Pos2, Rect, Response, Sense, Ui, Vec2};
3use egui::epaint::CornerRadius;
4
5#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
21pub struct MaterialCarousel<'a> {
22 items: Vec<CarouselItem<'a>>,
24 item_extent: f32,
26 shrink_extent: f32,
28 height: f32,
30 padding: f32,
32 corner_radius: f32,
34 item_snapping: bool,
36 scroll_offset: &'a mut f32,
38 id_salt: Option<String>,
40}
41
42pub struct CarouselItem<'a> {
44 content: Box<dyn FnOnce(&mut Ui, Rect) + 'a>,
45}
46
47impl<'a> MaterialCarousel<'a> {
48 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 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 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 pub fn item_extent(mut self, extent: f32) -> Self {
94 self.item_extent = extent;
95 self
96 }
97
98 pub fn shrink_extent(mut self, extent: f32) -> Self {
100 self.shrink_extent = extent;
101 self
102 }
103
104 pub fn height(mut self, height: f32) -> Self {
106 self.height = height;
107 self
108 }
109
110 pub fn padding(mut self, padding: f32) -> Self {
112 self.padding = padding;
113 self
114 }
115
116 pub fn corner_radius(mut self, radius: f32) -> Self {
118 self.corner_radius = radius;
119 self
120 }
121
122 pub fn item_snapping(mut self, snapping: bool) -> Self {
124 self.item_snapping = snapping;
125 self
126 }
127
128 pub fn id_salt(mut self, salt: impl Into<String>) -> Self {
130 self.id_salt = Some(salt.into());
131 self
132 }
133
134 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 let left_clip = (viewport_left - item_left).max(0.0);
144 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 let clip_ratio = (total_clip / full).min(1.0);
154 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 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 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 let mut scroll_delta = ui.input(|i| {
190 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 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 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 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 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 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 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 let screen_x = item_content_left - scroll + outer_rect.left();
258
259 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 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 painter.rect_filled(clipped_rect, rounding, surface_color);
283
284 painter.rect_stroke(
286 clipped_rect,
287 rounding,
288 egui::Stroke::new(1.0, outline_color),
289 egui::epaint::StrokeKind::Outside,
290 );
291
292 if let Some(item) = items_vec[i].take() {
294 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
309pub fn carousel<'a>(scroll_offset: &'a mut f32) -> MaterialCarousel<'a> {
324 MaterialCarousel::new(scroll_offset)
325}