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 crate::animation::SpringAnimation;
22use crate::ext::ArmasContextExt;
23use crate::Theme;
24use egui::{Pos2, Ui, Vec2};
25
26// shadcn Accordion constants
27const TRIGGER_PADDING_Y: f32 = 16.0; // py-4
28const CONTENT_PADDING_BOTTOM: f32 = 16.0; // pb-4
29const CHEVRON_SIZE: f32 = 16.0; // h-4 w-4
30
31/// Response from showing an accordion
32pub struct AccordionResponse {
33    /// The UI response
34    pub response: egui::Response,
35    /// Index of section that was clicked (if any)
36    pub clicked: Option<usize>,
37    /// Indices of currently open sections
38    pub open: Vec<usize>,
39}
40
41/// Accordion with collapsible sections
42pub struct Accordion {
43    id: egui::Id,
44    titles: Vec<String>,
45    allow_multiple: bool,
46}
47
48impl Accordion {
49    /// Create a new accordion with the given ID and section titles
50    pub fn new(id: impl Into<egui::Id>, titles: Vec<impl Into<String>>) -> Self {
51        Self {
52            id: id.into(),
53            titles: titles.into_iter().map(std::convert::Into::into).collect(),
54            allow_multiple: false,
55        }
56    }
57
58    /// Allow multiple sections to be open simultaneously
59    #[must_use]
60    pub const fn allow_multiple(mut self, allow: bool) -> Self {
61        self.allow_multiple = allow;
62        self
63    }
64
65    /// Show the accordion
66    pub fn show(
67        self,
68        ui: &mut Ui,
69        mut content_fn: impl FnMut(&mut Ui, usize),
70    ) -> AccordionResponse {
71        let theme = ui.ctx().armas_theme();
72        let dt = ui.input(|i| i.stable_dt);
73
74        // Load state
75        let state_id = self.id.with("accordion_state");
76        let mut open_indices: Vec<usize> = ui
77            .ctx()
78            .data_mut(|d| d.get_temp(state_id).unwrap_or_default());
79
80        // Load spring animations for each item
81        let mut springs: Vec<SpringAnimation> = ui.ctx().data_mut(|d| {
82            d.get_temp(self.id.with("accordion_springs"))
83                .unwrap_or_else(|| {
84                    self.titles
85                        .iter()
86                        .map(|_| SpringAnimation::new(0.0, 0.0).params(180.0, 22.0))
87                        .collect()
88                })
89        });
90
91        // Ensure springs vec matches titles length
92        while springs.len() < self.titles.len() {
93            springs.push(SpringAnimation::new(0.0, 0.0).params(180.0, 22.0));
94        }
95
96        let mut clicked = None;
97        let mut needs_repaint = false;
98
99        for (idx, title) in self.titles.iter().enumerate() {
100            let is_open = open_indices.contains(&idx);
101
102            // Trigger
103            let trigger_clicked = self.show_trigger(ui, title, springs[idx].value, &theme);
104
105            if trigger_clicked {
106                clicked = Some(idx);
107                if is_open {
108                    open_indices.retain(|&i| i != idx);
109                } else {
110                    if !self.allow_multiple {
111                        open_indices.clear();
112                    }
113                    open_indices.push(idx);
114                }
115            }
116
117            // Update spring target and simulate
118            let target = if open_indices.contains(&idx) {
119                1.0
120            } else {
121                0.0
122            };
123            springs[idx].set_target(target);
124            springs[idx].update(dt);
125
126            // Check if still animating (use looser threshold for smooth closing)
127            let is_animating = !springs[idx].is_settled(0.005, 0.1);
128            if is_animating {
129                needs_repaint = true;
130            }
131
132            // Content - show while animating OR while open
133            let anim_value = springs[idx].value.clamp(0.0, 1.0);
134            let should_show = anim_value > 0.001 || is_animating;
135            if should_show && anim_value > 0.0 {
136                let content_id = self.id.with(("content_height", idx));
137                let stored_height: f32 = ui
138                    .ctx()
139                    .data_mut(|d| d.get_temp(content_id).unwrap_or(50.0));
140
141                let animated_height = (stored_height + CONTENT_PADDING_BOTTOM) * anim_value;
142
143                let response = egui::Frame::new().show(ui, |ui| {
144                    ui.set_max_height(animated_height);
145                    ui.set_clip_rect(ui.max_rect());
146
147                    content_fn(ui, idx);
148                    ui.add_space(CONTENT_PADDING_BOTTOM);
149
150                    ui.min_rect().height()
151                });
152
153                let actual_height = response.inner / anim_value.max(0.01);
154                ui.ctx()
155                    .data_mut(|d| d.insert_temp(content_id, actual_height));
156            }
157
158            // Bottom border
159            let rect = ui.available_rect_before_wrap();
160            ui.painter().hline(
161                rect.x_range(),
162                rect.top(),
163                egui::Stroke::new(1.0, theme.border()),
164            );
165            ui.allocate_space(Vec2::new(0.0, 1.0));
166        }
167
168        if needs_repaint {
169            ui.ctx().request_repaint();
170        }
171
172        // Save state
173        ui.ctx().data_mut(|d| {
174            d.insert_temp(state_id, open_indices.clone());
175            d.insert_temp(self.id.with("accordion_springs"), springs);
176        });
177
178        let response = ui.interact(
179            ui.min_rect(),
180            self.id.with("response"),
181            egui::Sense::hover(),
182        );
183
184        AccordionResponse {
185            response,
186            clicked,
187            open: open_indices,
188        }
189    }
190
191    fn show_trigger(&self, ui: &mut Ui, title: &str, anim_value: f32, theme: &Theme) -> bool {
192        let available_width = ui.available_width();
193        let text_galley = ui.painter().layout_no_wrap(
194            title.to_string(),
195            egui::FontId::proportional(theme.typography.base),
196            theme.foreground(),
197        );
198        let text_height = text_galley.rect.height();
199        let trigger_height = text_height + TRIGGER_PADDING_Y * 2.0;
200
201        let (rect, response) = ui.allocate_exact_size(
202            Vec2::new(available_width, trigger_height),
203            egui::Sense::click(),
204        );
205
206        if ui.is_rect_visible(rect) {
207            let text_pos = Pos2::new(rect.left(), rect.center().y - text_height / 2.0);
208            ui.painter()
209                .galley(text_pos, text_galley.clone(), theme.foreground());
210
211            // Underline on hover
212            if response.hovered() {
213                ui.painter().hline(
214                    text_pos.x..=text_pos.x + text_galley.rect.width(),
215                    text_pos.y + text_height,
216                    egui::Stroke::new(1.0, theme.foreground()),
217                );
218            }
219
220            // Chevron (rotates 180deg based on spring value)
221            self.draw_chevron(
222                ui,
223                Pos2::new(rect.right() - CHEVRON_SIZE / 2.0, rect.center().y),
224                anim_value,
225                theme,
226            );
227        }
228
229        response.clicked()
230    }
231
232    fn draw_chevron(&self, ui: &mut Ui, center: Pos2, anim_value: f32, theme: &Theme) {
233        let size = CHEVRON_SIZE / 3.0;
234        let rotation = anim_value * std::f32::consts::PI;
235
236        let points = [
237            Vec2::new(-size, -size / 2.0),
238            Vec2::new(0.0, size / 2.0),
239            Vec2::new(size, -size / 2.0),
240        ];
241
242        let (cos, sin) = (rotation.cos(), rotation.sin());
243        let rotate = |v: Vec2| center + Vec2::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
244
245        ui.painter().line_segment(
246            [rotate(points[0]), rotate(points[1])],
247            egui::Stroke::new(1.5, theme.muted_foreground()),
248        );
249        ui.painter().line_segment(
250            [rotate(points[1]), rotate(points[2])],
251            egui::Stroke::new(1.5, theme.muted_foreground()),
252        );
253    }
254}