1use std::cell::{OnceCell, RefCell};
24
25use frus_core::{Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
26use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
27
28use crate::constraints::ConstrainedBox;
29use crate::disabled::disabled_content;
30use crate::expanded::Expanded;
31use crate::flex::Flex;
32use crate::interaction::Status;
33use crate::text::Text;
34use crate::theme::Theme;
35use crate::widget::Widget;
36
37pub const LIST_TILE_PADDING_START: f32 = 16.0;
40pub const LIST_TILE_PADDING_END: f32 = 24.0;
42pub const LIST_TILE_TITLE_GAP: f32 = 16.0;
45pub const LIST_TILE_MIN_LEADING_WIDTH: f32 = 24.0;
48pub const LIST_TILE_MIN_VERTICAL_PADDING: f32 = 8.0;
50pub const LIST_TILE_HEIGHTS: [f32; 3] = [56.0, 72.0, 88.0];
52pub const LIST_TILE_DENSE_HEIGHTS: [f32; 3] = [48.0, 64.0, 76.0];
54
55enum Slot<Msg> {
57 Text(String),
58 Child(Box<dyn Widget<Msg>>),
59}
60
61pub struct ListTile<Msg = crate::callback::Callback> {
67 leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
68 trailing: RefCell<Option<Box<dyn Widget<Msg>>>>,
69 title: RefCell<Option<Slot<Msg>>>,
70 subtitle: RefCell<Option<Slot<Msg>>>,
71 has_subtitle: bool,
72 three_line: bool,
73 dense: bool,
74 selected: bool,
75 enabled: bool,
76 on_tap: Option<Msg>,
77 padding: Option<Insets>,
78 title_gap: Option<f32>,
79 min_leading_width: Option<f32>,
80 min_height: Option<f32>,
81 title_style: Option<TextStyle>,
82 subtitle_style: Option<TextStyle>,
83 tile_color: Option<Color>,
84 selected_color: Option<Color>,
85 selected_tile_color: Option<Color>,
88 icon_color: Option<Color>,
90 text_color: Option<Color>,
92 shape: Option<ShapeBorder>,
95 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
97}
98
99impl<Msg> Default for ListTile<Msg> {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl<Msg> ListTile<Msg> {
106 pub fn new() -> Self {
108 Self {
109 leading: RefCell::new(None),
110 trailing: RefCell::new(None),
111 title: RefCell::new(None),
112 subtitle: RefCell::new(None),
113 has_subtitle: false,
114 three_line: false,
115 dense: false,
116 selected: false,
117 enabled: true,
118 on_tap: None,
119 padding: None,
120 title_gap: None,
121 min_leading_width: None,
122 min_height: None,
123 title_style: None,
124 subtitle_style: None,
125 tile_color: None,
126 selected_color: None,
127 selected_tile_color: None,
128 icon_color: None,
129 text_color: None,
130 shape: None,
131 built: OnceCell::new(),
132 }
133 }
134
135 pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
137 *self.leading.borrow_mut() = Some(Box::new(leading));
138 self
139 }
140
141 pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
143 *self.trailing.borrow_mut() = Some(Box::new(trailing));
144 self
145 }
146
147 pub fn title(self, title: impl Into<String>) -> Self {
149 *self.title.borrow_mut() = Some(Slot::Text(title.into()));
150 self
151 }
152
153 pub fn title_child(self, title: impl Widget<Msg> + 'static) -> Self {
156 *self.title.borrow_mut() = Some(Slot::Child(Box::new(title)));
157 self
158 }
159
160 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
163 *self.subtitle.borrow_mut() = Some(Slot::Text(subtitle.into()));
164 self.has_subtitle = true;
165 self
166 }
167
168 pub fn subtitle_child(mut self, subtitle: impl Widget<Msg> + 'static) -> Self {
170 *self.subtitle.borrow_mut() = Some(Slot::Child(Box::new(subtitle)));
171 self.has_subtitle = true;
172 self
173 }
174
175 pub fn three_line(mut self) -> Self {
177 self.three_line = true;
178 self
179 }
180
181 pub fn dense(mut self) -> Self {
183 self.dense = true;
184 self
185 }
186
187 pub fn selected(mut self, selected: bool) -> Self {
190 self.selected = selected;
191 self
192 }
193
194 pub fn enabled(mut self, enabled: bool) -> Self {
196 self.enabled = enabled;
197 self
198 }
199
200 pub fn on_tap(mut self, msg: impl Into<Msg>) -> Self {
202 self.on_tap = Some(msg.into());
203 self
204 }
205
206 pub fn padding(mut self, padding: Insets) -> Self {
208 self.padding = Some(padding);
209 self
210 }
211
212 pub fn title_gap(mut self, gap: f32) -> Self {
214 self.title_gap = Some(gap);
215 self
216 }
217
218 pub fn min_leading_width(mut self, width: f32) -> Self {
220 self.min_leading_width = Some(width);
221 self
222 }
223
224 pub fn min_height(mut self, height: f32) -> Self {
226 self.min_height = Some(height);
227 self
228 }
229
230 pub fn title_style(mut self, style: TextStyle) -> Self {
232 self.title_style = Some(style);
233 self
234 }
235
236 pub fn subtitle_style(mut self, style: TextStyle) -> Self {
238 self.subtitle_style = Some(style);
239 self
240 }
241
242 pub fn tile_color(mut self, color: Color) -> Self {
245 self.tile_color = Some(color);
246 self
247 }
248
249 pub fn selected_color(mut self, color: Color) -> Self {
251 self.selected_color = Some(color);
252 self
253 }
254
255 pub fn selected_tile_color(mut self, color: Color) -> Self {
263 self.selected_tile_color = Some(color);
264 self
265 }
266
267 pub fn icon_color(mut self, color: Color) -> Self {
270 self.icon_color = Some(color);
271 self
272 }
273
274 pub fn text_color(mut self, color: Color) -> Self {
277 self.text_color = Some(color);
278 self
279 }
280
281 #[must_use]
284 pub fn shape(mut self, shape: ShapeBorder) -> Self {
285 self.shape = Some(shape);
286 self
287 }
288
289 fn shape_of(&self, theme: &Theme) -> ShapeBorder {
292 self.shape
293 .or(theme.widgets.list_tile.shape)
294 .unwrap_or(ShapeBorder::rounded(0.0))
295 }
296
297 pub fn height(&self) -> f32 {
300 self.height_for(&Theme::default())
301 }
302
303 fn height_for(&self, theme: &Theme) -> f32 {
309 let t = &theme.widgets.list_tile;
310 if let Some(height) = self.min_height.or(t.min_height) {
311 return height;
312 }
313 let row = if self.three_line {
314 2
315 } else if self.has_subtitle {
316 1
317 } else {
318 0
319 };
320 if self.dense || t.dense.unwrap_or(false) {
321 LIST_TILE_DENSE_HEIGHTS[row]
322 } else {
323 LIST_TILE_HEIGHTS[row]
324 }
325 }
326
327 fn content_color(&self, theme: &Theme, subtitle: bool) -> Color {
330 let t = &theme.widgets.list_tile;
331 if !self.enabled {
332 disabled_content(theme)
333 } else if self.selected {
334 self.selected_color
335 .or(t.selected_color)
336 .unwrap_or(theme.primary)
337 } else if let Some(color) = self.text_color.or(t.text_color) {
338 color
339 } else if subtitle {
340 theme.scheme.on_surface_variant
341 } else {
342 theme.scheme.on_surface
343 }
344 }
345}
346
347impl<Msg: Clone + 'static> ListTile<Msg> {
348 fn row(&self, theme: &Theme) -> &[Box<dyn Widget<Msg>>] {
354 self.built.get_or_init(|| {
355 let t = &theme.widgets.list_tile;
356 let gap = self
357 .title_gap
358 .or(t.title_gap)
359 .unwrap_or(LIST_TILE_TITLE_GAP);
360 let mut row = Flex::row().align(Align::Center).gap(gap);
361
362 if let Some(leading) = self.leading.borrow_mut().take() {
363 let min = self
364 .min_leading_width
365 .or(t.min_leading_width)
366 .unwrap_or(LIST_TILE_MIN_LEADING_WIDTH);
367 row = row.child(ConstrainedBox::new_boxed(leading).min_width(min));
368 }
369
370 let mut column = Flex::column().align(Align::Start).justify(Justify::Center);
373 for (slot, subtitle) in [(&self.title, false), (&self.subtitle, true)] {
374 let taken = slot.borrow_mut().take();
375 match taken {
376 Some(Slot::Child(child)) => column = column.child_boxed(child),
377 Some(Slot::Text(content)) => {
378 let base = if subtitle {
379 self.subtitle_style
380 .or(t.subtitle_style)
381 .unwrap_or(theme.text.body_medium)
382 } else {
383 self.title_style
384 .or(t.title_style)
385 .unwrap_or(theme.text.body_large)
386 };
387 let mut text =
388 Text::styled(content, base).color(self.content_color(theme, subtitle));
389 if subtitle && self.three_line {
393 text = text.wrap();
394 } else {
395 text = text.ellipsis();
396 }
397 column = column.child(text);
398 }
399 None => {}
400 }
401 }
402 row = row.child(Expanded::new(column));
403
404 if let Some(trailing) = self.trailing.borrow_mut().take() {
405 row = row.child_boxed(trailing);
406 }
407 vec![Box::new(Expanded::new(row)) as Box<dyn Widget<Msg>>]
421 })
422 }
423}
424
425impl<Msg: Clone + 'static> Widget<Msg> for ListTile<Msg> {
426 fn style(&self) -> Style {
427 Widget::<Msg>::style_themed(self, &Theme::default())
428 }
429
430 fn style_themed(&self, theme: &Theme) -> Style {
437 Style {
438 min_height: Dimension::Length(self.height_for(theme)),
439 flex_direction: FlexDirection::Row,
440 align: Align::Center,
441 padding: self
442 .padding
443 .or(theme.widgets.list_tile.content_padding)
444 .unwrap_or(Insets::new(
445 LIST_TILE_MIN_VERTICAL_PADDING,
446 LIST_TILE_PADDING_END,
447 LIST_TILE_MIN_VERTICAL_PADDING,
448 LIST_TILE_PADDING_START,
449 )),
450 ..Default::default()
451 }
452 }
453
454 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
457 crate::widget::FillAxes::WIDTH
458 }
459
460 fn build_themed(&self, theme: &Theme) {
461 self.row(theme);
462 }
463
464 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
465 self.built.get().map(|v| &v[..]).unwrap_or(&[])
466 }
467
468 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
469 let t = &theme.widgets.list_tile;
474 let base = if self.selected {
475 self.selected_tile_color
476 .or(t.selected_tile_color)
477 .or(self.tile_color)
478 .or(t.tile_color)
479 } else {
480 self.tile_color.or(t.tile_color)
481 }
482 .unwrap_or(Color::TRANSPARENT);
483 let color = if self.enabled && self.on_tap.is_some() {
484 theme.state_layer(base, theme.scheme.on_surface, &status)
485 } else {
486 base
487 };
488 if color.a > 0.0 {
489 scene.draw_shape(bounds, self.shape_of(theme), color.fade(status.opacity));
490 }
491 }
492
493 fn on_click(&self) -> Option<Msg> {
494 self.enabled.then(|| self.on_tap.clone()).flatten()
495 }
496
497 fn ink(&self, theme: &Theme) -> Option<crate::ink::InkStyle> {
498 (self.enabled && self.on_tap.is_some()).then(|| {
499 let mut ink = crate::ink::InkStyle::of(theme);
500 if let Some((_, radius)) =
503 self.shape_of(theme)
504 .as_rounded(Rect::new(0.0, 0.0, 1000.0, self.height_for(theme)))
505 {
506 ink.radius = radius;
507 }
508 ink
509 })
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 #[test]
523 fn a_theme_answers_for_every_tile() {
524 let mut theme = Theme::default();
525 theme.widgets.list_tile.tile_color = Some(Color::rgb(0.2, 0.4, 0.6));
526 theme.widgets.list_tile.selected_tile_color = Some(Color::rgb(0.6, 0.4, 0.2));
527 theme.widgets.list_tile.shape = Some(frus_core::ShapeBorder::rounded(7.0));
528 theme.widgets.list_tile.min_height = Some(99.0);
529 theme.widgets.list_tile.content_padding = Some(Insets::uniform(3.0));
530
531 let painted = |tile: &ListTile<Msg>, theme: &Theme| {
532 let mut scene = Scene::new();
533 Widget::<Msg>::paint(
534 tile,
535 Rect::new(0.0, 0.0, 300.0, 56.0),
536 Status::default(),
537 theme,
538 &mut scene,
539 );
540 scene.primitives().iter().find_map(|p| match p {
541 frus_core::Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
542 _ => None,
543 })
544 };
545
546 let plain = ListTile::<Msg>::new();
547 assert_eq!(
548 painted(&plain, &theme),
549 Some((
550 Color::rgb(0.2, 0.4, 0.6),
551 frus_core::BorderRadius::uniform(7.0)
552 )),
553 "the theme's surface and the theme's shape"
554 );
555 assert_eq!(
556 painted(&ListTile::<Msg>::new().selected(true), &theme)
557 .expect("a surface")
558 .0,
559 Color::rgb(0.6, 0.4, 0.2),
560 "and its selected surface while the tile is the chosen one"
561 );
562
563 let told = ListTile::<Msg>::new().tile_color(Color::rgb(0.9, 0.9, 0.9));
565 assert_eq!(
566 painted(&told, &theme).expect("a surface").0,
567 Color::rgb(0.9, 0.9, 0.9)
568 );
569
570 let style = Widget::<Msg>::style_themed(&plain, &theme);
572 assert_eq!(style.min_height, Dimension::Length(99.0));
573 assert_eq!(style.padding, Insets::uniform(3.0));
574
575 assert_eq!(
577 Widget::<Msg>::style_themed(&plain, &Theme::default()).min_height,
578 Dimension::Length(LIST_TILE_HEIGHTS[0])
579 );
580 }
581
582 #[test]
589 fn a_selected_tile_has_a_surface_of_its_own() {
590 let surface = |tile: &ListTile<Msg>| {
591 let mut scene = Scene::new();
592 Widget::<Msg>::paint(
593 tile,
594 Rect::new(0.0, 0.0, 300.0, 56.0),
595 Status::default(),
596 &Theme::default(),
597 &mut scene,
598 );
599 scene.primitives().iter().find_map(|p| match p {
600 frus_core::Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
601 _ => None,
602 })
603 };
604
605 assert_eq!(surface(&ListTile::<Msg>::new().selected(true)), None);
607
608 let chosen = Color::rgb(0.1, 0.2, 0.3);
609 assert_eq!(
610 surface(
611 &ListTile::<Msg>::new()
612 .selected(true)
613 .selected_tile_color(chosen)
614 )
615 .expect("a surface")
616 .0,
617 chosen
618 );
619 assert_eq!(
621 surface(&ListTile::<Msg>::new().selected_tile_color(chosen)),
622 None
623 );
624 let plain = Color::rgb(0.9, 0.9, 0.9);
626 assert_eq!(
627 surface(
628 &ListTile::<Msg>::new()
629 .tile_color(plain)
630 .selected_tile_color(chosen)
631 )
632 .expect("a surface")
633 .0,
634 plain,
635 "unselected"
636 );
637 assert_eq!(
638 surface(
639 &ListTile::<Msg>::new()
640 .selected(true)
641 .tile_color(plain)
642 .selected_tile_color(chosen)
643 )
644 .expect("a surface")
645 .0,
646 chosen,
647 "selected"
648 );
649 }
650
651 #[test]
654 fn a_tile_takes_a_shape_and_the_ink_takes_it_too() {
655 let tile = ListTile::<Msg>::new()
656 .tile_color(Color::rgb(0.5, 0.5, 0.5))
657 .on_tap(Msg::Tapped)
658 .shape(frus_core::ShapeBorder::rounded(12.0));
659 let mut scene = Scene::new();
660 Widget::<Msg>::paint(
661 &tile,
662 Rect::new(0.0, 0.0, 300.0, 56.0),
663 Status::default(),
664 &Theme::default(),
665 &mut scene,
666 );
667 let radius = scene.primitives().iter().find_map(|p| match p {
668 frus_core::Primitive::Rect { radius, .. } => Some(*radius),
669 _ => None,
670 });
671 assert_eq!(radius, Some(frus_core::BorderRadius::uniform(12.0)));
672 assert_eq!(
673 Widget::<Msg>::ink(&tile, &Theme::default())
674 .expect("a tappable tile inks")
675 .radius,
676 frus_core::BorderRadius::uniform(12.0),
677 "and the ink is clipped to the same shape"
678 );
679 }
680
681 #[derive(Clone, Debug, PartialEq)]
682 enum Msg {
683 Tapped,
684 }
685
686 #[test]
688 fn the_height_follows_the_line_count() {
689 let one: ListTile<Msg> = ListTile::new().title("a");
690 assert_eq!(one.height(), 56.0);
691 let two: ListTile<Msg> = ListTile::new().title("a").subtitle("b");
692 assert_eq!(two.height(), 72.0);
693 let three: ListTile<Msg> = ListTile::new().title("a").subtitle("b").three_line();
694 assert_eq!(three.height(), 88.0);
695 let dense: ListTile<Msg> = ListTile::new().title("a").dense();
696 assert_eq!(dense.height(), 48.0);
697 let asked: ListTile<Msg> = ListTile::new().title("a").min_height(100.0);
698 assert_eq!(asked.height(), 100.0);
699 }
700
701 #[test]
703 fn it_assembles_the_slots_it_was_given() {
704 let theme = Theme::default();
705 let bare: ListTile<Msg> = ListTile::new().title("only a title");
706 Widget::<Msg>::build_themed(&bare, &theme);
707 let row = &Widget::<Msg>::children(&bare)[0];
708 assert_eq!(row.children().len(), 1, "just the text column");
709
710 let full: ListTile<Msg> = ListTile::new()
711 .leading(crate::Icon::new(crate::Icons::STAR))
712 .title("title")
713 .subtitle("subtitle")
714 .trailing(crate::Icon::new(crate::Icons::CHEVRON_RIGHT));
715 Widget::<Msg>::build_themed(&full, &theme);
716 let row = &Widget::<Msg>::children(&full)[0];
717 assert_eq!(row.children().len(), 3, "leading, text, trailing");
718 }
719
720 #[test]
722 fn a_disabled_tile_is_inert_and_flattened() {
723 let theme = Theme::default();
724 let tile: ListTile<Msg> = ListTile::new()
725 .title("x")
726 .on_tap(Msg::Tapped)
727 .enabled(false);
728 assert_eq!(Widget::<Msg>::on_click(&tile), None);
729 assert_eq!(tile.content_color(&theme, false), disabled_content(&theme));
730 assert!(Widget::<Msg>::ink(&tile, &theme).is_none());
731 }
732
733 #[test]
736 fn selected_takes_the_primary_and_disabled_takes_it_back() {
737 let theme = Theme::default();
738 let chosen: ListTile<Msg> = ListTile::new().title("x").selected(true);
739 assert_eq!(chosen.content_color(&theme, false), theme.primary);
740 assert_eq!(chosen.content_color(&theme, true), theme.primary);
741
742 let both: ListTile<Msg> = ListTile::new().title("x").selected(true).enabled(false);
743 assert_eq!(both.content_color(&theme, false), disabled_content(&theme));
744 }
745
746 #[test]
749 fn the_two_lines_take_their_own_roles() {
750 let theme = Theme::default();
751 let tile: ListTile<Msg> = ListTile::new().title("x").subtitle("y");
752 assert_eq!(tile.content_color(&theme, false), theme.scheme.on_surface);
753 assert_eq!(
754 tile.content_color(&theme, true),
755 theme.scheme.on_surface_variant
756 );
757 }
758
759 #[test]
760 fn a_tap_sends_its_message() {
761 let tile: ListTile<Msg> = ListTile::new().title("x").on_tap(Msg::Tapped);
762 assert_eq!(Widget::<Msg>::on_click(&tile), Some(Msg::Tapped));
763 }
764}
765
766#[cfg(test)]
767mod fill_tests {
768 use super::*;
769 use crate::{build_ui, Container, Runtime, Size};
770 use frus_core::Primitive;
771
772 const END: Color = Color::rgb(1.0, 0.0, 0.0);
773
774 fn trailing_rect(width: f32) -> Rect {
776 rect_with(width, "Row")
777 }
778
779 fn rect_with(width: f32, title: &str) -> Rect {
781 let tile: ListTile<()> = ListTile::new()
782 .title(title.to_string())
783 .trailing(Container::<()>::new().width(24.0).height(24.0).color(END));
784 let root = Container::<()>::new().width(width).child(tile);
785 build_ui(
786 &root,
787 Size::new(width, 80.0),
788 &Runtime::default(),
789 &Theme::default(),
790 )
791 .scene()
792 .primitives()
793 .iter()
794 .find_map(|p| match p {
795 Primitive::Rect { rect, color, .. } if *color == END => Some(*rect),
796 _ => None,
797 })
798 .expect("the trailing slot is painted")
799 }
800
801 #[test]
815 fn a_title_too_long_is_cut_rather_than_carried_past_the_edge() {
816 let long = "A title far too long for two hundred pixels of row";
817 let rect = rect_with(200.0, long);
818 assert!(
819 rect.x + rect.width <= 200.0 + 1.0,
820 "the slot ends at {} rather than inside the tile",
821 rect.x + rect.width
822 );
823 }
824
825 #[test]
826 fn the_trailing_slot_reaches_the_end_of_the_row() {
827 for width in [200.0, 320.0, 500.0] {
828 let rect = trailing_rect(width);
829 assert!(
830 (rect.x + rect.width - (width - LIST_TILE_PADDING_END)).abs() <= 1.0,
831 "at {width}: the slot ends at {} rather than {}",
832 rect.x + rect.width,
833 width - LIST_TILE_PADDING_END
834 );
835 }
836 }
837 #[test]
855 fn widgets_that_fill_the_width_do_it_in_a_column_too() {
856 use crate::{build_ui_inspected, Runtime};
857 use frus_core::Size;
858 type W = Box<dyn Widget<()>>;
859 type Case = (&'static str, fn() -> W);
860 let cases: Vec<Case> = vec![
861 ("ListTile", || Box::new(ListTile::new().title("A row"))),
862 ("BottomAppBar", || Box::new(crate::BottomAppBar::new())),
863 ("BottomSheet", || Box::new(crate::BottomSheet::new(true))),
864 ("Drawer", || Box::new(crate::Drawer::new(true))),
865 ("Steps", || Box::new(crate::steps::Steps::new(["a", "b"]))),
866 ("NavScaffold", || {
870 Box::new(crate::NavScaffold::new(
871 frus_core::SizeClass::Expanded,
872 0,
873 |_| (),
874 ))
875 }),
876 ("TwoPane", || {
877 Box::new(crate::TwoPane::new(frus_core::SizeClass::Expanded))
878 }),
879 ];
880 for (name, make) in cases {
881 for (what, root) in [
882 ("alone", make()),
883 (
884 "in a column",
885 Box::new(crate::Flex::column().child_boxed(make())) as W,
886 ),
887 ] {
888 let (_, nodes) = build_ui_inspected(
889 root.as_ref(),
890 Size::new(400.0, 300.0),
891 &Runtime::default(),
892 &Theme::default(),
893 );
894 let width = nodes
895 .iter()
896 .find(|n| n.name == name)
897 .map(|n| n.rect.width)
898 .unwrap_or_else(|| panic!("{name} is not in the tree"));
899 assert_eq!(
900 width, 400.0,
901 "{name} {what}: {width} of the 400 it was offered"
902 );
903 }
904 }
905 }
906}