1use std::cell::{OnceCell, RefCell};
10
11use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder};
12use frus_layout::{FlexDirection, Style};
13
14use crate::icons::Icons;
15use crate::interaction::Status;
16use crate::theme::Theme;
17use crate::widget::Widget;
18use crate::{Container, Icon, ListTile};
19
20#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
22pub enum ControlAffinity {
23 Leading,
26 #[default]
28 Trailing,
29}
30
31const CHEVRON: f32 = 24.0;
33const CHILDREN_PADDING: Insets = Insets::new(0.0, 16.0, 16.0, 16.0);
36
37pub struct ExpansionTile<Msg = crate::callback::Callback> {
52 title: String,
53 title_widget: RefCell<Option<Box<dyn Widget<Msg>>>>,
56 open: bool,
57 on_toggle: Msg,
58 subtitle: Option<String>,
59 leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
60 trailing: RefCell<Option<Box<dyn Widget<Msg>>>>,
61 content: RefCell<Option<Box<dyn Widget<Msg>>>>,
62 show_trailing_icon: bool,
63 affinity: ControlAffinity,
64 dense: bool,
65 tile_padding: Option<Insets>,
66 children_padding: Option<Insets>,
70 background: Option<Color>,
71 collapsed_background: Option<Color>,
72 text_color: Option<Color>,
73 collapsed_text_color: Option<Color>,
74 icon_color: Option<Color>,
75 collapsed_icon_color: Option<Color>,
76 shape: Option<ShapeBorder>,
77 collapsed_shape: Option<ShapeBorder>,
78 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
82}
83
84impl<Msg: Clone + 'static> ExpansionTile<Msg> {
85 pub fn new(title: impl Into<String>, open: bool, on_toggle: Msg) -> Self {
87 Self {
88 title: title.into(),
89 title_widget: RefCell::new(None),
90 open,
91 on_toggle,
92 subtitle: None,
93 leading: RefCell::new(None),
94 trailing: RefCell::new(None),
95 content: RefCell::new(None),
96 show_trailing_icon: true,
97 affinity: ControlAffinity::default(),
98 dense: false,
99 tile_padding: None,
100 children_padding: None,
101 background: None,
102 collapsed_background: None,
103 text_color: None,
104 collapsed_text_color: None,
105 icon_color: None,
106 collapsed_icon_color: None,
107 shape: None,
108 collapsed_shape: None,
109 built: OnceCell::new(),
110 }
111 }
112
113 pub fn title_child(self, title: impl Widget<Msg> + 'static) -> Self {
119 self.title_child_boxed(Box::new(title))
120 }
121
122 pub fn title_child_boxed(self, title: Box<dyn Widget<Msg>>) -> Self {
124 *self.title_widget.borrow_mut() = Some(title);
125 self
126 }
127
128 pub fn content(self, content: impl Widget<Msg> + 'static) -> Self {
134 *self.content.borrow_mut() = Some(Box::new(content));
135 self
136 }
137
138 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
140 self.subtitle = Some(subtitle.into());
141 self
142 }
143
144 pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
150 *self.leading.borrow_mut() = Some(Box::new(leading));
151 self
152 }
153
154 pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
157 *self.trailing.borrow_mut() = Some(Box::new(trailing));
158 self
159 }
160
161 pub fn show_trailing_icon(mut self, show: bool) -> Self {
164 self.show_trailing_icon = show;
165 self
166 }
167
168 pub fn control_affinity(mut self, affinity: ControlAffinity) -> Self {
170 self.affinity = affinity;
171 self
172 }
173
174 pub fn dense(mut self) -> Self {
176 self.dense = true;
177 self
178 }
179
180 pub fn tile_padding(mut self, padding: Insets) -> Self {
182 self.tile_padding = Some(padding);
183 self
184 }
185
186 pub fn children_padding(mut self, padding: Insets) -> Self {
188 self.children_padding = Some(padding);
189 self
190 }
191
192 pub fn background_color(mut self, color: Color) -> Self {
194 self.background = Some(color);
195 self
196 }
197
198 pub fn collapsed_background_color(mut self, color: Color) -> Self {
200 self.collapsed_background = Some(color);
201 self
202 }
203
204 pub fn text_color(mut self, color: Color) -> Self {
206 self.text_color = Some(color);
207 self
208 }
209
210 pub fn collapsed_text_color(mut self, color: Color) -> Self {
212 self.collapsed_text_color = Some(color);
213 self
214 }
215
216 pub fn icon_color(mut self, color: Color) -> Self {
218 self.icon_color = Some(color);
219 self
220 }
221
222 pub fn collapsed_icon_color(mut self, color: Color) -> Self {
224 self.collapsed_icon_color = Some(color);
225 self
226 }
227
228 #[must_use]
233 pub fn shape(mut self, shape: ShapeBorder) -> Self {
234 self.shape = Some(shape);
235 self
236 }
237
238 #[must_use]
243 pub fn collapsed_shape(mut self, shape: ShapeBorder) -> Self {
244 self.collapsed_shape = Some(shape);
245 self
246 }
247
248 #[must_use]
251 pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
252 let shape = ShapeBorder::rounded(radius.into());
253 self.shape(shape).collapsed_shape(shape)
254 }
255
256 fn chevron(&self, theme: &Theme) -> Option<Box<dyn Widget<Msg>>> {
259 if !self.show_trailing_icon {
260 return None;
261 }
262 let t = &theme.widgets.expansion_tile;
263 let color = if self.open {
264 self.icon_color.or(t.icon_color)
265 } else {
266 self.collapsed_icon_color.or(t.collapsed_icon_color)
267 }
268 .unwrap_or(theme.scheme.on_surface_variant);
269 let name = if self.open {
270 Icons::EXPAND_MORE
271 } else {
272 Icons::CHEVRON_RIGHT
273 };
274 Some(Box::new(Icon::new(name).size(CHEVRON).color(color)) as Box<dyn Widget<Msg>>)
275 }
276
277 fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
279 let chevron = self.chevron(theme);
280 let given_leading = self.leading.borrow_mut().take();
281 let given_trailing = self.trailing.borrow_mut().take();
282
283 let (leading, trailing) = match self.affinity {
287 ControlAffinity::Leading => (chevron.or(given_leading), given_trailing),
288 ControlAffinity::Trailing => (given_leading, given_trailing.or(chevron)),
289 };
290
291 let mut tile = ListTile::new().on_tap(self.on_toggle.clone());
292 tile = match self.title_widget.borrow_mut().take() {
293 Some(header) => tile.title_child(crate::ConstrainedBox::new_boxed(header)),
294 None => tile.title(self.title.clone()),
295 };
296 if let Some(subtitle) = &self.subtitle {
297 tile = tile.subtitle(subtitle.clone());
298 }
299 if let Some(leading) = leading {
300 tile = tile.leading(crate::ConstrainedBox::new_boxed(leading));
301 }
302 if let Some(trailing) = trailing {
303 tile = tile.trailing(crate::ConstrainedBox::new_boxed(trailing));
304 }
305 if self.dense {
306 tile = tile.dense();
307 }
308 let t = &theme.widgets.expansion_tile;
309 if let Some(padding) = self.tile_padding.or(t.tile_padding) {
310 tile = tile.padding(padding);
311 }
312 if let Some(color) = if self.open {
313 self.background.or(t.background)
314 } else {
315 self.collapsed_background.or(t.collapsed_background)
316 } {
317 tile = tile.tile_color(color);
318 }
319 if let Some(color) = if self.open {
320 self.text_color.or(t.text_color)
321 } else {
322 self.collapsed_text_color.or(t.collapsed_text_color)
323 } {
324 let mut style = theme.text.body_large;
325 style.color = Some(color);
326 tile = tile.title_style(style);
327 }
328 if let Some(shape) = if self.open {
332 self.shape.or(t.shape)
333 } else {
334 self.collapsed_shape.or(t.collapsed_shape)
335 } {
336 tile = tile.shape(shape);
337 }
338
339 let mut out: Vec<Box<dyn Widget<Msg>>> = vec![Box::new(tile)];
340 if self.open {
341 if let Some(content) = self.content.borrow_mut().take() {
342 let pad = self
343 .children_padding
344 .or(t.children_padding)
345 .unwrap_or(CHILDREN_PADDING);
346 out.push(Box::new(
347 Container::new()
348 .padding_each(pad.top, pad.right, pad.bottom, pad.left)
349 .child(crate::ConstrainedBox::new_boxed(content)),
350 ));
351 }
352 }
353 out
354 }
355}
356
357impl<Msg: Clone + 'static> Widget<Msg> for ExpansionTile<Msg> {
358 fn style(&self) -> Style {
359 Style {
360 flex_direction: FlexDirection::Column,
365 ..Default::default()
366 }
367 }
368
369 fn build_themed(&self, theme: &Theme) {
373 self.built.get_or_init(|| self.assemble(theme));
374 }
375
376 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
377 self.built.get().map(|v| &v[..]).unwrap_or(&[])
378 }
379
380 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
381
382 fn on_click(&self) -> Option<Msg> {
383 None
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::{build_ui, Runtime, Size, Text};
391 use frus_core::{Color, Primitive};
392
393 #[derive(Clone, Debug, PartialEq)]
394 enum Msg {
395 Toggle,
396 Other,
397 }
398
399 fn assembled(tile: &ExpansionTile<Msg>) -> &[Box<dyn Widget<Msg>>] {
401 Widget::build_themed(tile, &Theme::default());
402 Widget::children(tile)
403 }
404
405 fn row_of(tile: &ExpansionTile<Msg>, theme: &Theme) -> (Color, frus_core::BorderRadius) {
407 let ui = build_ui(tile, Size::new(300.0, 300.0), &Runtime::default(), theme);
408 fn find(primitives: &[frus_core::Primitive]) -> Option<(Color, frus_core::BorderRadius)> {
409 for p in primitives {
410 match p {
411 frus_core::Primitive::Rect {
412 color,
413 radius,
414 border_width,
415 ..
416 } if *border_width == 0.0 && color.a > 0.0 => return Some((*color, *radius)),
417 frus_core::Primitive::Layer { primitives, .. } => {
418 if let Some(found) = find(primitives) {
419 return Some(found);
420 }
421 }
422 _ => {}
423 }
424 }
425 None
426 }
427 find(ui.scene().primitives()).expect("the row paints a surface")
428 }
429
430 #[test]
435 fn a_theme_answers_for_every_expansion_tile() {
436 let mut theme = Theme::default();
437 theme.widgets.expansion_tile.background = Some(Color::rgb(0.2, 0.4, 0.6));
438 theme.widgets.expansion_tile.collapsed_background = Some(Color::rgb(0.6, 0.4, 0.2));
439 theme.widgets.expansion_tile.shape = Some(ShapeBorder::rounded(7.0));
440 theme.widgets.expansion_tile.collapsed_shape = Some(ShapeBorder::rounded(3.0));
441
442 let open = ExpansionTile::new("Delivery", true, Msg::Toggle);
443 assert_eq!(
444 row_of(&open, &theme),
445 (
446 Color::rgb(0.2, 0.4, 0.6),
447 frus_core::BorderRadius::uniform(7.0)
448 ),
449 "the open answers"
450 );
451 let shut = ExpansionTile::new("Delivery", false, Msg::Toggle);
452 assert_eq!(
453 row_of(&shut, &theme),
454 (
455 Color::rgb(0.6, 0.4, 0.2),
456 frus_core::BorderRadius::uniform(3.0)
457 ),
458 "and the shut ones, which are a different set and not a fallback"
459 );
460
461 let told = ExpansionTile::new("Delivery", true, Msg::Toggle)
463 .background_color(Color::rgb(0.9, 0.9, 0.9))
464 .shape(ShapeBorder::rounded(1.0));
465 assert_eq!(
466 row_of(&told, &theme),
467 (
468 Color::rgb(0.9, 0.9, 0.9),
469 frus_core::BorderRadius::uniform(1.0)
470 )
471 );
472 }
473
474 #[test]
479 fn a_shut_tile_does_not_borrow_the_open_one_s_shape() {
480 let mut theme = Theme::default();
481 theme.widgets.expansion_tile.shape = Some(ShapeBorder::rounded(9.0));
482 theme.widgets.expansion_tile.collapsed_background = Some(Color::rgb(0.5, 0.5, 0.5));
483
484 let shut = ExpansionTile::new("Delivery", false, Msg::Toggle);
485 assert_eq!(
486 row_of(&shut, &theme).1,
487 frus_core::BorderRadius::ZERO,
488 "an open tile's shape is not a shut one's"
489 );
490
491 let both = ExpansionTile::new("Delivery", false, Msg::Toggle)
493 .collapsed_background_color(Color::rgb(0.5, 0.5, 0.5))
494 .radius(5.0);
495 assert_eq!(
496 row_of(&both, &Theme::default()).1,
497 frus_core::BorderRadius::uniform(5.0)
498 );
499 }
500
501 #[test]
506 fn the_room_round_the_body_can_be_told_to_be_nothing() {
507 let theme = Theme::default();
508 let body_padding = |tile: &ExpansionTile<Msg>, theme: &Theme| {
509 Widget::<Msg>::build_themed(tile, theme);
512 let children = Widget::<Msg>::children(tile);
513 assert_eq!(children.len(), 2, "an open tile with a body");
514 children[1].style_themed(theme).padding
515 };
516
517 let plain = ExpansionTile::new("Delivery", true, Msg::Toggle).content(Text::new("Two"));
518 assert_eq!(body_padding(&plain, &theme), CHILDREN_PADDING);
519
520 let mut themed = Theme::default();
521 themed.widgets.expansion_tile.children_padding = Some(Insets::uniform(4.0));
522 let from_theme =
523 ExpansionTile::new("Delivery", true, Msg::Toggle).content(Text::new("Two"));
524 assert_eq!(body_padding(&from_theme, &themed), Insets::uniform(4.0));
525
526 let none = ExpansionTile::new("Delivery", true, Msg::Toggle)
527 .content(Text::new("Two"))
528 .children_padding(Insets::ZERO);
529 assert_eq!(
530 body_padding(&none, &themed),
531 Insets::ZERO,
532 "zero is an answer, not a silence"
533 );
534 }
535
536 #[test]
538 fn a_shut_tile_builds_only_its_row() {
539 let tile = ExpansionTile::new("Title", false, Msg::Toggle).content(Text::new("hidden"));
540 assert_eq!(assembled(&tile).len(), 1);
541 }
542
543 #[test]
545 fn an_open_tile_carries_its_body_and_the_row_toggles() {
546 let tile = ExpansionTile::new("Title", true, Msg::Toggle).content(Text::new("visible"));
547 let children = assembled(&tile);
548 assert_eq!(children.len(), 2);
549 assert_eq!(children[0].on_click(), Some(Msg::Toggle));
550 }
551
552 #[test]
554 fn the_row_is_a_list_tile() {
555 let one = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle);
556 Widget::build_themed(&one, &Theme::default());
557 let row = &Widget::children(&one)[0];
558 assert_eq!(
559 row.style_themed(&Theme::default()).min_height,
560 frus_layout::Dimension::Length(crate::LIST_TILE_HEIGHTS[0]),
561 "one line"
562 );
563
564 let two = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle).subtitle("Second line");
565 Widget::build_themed(&two, &Theme::default());
566 let row = &Widget::children(&two)[0];
567 assert_eq!(
568 row.style_themed(&Theme::default()).min_height,
569 frus_layout::Dimension::Length(crate::LIST_TILE_HEIGHTS[1]),
570 "two lines"
571 );
572 }
573
574 fn slots(tile: &ExpansionTile<Msg>) -> usize {
576 let row = &assembled(tile)[0];
577 row.build_themed(&Theme::default());
579 row.children()
580 .first()
581 .map(|inner| inner.children().len())
582 .unwrap_or(0)
583 }
584
585 #[test]
587 fn the_chevron_takes_the_trailing_slot() {
588 let bare = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle);
589 let with_leading =
590 ExpansionTile::<Msg>::new("Title", false, Msg::Toggle).leading(Text::new("*"));
591 assert_eq!(slots(&with_leading), slots(&bare) + 1);
592 }
593
594 #[test]
597 fn a_trailing_widget_replaces_the_chevron() {
598 let bare = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle);
599 let replaced =
600 ExpansionTile::<Msg>::new("Title", false, Msg::Toggle).trailing(Text::new("3"));
601 assert_eq!(slots(&replaced), slots(&bare), "one end slot either way");
602 }
603
604 #[test]
606 fn the_chevron_can_be_hidden() {
607 let bare = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle);
608 let none = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle).show_trailing_icon(false);
609 assert_eq!(slots(&none) + 1, slots(&bare));
610 }
611
612 #[test]
616 fn leading_affinity_gives_the_chevron_the_front_slot() {
617 let bare = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle)
618 .control_affinity(ControlAffinity::Leading);
619 let contested = ExpansionTile::<Msg>::new("Title", false, Msg::Toggle)
620 .control_affinity(ControlAffinity::Leading)
621 .leading(Text::new("*"));
622 assert_eq!(
623 slots(&contested),
624 slots(&bare),
625 "the chevron keeps the slot"
626 );
627 }
628
629 #[test]
631 fn the_row_takes_the_colour_it_was_given() {
632 const OPEN: Color = Color::rgb(0.0, 0.6, 0.3);
633 const SHUT: Color = Color::rgb(0.9, 0.2, 0.1);
634
635 let painted = |open: bool| {
636 let tile = ExpansionTile::<Msg>::new("Title", open, Msg::Toggle)
637 .background_color(OPEN)
638 .collapsed_background_color(SHUT);
639 let ui = build_ui(
640 &tile,
641 Size::new(300.0, 200.0),
642 &Runtime::default(),
643 &Theme::default(),
644 );
645 ui.scene().primitives().iter().any(|p| match p {
646 Primitive::Rect { color, .. } => *color == if open { OPEN } else { SHUT },
647 _ => false,
648 })
649 };
650 assert!(painted(true), "the open background is painted");
651 assert!(
652 painted(false),
653 "and the shut one, which is a different colour"
654 );
655 }
656
657 #[test]
659 fn the_row_emits_the_message_it_was_handed() {
660 let tile = ExpansionTile::new("Title", false, Msg::Other);
661 assert_eq!(assembled(&tile)[0].on_click(), Some(Msg::Other));
662 }
663}