Skip to main content

armas_basic/components/
accordion.rs

1//! Accordion Component
2//!
3//! Collapsible content sections styled like shadcn/ui Accordion.
4//! Supports single or multiple open items with smooth spring animations.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Ui;
10//! # fn example(ui: &mut Ui) {
11//! use armas_basic::components::Accordion;
12//!
13//! Accordion::new("my_accordion", vec!["Section 1", "Section 2"])
14//!     .allow_multiple(true)
15//!     .show(ui, |ui, idx| {
16//!         ui.label(format!("Content for section {}", idx));
17//!     });
18//! # }
19//! ```
20
21use super::content::ContentContext;
22use crate::animation::SpringAnimation;
23use crate::ext::ArmasContextExt;
24use crate::Theme;
25use egui::{Pos2, Ui, Vec2};
26
27// shadcn Accordion constants
28const TRIGGER_PADDING_Y: f32 = 16.0; // py-4
29const CONTENT_PADDING_BOTTOM: f32 = 16.0; // pb-4
30const CHEVRON_SIZE: f32 = 16.0; // h-4 w-4
31
32/// Response from showing an accordion
33pub struct AccordionResponse {
34    /// The UI response
35    pub response: egui::Response,
36    /// Index of section that was clicked (if any)
37    pub clicked: Option<usize>,
38    /// Indices of currently open sections
39    pub open: Vec<usize>,
40}
41
42/// Accordion with collapsible sections
43pub struct Accordion {
44    id: egui::Id,
45    titles: Vec<String>,
46    allow_multiple: bool,
47}
48
49impl Accordion {
50    /// Create a new accordion with the given ID and section titles
51    pub fn new(id: impl Into<egui::Id>, titles: Vec<impl Into<String>>) -> Self {
52        Self {
53            id: id.into(),
54            titles: titles.into_iter().map(std::convert::Into::into).collect(),
55            allow_multiple: false,
56        }
57    }
58
59    /// Allow multiple sections to be open simultaneously
60    #[must_use]
61    pub const fn allow_multiple(mut self, allow: bool) -> Self {
62        self.allow_multiple = allow;
63        self
64    }
65
66    /// Show the accordion
67    pub fn show(
68        self,
69        ui: &mut Ui,
70        mut content_fn: impl FnMut(&mut Ui, usize),
71    ) -> AccordionResponse {
72        let theme = ui.ctx().armas_theme();
73        let dt = ui.input(|i| i.stable_dt);
74
75        // Load state
76        let state_id = self.id.with("accordion_state");
77        let mut open_indices: Vec<usize> = ui
78            .ctx()
79            .data_mut(|d| d.get_temp(state_id).unwrap_or_default());
80
81        // Load spring animations for each item
82        let mut springs: Vec<SpringAnimation> = ui.ctx().data_mut(|d| {
83            d.get_temp(self.id.with("accordion_springs"))
84                .unwrap_or_else(|| {
85                    self.titles
86                        .iter()
87                        .map(|_| SpringAnimation::new(0.0, 0.0).params(180.0, 22.0))
88                        .collect()
89                })
90        });
91
92        // Ensure springs vec matches titles length
93        while springs.len() < self.titles.len() {
94            springs.push(SpringAnimation::new(0.0, 0.0).params(180.0, 22.0));
95        }
96
97        let mut clicked = None;
98        let mut needs_repaint = false;
99
100        for (idx, title) in self.titles.iter().enumerate() {
101            let is_open = open_indices.contains(&idx);
102
103            // Trigger
104            let trigger_clicked = self.show_trigger(ui, title, springs[idx].value, &theme);
105
106            if trigger_clicked {
107                clicked = Some(idx);
108                if is_open {
109                    open_indices.retain(|&i| i != idx);
110                } else {
111                    if !self.allow_multiple {
112                        open_indices.clear();
113                    }
114                    open_indices.push(idx);
115                }
116            }
117
118            // Update spring target and simulate
119            let target = if open_indices.contains(&idx) {
120                1.0
121            } else {
122                0.0
123            };
124            springs[idx].set_target(target);
125            springs[idx].update(dt);
126
127            // Check if still animating (use looser threshold for smooth closing)
128            let is_animating = !springs[idx].is_settled(0.005, 0.1);
129            if is_animating {
130                needs_repaint = true;
131            }
132
133            // Content - show while animating OR while open
134            let anim_value = springs[idx].value.clamp(0.0, 1.0);
135            let should_show = anim_value > 0.001 || is_animating;
136            if should_show && anim_value > 0.0 {
137                let content_id = self.id.with(("content_height", idx));
138                let stored_height: f32 = ui
139                    .ctx()
140                    .data_mut(|d| d.get_temp(content_id).unwrap_or(50.0));
141
142                let animated_height = (stored_height + CONTENT_PADDING_BOTTOM) * anim_value;
143
144                let response = egui::Frame::new().show(ui, |ui| {
145                    ui.set_max_height(animated_height);
146                    ui.set_clip_rect(ui.max_rect());
147
148                    content_fn(ui, idx);
149                    ui.add_space(CONTENT_PADDING_BOTTOM);
150
151                    ui.min_rect().height()
152                });
153
154                let actual_height = response.inner / anim_value.max(0.01);
155                ui.ctx()
156                    .data_mut(|d| d.insert_temp(content_id, actual_height));
157            }
158
159            // Bottom border
160            let rect = ui.available_rect_before_wrap();
161            ui.painter().hline(
162                rect.x_range(),
163                rect.top(),
164                egui::Stroke::new(1.0, theme.border()),
165            );
166            ui.allocate_space(Vec2::new(0.0, 1.0));
167        }
168
169        if needs_repaint {
170            ui.ctx().request_repaint();
171        }
172
173        // Save state
174        ui.ctx().data_mut(|d| {
175            d.insert_temp(state_id, open_indices.clone());
176            d.insert_temp(self.id.with("accordion_springs"), springs);
177        });
178
179        let response = ui.interact(
180            ui.min_rect(),
181            self.id.with("response"),
182            egui::Sense::hover(),
183        );
184
185        AccordionResponse {
186            response,
187            clicked,
188            open: open_indices,
189        }
190    }
191
192    /// Show the accordion with custom trigger content for each section.
193    ///
194    /// The trigger closure receives the section index, a `&mut Ui`, and a
195    /// [`ContentContext`] with state-dependent color, font size, and active state.
196    /// The content closure receives `&mut Ui` and the section index.
197    ///
198    /// # Example
199    ///
200    /// ```ignore
201    /// Accordion::new("my_accordion", Vec::<String>::new())
202    ///     .allow_multiple(true)
203    ///     .show_ui(ui, 2, |idx, ui, ctx| {
204    ///         let titles = ["Section 1", "Section 2"];
205    ///         ui.label(titles[idx]);
206    ///     }, |ui, idx| {
207    ///         ui.label(format!("Content for section {}", idx));
208    ///     });
209    /// ```
210    pub fn show_ui(
211        self,
212        ui: &mut Ui,
213        count: usize,
214        mut render_trigger: impl FnMut(usize, &mut Ui, &ContentContext),
215        mut content_fn: impl FnMut(&mut Ui, usize),
216    ) -> AccordionResponse {
217        let theme = ui.ctx().armas_theme();
218        let dt = ui.input(|i| i.stable_dt);
219
220        let state_id = self.id.with("accordion_state");
221        let mut open_indices: Vec<usize> = ui
222            .ctx()
223            .data_mut(|d| d.get_temp(state_id).unwrap_or_default());
224
225        let mut springs: Vec<SpringAnimation> = ui.ctx().data_mut(|d| {
226            d.get_temp(self.id.with("accordion_springs"))
227                .unwrap_or_else(|| {
228                    (0..count)
229                        .map(|_| SpringAnimation::new(0.0, 0.0).params(180.0, 22.0))
230                        .collect()
231                })
232        });
233
234        while springs.len() < count {
235            springs.push(SpringAnimation::new(0.0, 0.0).params(180.0, 22.0));
236        }
237
238        let mut clicked = None;
239        let mut needs_repaint = false;
240
241        #[allow(clippy::needless_range_loop)]
242        for idx in 0..count {
243            let is_open = open_indices.contains(&idx);
244
245            let trigger_clicked = self.show_trigger_ui(
246                ui,
247                idx,
248                is_open,
249                springs[idx].value,
250                &theme,
251                &mut render_trigger,
252            );
253
254            if trigger_clicked {
255                clicked = Some(idx);
256                if is_open {
257                    open_indices.retain(|&i| i != idx);
258                } else {
259                    if !self.allow_multiple {
260                        open_indices.clear();
261                    }
262                    open_indices.push(idx);
263                }
264            }
265
266            let target = if open_indices.contains(&idx) {
267                1.0
268            } else {
269                0.0
270            };
271            springs[idx].set_target(target);
272            springs[idx].update(dt);
273
274            let is_animating = !springs[idx].is_settled(0.005, 0.1);
275            if is_animating {
276                needs_repaint = true;
277            }
278
279            let anim_value = springs[idx].value.clamp(0.0, 1.0);
280            let should_show = anim_value > 0.001 || is_animating;
281            if should_show && anim_value > 0.0 {
282                let content_id = self.id.with(("content_height", idx));
283                let stored_height: f32 = ui
284                    .ctx()
285                    .data_mut(|d| d.get_temp(content_id).unwrap_or(50.0));
286
287                let animated_height = (stored_height + CONTENT_PADDING_BOTTOM) * anim_value;
288
289                let response = egui::Frame::new().show(ui, |ui| {
290                    ui.set_max_height(animated_height);
291                    ui.set_clip_rect(ui.max_rect());
292
293                    content_fn(ui, idx);
294                    ui.add_space(CONTENT_PADDING_BOTTOM);
295
296                    ui.min_rect().height()
297                });
298
299                let actual_height = response.inner / anim_value.max(0.01);
300                ui.ctx()
301                    .data_mut(|d| d.insert_temp(content_id, actual_height));
302            }
303
304            let rect = ui.available_rect_before_wrap();
305            ui.painter().hline(
306                rect.x_range(),
307                rect.top(),
308                egui::Stroke::new(1.0, theme.border()),
309            );
310            ui.allocate_space(Vec2::new(0.0, 1.0));
311        }
312
313        if needs_repaint {
314            ui.ctx().request_repaint();
315        }
316
317        ui.ctx().data_mut(|d| {
318            d.insert_temp(state_id, open_indices.clone());
319            d.insert_temp(self.id.with("accordion_springs"), springs);
320        });
321
322        let response = ui.interact(
323            ui.min_rect(),
324            self.id.with("response_ui"),
325            egui::Sense::hover(),
326        );
327
328        AccordionResponse {
329            response,
330            clicked,
331            open: open_indices,
332        }
333    }
334
335    fn show_trigger_ui(
336        &self,
337        ui: &mut Ui,
338        idx: usize,
339        is_open: bool,
340        anim_value: f32,
341        theme: &Theme,
342        render_trigger: &mut impl FnMut(usize, &mut Ui, &ContentContext),
343    ) -> bool {
344        let available_width = ui.available_width();
345        let font_size = theme.typography.base;
346        let text_height = font_size * 1.3;
347        let trigger_height = text_height + TRIGGER_PADDING_Y * 2.0;
348
349        let (rect, response) = ui.allocate_exact_size(
350            Vec2::new(available_width, trigger_height),
351            egui::Sense::click(),
352        );
353
354        if ui.is_rect_visible(rect) {
355            let color = theme.foreground();
356            let ctx = ContentContext {
357                color,
358                font_size,
359                is_active: is_open,
360            };
361
362            // Content area (leave space for chevron)
363            let content_rect = egui::Rect::from_min_max(
364                Pos2::new(rect.left(), rect.min.y + TRIGGER_PADDING_Y),
365                Pos2::new(
366                    rect.right() - CHEVRON_SIZE - 4.0,
367                    rect.max.y - TRIGGER_PADDING_Y,
368                ),
369            );
370
371            let mut child_ui = ui.new_child(
372                egui::UiBuilder::new()
373                    .max_rect(content_rect)
374                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
375            );
376            child_ui.style_mut().visuals.override_text_color = Some(color);
377
378            render_trigger(idx, &mut child_ui, &ctx);
379
380            // Chevron
381            self.draw_chevron(
382                ui,
383                Pos2::new(rect.right() - CHEVRON_SIZE / 2.0, rect.center().y),
384                anim_value,
385                theme,
386            );
387        }
388
389        response.clicked()
390    }
391
392    fn show_trigger(&self, ui: &mut Ui, title: &str, anim_value: f32, theme: &Theme) -> bool {
393        let available_width = ui.available_width();
394        let text_galley = ui.painter().layout_no_wrap(
395            title.to_string(),
396            egui::FontId::proportional(theme.typography.base),
397            theme.foreground(),
398        );
399        let text_height = text_galley.rect.height();
400        let trigger_height = text_height + TRIGGER_PADDING_Y * 2.0;
401
402        let (rect, response) = ui.allocate_exact_size(
403            Vec2::new(available_width, trigger_height),
404            egui::Sense::click(),
405        );
406
407        if ui.is_rect_visible(rect) {
408            let text_pos = Pos2::new(rect.left(), rect.center().y - text_height / 2.0);
409            ui.painter()
410                .galley(text_pos, text_galley.clone(), theme.foreground());
411
412            // Underline on hover
413            if response.hovered() {
414                ui.painter().hline(
415                    text_pos.x..=text_pos.x + text_galley.rect.width(),
416                    text_pos.y + text_height,
417                    egui::Stroke::new(1.0, theme.foreground()),
418                );
419            }
420
421            // Chevron (rotates 180deg based on spring value)
422            self.draw_chevron(
423                ui,
424                Pos2::new(rect.right() - CHEVRON_SIZE / 2.0, rect.center().y),
425                anim_value,
426                theme,
427            );
428        }
429
430        response.clicked()
431    }
432
433    fn draw_chevron(&self, ui: &mut Ui, center: Pos2, anim_value: f32, theme: &Theme) {
434        let size = CHEVRON_SIZE / 3.0;
435        let rotation = anim_value * std::f32::consts::PI;
436
437        let points = [
438            Vec2::new(-size, -size / 2.0),
439            Vec2::new(0.0, size / 2.0),
440            Vec2::new(size, -size / 2.0),
441        ];
442
443        let (cos, sin) = (rotation.cos(), rotation.sin());
444        let rotate = |v: Vec2| center + Vec2::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
445
446        ui.painter().line_segment(
447            [rotate(points[0]), rotate(points[1])],
448            egui::Stroke::new(1.5, theme.muted_foreground()),
449        );
450        ui.painter().line_segment(
451            [rotate(points[1]), rotate(points[2])],
452            egui::Stroke::new(1.5, theme.muted_foreground()),
453        );
454    }
455}