1use crate::animation::SpringAnimation;
22use crate::ext::ArmasContextExt;
23use crate::Theme;
24use egui::{Pos2, Ui, Vec2};
25
26const TRIGGER_PADDING_Y: f32 = 16.0; const CONTENT_PADDING_BOTTOM: f32 = 16.0; const CHEVRON_SIZE: f32 = 16.0; pub struct AccordionResponse {
33 pub response: egui::Response,
35 pub clicked: Option<usize>,
37 pub open: Vec<usize>,
39}
40
41pub struct Accordion {
43 id: egui::Id,
44 titles: Vec<String>,
45 allow_multiple: bool,
46}
47
48impl Accordion {
49 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 #[must_use]
60 pub const fn allow_multiple(mut self, allow: bool) -> Self {
61 self.allow_multiple = allow;
62 self
63 }
64
65 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 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 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 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 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 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 let is_animating = !springs[idx].is_settled(0.005, 0.1);
128 if is_animating {
129 needs_repaint = true;
130 }
131
132 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 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 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 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 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}