1use std::cell::{OnceCell, RefCell};
45use std::rc::Rc;
46
47use frus_core::{BorderRadius, Color, Rect, Scene};
48use frus_layout::{FlexDirection, Style};
49
50use crate::interaction::Status;
51use crate::theme::Theme;
52use crate::widget::Widget;
53
54const GAP: f32 = 16.0;
57const RADIUS: f32 = 12.0;
58const ELEVATION: f32 = 2.0;
62
63pub struct ExpansionPanel<Msg = crate::callback::Callback> {
65 title: Option<String>,
66 header: RefCell<Option<Box<dyn Widget<Msg>>>>,
67 subtitle: Option<String>,
68 body: RefCell<Option<Box<dyn Widget<Msg>>>>,
69}
70
71impl<Msg> ExpansionPanel<Msg> {
72 pub fn new(title: impl Into<String>, body: impl Widget<Msg> + 'static) -> Self {
74 Self {
75 title: Some(title.into()),
76 header: RefCell::new(None),
77 subtitle: None,
78 body: RefCell::new(Some(Box::new(body))),
79 }
80 }
81
82 pub fn with_header(header: impl Widget<Msg> + 'static, body: impl Widget<Msg> + 'static) -> Self
88 where
89 Msg: 'static,
90 {
91 Self {
92 title: None,
93 header: RefCell::new(Some(Box::new(header))),
94 subtitle: None,
95 body: RefCell::new(Some(Box::new(body))),
96 }
97 }
98
99 #[must_use]
101 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
102 self.subtitle = Some(subtitle.into());
103 self
104 }
105}
106
107enum Mode<Msg> {
109 Free(Vec<usize>, Rc<dyn Fn(usize, bool) -> Msg>),
111 Radio(Option<usize>, Rc<dyn Fn(Option<usize>) -> Msg>),
113}
114
115pub struct ExpansionPanelList<Msg = crate::callback::Callback> {
133 panels: Vec<ExpansionPanel<Msg>>,
134 mode: Mode<Msg>,
135 gap: Option<f32>,
136 radius: Option<f32>,
137 background: Option<Color>,
138 divider_color: Option<Color>,
139 elevation: Option<f32>,
140 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
143}
144
145impl<Msg: Clone + 'static> ExpansionPanelList<Msg> {
146 pub fn new(open: &[usize], on_toggle: impl Fn(usize, bool) -> Msg + 'static) -> Self {
149 Self::with_mode(Mode::Free(open.to_vec(), Rc::new(on_toggle)))
150 }
151
152 pub fn radio(open: Option<usize>, on_open: impl Fn(Option<usize>) -> Msg + 'static) -> Self {
160 Self::with_mode(Mode::Radio(open, Rc::new(on_open)))
161 }
162
163 fn with_mode(mode: Mode<Msg>) -> Self {
164 Self {
165 panels: Vec::new(),
166 mode,
167 gap: None,
168 radius: None,
169 background: None,
170 divider_color: None,
171 elevation: None,
172 built: OnceCell::new(),
173 }
174 }
175
176 pub fn panel(mut self, panel: ExpansionPanel<Msg>) -> Self {
178 self.panels.push(panel);
179 self
180 }
181
182 #[must_use]
185 pub fn gap(mut self, gap: f32) -> Self {
186 self.gap = Some(gap.max(0.0));
187 self
188 }
189
190 #[must_use]
193 pub fn radius(mut self, radius: f32) -> Self {
194 self.radius = Some(radius.max(0.0));
195 self
196 }
197
198 #[must_use]
200 pub fn background(mut self, color: Color) -> Self {
201 self.background = Some(color);
202 self
203 }
204
205 #[must_use]
207 pub fn divider_color(mut self, color: Color) -> Self {
208 self.divider_color = Some(color);
209 self
210 }
211
212 #[must_use]
215 pub fn elevation(mut self, elevation: f32) -> Self {
216 self.elevation = Some(elevation.max(0.0));
217 self
218 }
219
220 fn is_open(&self, index: usize) -> bool {
222 match &self.mode {
223 Mode::Free(open, _) => open.contains(&index),
224 Mode::Radio(open, _) => *open == Some(index),
225 }
226 }
227
228 fn message(&self, index: usize) -> Msg {
230 let open = self.is_open(index);
231 match &self.mode {
232 Mode::Free(_, toggle) => toggle(index, !open),
233 Mode::Radio(_, choose) => choose(match open {
234 true => None,
235 false => Some(index),
236 }),
237 }
238 }
239
240 fn gap_before(&self, index: usize) -> bool {
244 index > 0 && (self.is_open(index) || self.is_open(index - 1))
245 }
246
247 fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
248 let t = &theme.widgets.expansion_panel_list;
249 let gap = self.gap.or(t.gap).unwrap_or(GAP);
250 let radius = self.radius.or(t.radius).unwrap_or(RADIUS);
251 let background = self
252 .background
253 .or(t.background)
254 .unwrap_or(theme.scheme.surface_container_low);
255 let divider = self
256 .divider_color
257 .or(t.divider_color)
258 .unwrap_or(theme.scheme.outline_variant);
259 let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
260
261 let card = |run: crate::Flex<Msg>| CardShadow {
267 children: vec![Box::new(run) as Box<dyn Widget<Msg>>],
268 depth,
269 radius,
270 };
271 let mut column = crate::Flex::column();
272 let mut run: Option<crate::Flex<Msg>> = None;
273 for (index, panel) in self.panels.iter().enumerate() {
274 let open = self.is_open(index);
275 let split_above = self.gap_before(index);
276 let split_below = index + 1 == self.panels.len() || self.gap_before(index + 1);
277 if split_above {
278 if let Some(done) = run.take() {
279 column = column.child(card(done));
280 }
281 column = column.child(crate::Container::<Msg>::new().height(gap));
282 }
283 let mut current = run.take().unwrap_or_else(crate::Flex::column);
284 if index > 0 && !split_above {
285 current = current.child(crate::Divider::new().color(divider));
288 }
289
290 let corners = BorderRadius {
293 top_left: if split_above || index == 0 {
294 radius
295 } else {
296 0.0
297 },
298 top_right: if split_above || index == 0 {
299 radius
300 } else {
301 0.0
302 },
303 bottom_right: if split_below { radius } else { 0.0 },
304 bottom_left: if split_below { radius } else { 0.0 },
305 };
306
307 let mut tile = crate::ExpansionTile::new(
308 panel.title.clone().unwrap_or_default(),
309 open,
310 self.message(index),
311 );
312 if let Some(header) = panel.header.borrow_mut().take() {
313 tile = tile.title_child_boxed(header);
314 }
315 if let Some(subtitle) = &panel.subtitle {
316 tile = tile.subtitle(subtitle.clone());
317 }
318 if let Some(body) = panel.body.borrow_mut().take() {
319 tile = tile.content(crate::ConstrainedBox::new_boxed(body));
320 }
321 current = current.child(
322 crate::Container::new()
323 .color(background)
324 .radius(corners)
325 .clip()
326 .child(tile),
327 );
328 run = Some(current);
329 }
330 if let Some(done) = run {
331 column = column.child(card(done));
332 }
333 vec![Box::new(column) as Box<dyn Widget<Msg>>]
334 }
335}
336
337struct CardShadow<Msg> {
343 children: Vec<Box<dyn Widget<Msg>>>,
344 depth: f32,
345 radius: f32,
346}
347
348impl<Msg: Clone + 'static> Widget<Msg> for CardShadow<Msg> {
349 fn style(&self) -> Style {
350 Style {
351 flex_direction: FlexDirection::Column,
352 ..Default::default()
353 }
354 }
355
356 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
357 &self.children
358 }
359
360 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
363 if self.depth <= 0.0 {
364 return;
365 }
366 let blur = self.depth * 4.0 + 8.0;
367 scene.shadow(
368 Rect::new(
369 bounds.x - blur,
370 bounds.y + self.depth * 2.0 - blur,
371 bounds.width + 2.0 * blur,
372 bounds.height + 2.0 * blur,
373 ),
374 theme.scheme.shadow.with_alpha(0.30).fade(status.opacity),
375 BorderRadius::uniform(self.radius).inflate(blur),
376 blur,
377 );
378 }
379
380 fn on_click(&self) -> Option<Msg> {
381 None
382 }
383
384 fn debug_name(&self) -> &'static str {
385 "ExpansionPanelCard"
386 }
387}
388
389impl<Msg: Clone + 'static> Widget<Msg> for ExpansionPanelList<Msg> {
390 fn style(&self) -> Style {
391 Style {
392 flex_direction: FlexDirection::Column,
393 ..Default::default()
394 }
395 }
396
397 fn build_themed(&self, theme: &Theme) {
398 self.built.get_or_init(|| self.assemble(theme));
399 }
400
401 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
402 self.built.get().map(|v| &v[..]).unwrap_or(&[])
403 }
404
405 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
406
407 fn on_click(&self) -> Option<Msg> {
408 None
409 }
410
411 fn debug_name(&self) -> &'static str {
412 "ExpansionPanelList"
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::{build_ui, Runtime, Text};
420 use frus_core::{Primitive, Size};
421
422 #[derive(Clone, Debug, PartialEq)]
423 enum Msg {
424 Toggle(usize, bool),
425 Open(Option<usize>),
426 }
427
428 fn three(list: ExpansionPanelList<Msg>) -> ExpansionPanelList<Msg> {
429 list.panel(ExpansionPanel::new("One", Text::new("body one")))
430 .panel(ExpansionPanel::new("Two", Text::new("body two")))
431 .panel(ExpansionPanel::new("Three", Text::new("body three")))
432 }
433
434 fn ui(list: &ExpansionPanelList<Msg>) -> crate::Ui<Msg> {
435 build_ui(
436 list,
437 Size::new(320.0, 400.0),
438 &Runtime::default(),
439 &Theme::default(),
440 )
441 }
442
443 fn flat(ui: &crate::Ui<Msg>) -> Vec<Primitive> {
446 fn walk(from: &[Primitive], out: &mut Vec<Primitive>) {
447 for p in from {
448 match p {
449 Primitive::Layer { primitives, .. } => walk(primitives, out),
450 other => out.push(other.clone()),
451 }
452 }
453 }
454 let mut out = Vec::new();
455 walk(ui.scene().primitives(), &mut out);
456 out
457 }
458
459 fn texts(ui: &crate::Ui<Msg>) -> Vec<String> {
461 flat(ui)
462 .iter()
463 .filter_map(|p| match p {
464 Primitive::Text { text, .. } => Some(text.clone()),
465 _ => None,
466 })
467 .collect()
468 }
469
470 fn press(list: &ExpansionPanelList<Msg>, title: &str) -> Msg {
472 let ui = ui(list);
473 let row = flat(&ui)
474 .iter()
475 .find_map(|p| match p {
476 Primitive::Text { text, position, .. } if text == title => Some(*position),
477 _ => None,
478 })
479 .expect("the header is drawn");
480 ui.hit(frus_core::Point::new(row.x + 4.0, row.y + 4.0))
481 .and_then(|id| ui.msg_for(id))
482 .expect("the header answers a press")
483 }
484
485 #[test]
489 fn in_radio_mode_opening_one_shuts_the_other() {
490 let mut open = Some(1);
491 let list = three(ExpansionPanelList::radio(open, Msg::Open));
492 assert!(texts(&ui(&list)).contains(&"body two".to_string()));
493
494 let asked = press(&list, "Three");
495 assert_eq!(
496 asked,
497 Msg::Open(Some(2)),
498 "the resulting choice, not the index"
499 );
500 let Msg::Open(next) = asked else {
501 unreachable!()
502 };
503 open = next;
504
505 let after = three(ExpansionPanelList::radio(open, Msg::Open));
506 let drawn = texts(&ui(&after));
507 assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
508 assert!(
509 !drawn.contains(&"body two".to_string()),
510 "the second shut itself: {drawn:?}"
511 );
512 }
513
514 #[test]
517 fn in_free_mode_opening_one_leaves_the_other() {
518 let mut open = vec![1];
519 let list = three(ExpansionPanelList::new(&open, Msg::Toggle));
520 let asked = press(&list, "Three");
521 assert_eq!(asked, Msg::Toggle(2, true));
522 let Msg::Toggle(index, now) = asked else {
523 unreachable!()
524 };
525 if now {
526 open.push(index);
527 }
528
529 let after = three(ExpansionPanelList::new(&open, Msg::Toggle));
530 let drawn = texts(&ui(&after));
531 assert!(drawn.contains(&"body two".to_string()), "{drawn:?}");
532 assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
533 }
534
535 #[test]
538 fn pressing_the_open_panel_shuts_it() {
539 let list = three(ExpansionPanelList::radio(Some(1), Msg::Open));
540 assert_eq!(press(&list, "Two"), Msg::Open(None));
541 }
542
543 #[test]
547 fn the_card_splits_only_where_a_panel_is_open() {
548 let shut = three(ExpansionPanelList::new(&[], Msg::Toggle));
549 assert!(!shut.gap_before(1) && !shut.gap_before(2), "one card");
550
551 let second = three(ExpansionPanelList::radio(Some(1), Msg::Open));
552 assert!(second.gap_before(1), "parted from the one above it");
553 assert!(second.gap_before(2), "and from the one below");
554
555 let first = three(ExpansionPanelList::radio(Some(0), Msg::Open));
556 assert!(
557 first.gap_before(1),
558 "the open first panel parts from the second"
559 );
560 assert!(!first.gap_before(2), "and the two shut ones stay one card");
561 }
562
563 #[test]
566 fn the_gap_is_room_on_the_screen() {
567 let card_tops = |list: &ExpansionPanelList<Msg>| -> Vec<f32> {
568 let ui = ui(list);
569 let mut tops: Vec<f32> = flat(&ui)
570 .iter()
571 .filter_map(|p| match p {
572 Primitive::Rect {
574 rect, radius, blur, ..
575 } if radius.top_left > 0.0 && *blur == 0.0 => Some(rect.y),
576 _ => None,
577 })
578 .collect();
579 tops.dedup();
580 tops
581 };
582 let shut = card_tops(&three(ExpansionPanelList::new(&[], Msg::Toggle)));
583 let open = card_tops(&three(ExpansionPanelList::radio(Some(0), Msg::Open)));
584 assert!(
585 open.len() > shut.len(),
586 "an open panel makes a second card: {shut:?} against {open:?}"
587 );
588 }
589
590 #[test]
595 fn each_card_casts_one_shadow() {
596 let shadows = |list: &ExpansionPanelList<Msg>, theme: &Theme| {
597 let ui = build_ui(list, Size::new(320.0, 400.0), &Runtime::default(), theme);
598 flat(&ui)
599 .iter()
600 .filter_map(|p| match p {
601 Primitive::Rect { blur, .. } if *blur > 0.0 => Some((blur - 8.0) / 4.0),
602 _ => None,
603 })
604 .collect::<Vec<_>>()
605 };
606 let theme = Theme::default();
607 assert_eq!(
608 shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &theme),
609 vec![2.0],
610 "one card, one shadow"
611 );
612 assert_eq!(
613 shadows(
614 &three(ExpansionPanelList::radio(Some(1), Msg::Open)),
615 &theme
616 ),
617 vec![2.0; 3],
618 "three cards, three"
619 );
620 assert!(shadows(
621 &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(0.0)),
622 &theme
623 )
624 .is_empty());
625
626 let mut level = Theme::default();
627 level.widgets.expansion_panel_list.elevation = Some(0.0);
628 assert!(
629 shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &level).is_empty(),
630 "the theme's word"
631 );
632 assert_eq!(
633 shadows(
634 &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(4.0)),
635 &level
636 ),
637 vec![4.0],
638 "and the list's over it"
639 );
640 }
641}