1use crate::animation::SpringAnimation;
18use egui::{vec2, Id, Pos2, Rect, Sense, Stroke, Ui};
19
20const BUTTON_SIZE: f32 = 32.0;
22const BUTTON_RADIUS: f32 = 16.0;
23const BUTTON_ICON_SIZE: f32 = 16.0;
24const BUTTON_MARGIN: f32 = 12.0; const DEFAULT_GAP: f32 = 16.0;
26const DEFAULT_HEIGHT: f32 = 200.0;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CarouselOrientation {
31 Horizontal,
33 Vertical,
35}
36
37pub 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
48pub struct CarouselResponse {
50 pub response: egui::Response,
52 pub active_index: usize,
54 pub changed: bool,
56}
57
58impl Carousel {
59 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 #[must_use]
74 pub const fn orientation(mut self, o: CarouselOrientation) -> Self {
75 self.orientation = o;
76 self
77 }
78
79 #[must_use]
81 pub const fn loop_mode(mut self, l: bool) -> Self {
82 self.loop_mode = l;
83 self
84 }
85
86 #[must_use]
88 pub const fn item_basis(mut self, basis: f32) -> Self {
89 self.item_basis = basis;
90 self
91 }
92
93 #[must_use]
95 pub const fn gap(mut self, gap: f32) -> Self {
96 self.gap = gap;
97 self
98 }
99
100 #[must_use]
102 pub const fn show_buttons(mut self, show: bool) -> Self {
103 self.show_buttons = show;
104 self
105 }
106
107 #[must_use]
109 pub const fn height(mut self, height: f32) -> Self {
110 self.height = height;
111 self
112 }
113
114 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 let button_space = if self.show_buttons {
137 BUTTON_SIZE + BUTTON_MARGIN
138 } else {
139 0.0
140 };
141
142 let outer_size = vec2(available_width, self.height);
144 let (outer_rect, outer_response) = ui.allocate_exact_size(outer_size, Sense::hover());
145
146 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}