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