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 frus_core::paint_elevation(
368 scene,
369 bounds,
370 BorderRadius::uniform(self.radius),
371 self.depth,
372 theme.scheme.shadow.fade(status.opacity),
373 );
374 }
375
376 fn on_click(&self) -> Option<Msg> {
377 None
378 }
379
380 fn debug_name(&self) -> &'static str {
381 "ExpansionPanelCard"
382 }
383}
384
385impl<Msg: Clone + 'static> Widget<Msg> for ExpansionPanelList<Msg> {
386 fn style(&self) -> Style {
387 Style {
388 flex_direction: FlexDirection::Column,
389 ..Default::default()
390 }
391 }
392
393 fn build_themed(&self, theme: &Theme) {
394 self.built.get_or_init(|| self.assemble(theme));
395 }
396
397 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
398 self.built.get().map(|v| &v[..]).unwrap_or(&[])
399 }
400
401 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
402
403 fn on_click(&self) -> Option<Msg> {
404 None
405 }
406
407 fn debug_name(&self) -> &'static str {
408 "ExpansionPanelList"
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::{build_ui, Runtime, Text};
416 use frus_core::{Primitive, Size};
417
418 #[derive(Clone, Debug, PartialEq)]
419 enum Msg {
420 Toggle(usize, bool),
421 Open(Option<usize>),
422 }
423
424 fn three(list: ExpansionPanelList<Msg>) -> ExpansionPanelList<Msg> {
425 list.panel(ExpansionPanel::new("One", Text::new("body one")))
426 .panel(ExpansionPanel::new("Two", Text::new("body two")))
427 .panel(ExpansionPanel::new("Three", Text::new("body three")))
428 }
429
430 fn ui(list: &ExpansionPanelList<Msg>) -> crate::Ui<Msg> {
431 build_ui(
432 list,
433 Size::new(320.0, 400.0),
434 &Runtime::default(),
435 &Theme::default(),
436 )
437 }
438
439 fn flat(ui: &crate::Ui<Msg>) -> Vec<Primitive> {
442 fn walk(from: &[Primitive], out: &mut Vec<Primitive>) {
443 for p in from {
444 match p {
445 Primitive::Layer { primitives, .. } => walk(primitives, out),
446 other => out.push(other.clone()),
447 }
448 }
449 }
450 let mut out = Vec::new();
451 walk(ui.scene().primitives(), &mut out);
452 out
453 }
454
455 fn texts(ui: &crate::Ui<Msg>) -> Vec<String> {
457 flat(ui)
458 .iter()
459 .filter_map(|p| match p {
460 Primitive::Text { text, .. } => Some(text.clone()),
461 _ => None,
462 })
463 .collect()
464 }
465
466 fn press(list: &ExpansionPanelList<Msg>, title: &str) -> Msg {
468 let ui = ui(list);
469 let row = flat(&ui)
470 .iter()
471 .find_map(|p| match p {
472 Primitive::Text { text, position, .. } if text == title => Some(*position),
473 _ => None,
474 })
475 .expect("the header is drawn");
476 ui.hit(frus_core::Point::new(row.x + 4.0, row.y + 4.0))
477 .and_then(|id| ui.msg_for(id))
478 .expect("the header answers a press")
479 }
480
481 #[test]
485 fn in_radio_mode_opening_one_shuts_the_other() {
486 let mut open = Some(1);
487 let list = three(ExpansionPanelList::radio(open, Msg::Open));
488 assert!(texts(&ui(&list)).contains(&"body two".to_string()));
489
490 let asked = press(&list, "Three");
491 assert_eq!(
492 asked,
493 Msg::Open(Some(2)),
494 "the resulting choice, not the index"
495 );
496 let Msg::Open(next) = asked else {
497 unreachable!()
498 };
499 open = next;
500
501 let after = three(ExpansionPanelList::radio(open, Msg::Open));
502 let drawn = texts(&ui(&after));
503 assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
504 assert!(
505 !drawn.contains(&"body two".to_string()),
506 "the second shut itself: {drawn:?}"
507 );
508 }
509
510 #[test]
513 fn in_free_mode_opening_one_leaves_the_other() {
514 let mut open = vec![1];
515 let list = three(ExpansionPanelList::new(&open, Msg::Toggle));
516 let asked = press(&list, "Three");
517 assert_eq!(asked, Msg::Toggle(2, true));
518 let Msg::Toggle(index, now) = asked else {
519 unreachable!()
520 };
521 if now {
522 open.push(index);
523 }
524
525 let after = three(ExpansionPanelList::new(&open, Msg::Toggle));
526 let drawn = texts(&ui(&after));
527 assert!(drawn.contains(&"body two".to_string()), "{drawn:?}");
528 assert!(drawn.contains(&"body three".to_string()), "{drawn:?}");
529 }
530
531 #[test]
534 fn pressing_the_open_panel_shuts_it() {
535 let list = three(ExpansionPanelList::radio(Some(1), Msg::Open));
536 assert_eq!(press(&list, "Two"), Msg::Open(None));
537 }
538
539 #[test]
543 fn the_card_splits_only_where_a_panel_is_open() {
544 let shut = three(ExpansionPanelList::new(&[], Msg::Toggle));
545 assert!(!shut.gap_before(1) && !shut.gap_before(2), "one card");
546
547 let second = three(ExpansionPanelList::radio(Some(1), Msg::Open));
548 assert!(second.gap_before(1), "parted from the one above it");
549 assert!(second.gap_before(2), "and from the one below");
550
551 let first = three(ExpansionPanelList::radio(Some(0), Msg::Open));
552 assert!(
553 first.gap_before(1),
554 "the open first panel parts from the second"
555 );
556 assert!(!first.gap_before(2), "and the two shut ones stay one card");
557 }
558
559 #[test]
562 fn the_gap_is_room_on_the_screen() {
563 let card_tops = |list: &ExpansionPanelList<Msg>| -> Vec<f32> {
564 let ui = ui(list);
565 let mut tops: Vec<f32> = flat(&ui)
566 .iter()
567 .filter_map(|p| match p {
568 Primitive::Rect {
570 rect, radius, blur, ..
571 } if radius.top_left > 0.0 && *blur == 0.0 => Some(rect.y),
572 _ => None,
573 })
574 .collect();
575 tops.dedup();
576 tops
577 };
578 let shut = card_tops(&three(ExpansionPanelList::new(&[], Msg::Toggle)));
579 let open = card_tops(&three(ExpansionPanelList::radio(Some(0), Msg::Open)));
580 assert!(
581 open.len() > shut.len(),
582 "an open panel makes a second card: {shut:?} against {open:?}"
583 );
584 }
585
586 #[test]
591 fn each_card_casts_one_shadow() {
592 let shadows = |list: &ExpansionPanelList<Msg>, theme: &Theme| {
593 let ui = build_ui(list, Size::new(320.0, 400.0), &Runtime::default(), theme);
594 crate::shadowprobe::heights(&flat(&ui))
595 };
596 let theme = Theme::default();
597 assert_eq!(
598 shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &theme),
599 vec![2.0],
600 "one card, one shadow"
601 );
602 assert_eq!(
603 shadows(
604 &three(ExpansionPanelList::radio(Some(1), Msg::Open)),
605 &theme
606 ),
607 vec![2.0; 3],
608 "three cards, three"
609 );
610 assert!(shadows(
611 &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(0.0)),
612 &theme
613 )
614 .is_empty());
615
616 let mut level = Theme::default();
617 level.widgets.expansion_panel_list.elevation = Some(0.0);
618 assert!(
619 shadows(&three(ExpansionPanelList::new(&[], Msg::Toggle)), &level).is_empty(),
620 "the theme's word"
621 );
622 assert_eq!(
623 shadows(
624 &three(ExpansionPanelList::new(&[], Msg::Toggle).elevation(4.0)),
625 &level
626 ),
627 vec![4.0],
628 "and the list's over it"
629 );
630 }
631}