Skip to main content

frus_widgets/
listtile.rs

1//! [`ListTile`]: a row of a list — something in front, one or two lines of text, and
2//! something behind.
3//!
4//! The most reached-for row in the reference's catalogue, and the shape behind a drawer
5//! entry, a settings line, a contact, a menu item. It was the largest single gap in this
6//! framework's coverage of that catalogue.
7//!
8//! ```ignore
9//! ListTile::new()
10//!     .leading(Icon::new(Icons::STAR))
11//!     .title("Starred")
12//!     .subtitle("Everything you kept")
13//!     .trailing(Icon::new(Icons::CHEVRON_RIGHT))
14//!     .on_tap(Msg::OpenStarred)
15//! ```
16//!
17//! The measurements are the reference's Material 3 defaults: 16 px in front, 24 behind, a
18//! 16 px gap either side of the text, and a height of **56** for one line, **72** for two
19//! and **88** for three — 48 / 64 / 76 when dense. Every one of them is overridable, and
20//! each of the four slots takes a widget when a caller wants something the tile would not
21//! have made.
22
23use 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
37/// Room in front of the leading slot — the start of the reference's
38/// `EdgeInsetsDirectional.only(start: 16, end: 24)`.
39pub const LIST_TILE_PADDING_START: f32 = 16.0;
40/// Room behind the trailing slot. See [`LIST_TILE_PADDING_START`].
41pub const LIST_TILE_PADDING_END: f32 = 24.0;
42/// The gap between the leading slot and the text, and between the text and the trailing
43/// slot (`horizontalTitleGap`).
44pub const LIST_TILE_TITLE_GAP: f32 = 16.0;
45/// The narrowest the leading slot may be, so that a column of tiles lines its text up
46/// whatever each row put in front of it (`minLeadingWidth`).
47pub const LIST_TILE_MIN_LEADING_WIDTH: f32 = 24.0;
48/// The least room above and below the text (`minVerticalPadding`).
49pub const LIST_TILE_MIN_VERTICAL_PADDING: f32 = 8.0;
50/// Heights by line count — one, two, three.
51pub const LIST_TILE_HEIGHTS: [f32; 3] = [56.0, 72.0, 88.0];
52/// The same, dense. See [`LIST_TILE_HEIGHTS`].
53pub const LIST_TILE_DENSE_HEIGHTS: [f32; 3] = [48.0, 64.0, 76.0];
54
55/// A text slot: a string the tile styles itself, or a widget the caller styled.
56enum Slot<Msg> {
57    Text(String),
58    Child(Box<dyn Widget<Msg>>),
59}
60
61/// A row of a list: leading, title, subtitle, trailing.
62///
63/// The reference's Material 3 measurements — 16 px in front, 24 behind, a 16 px gap
64/// either side of the text, heights of 56 / 72 / 88 by line count and 48 / 64 / 76 dense —
65/// and every one of them replaceable, as is each of the four slots.
66pub 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    /// The tile's surface **while it is the chosen one** — the reference's
86    /// `selectedTileColor`.
87    selected_tile_color: Option<Color>,
88    /// The colour of the two slots' icons, over what the selection would give them.
89    icon_color: Option<Color>,
90    /// The colour of the title and subtitle, over what the selection would give them.
91    text_color: Option<Color>,
92    /// What shape the tile is — the reference's `shape`. The surface and the ink both
93    /// take it, so a rounded tile in a list is rounded all the way through.
94    shape: Option<ShapeBorder>,
95    /// The assembled row, as the one-element slice [`Widget::children`] hands back.
96    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    /// An empty tile. Give it at least a title.
107    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    /// What goes in front: an icon, an avatar, a checkbox.
136    pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
137        *self.leading.borrow_mut() = Some(Box::new(leading));
138        self
139    }
140
141    /// What goes behind: a chevron, a switch, a timestamp.
142    pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
143        *self.trailing.borrow_mut() = Some(Box::new(trailing));
144        self
145    }
146
147    /// The first line, styled by the tile (`body_large` on `on_surface`).
148    pub fn title(self, title: impl Into<String>) -> Self {
149        *self.title.borrow_mut() = Some(Slot::Text(title.into()));
150        self
151    }
152
153    /// The first line as a **widget**, for a caller who wants something the tile would
154    /// not have made — a rich span, a row of chips.
155    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    /// The second line, styled by the tile (`body_medium` on `on_surface_variant`).
161    /// Its presence is what makes the tile a two-line one.
162    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    /// The second line as a **widget**. See [`Self::title_child`].
169    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    /// Room for three lines rather than two, for a subtitle that wraps.
176    pub fn three_line(mut self) -> Self {
177        self.three_line = true;
178        self
179    }
180
181    /// The compact height, for a dense list.
182    pub fn dense(mut self) -> Self {
183        self.dense = true;
184        self
185    }
186
187    /// Marks the tile as the chosen one: its text and its two slots take the primary
188    /// colour.
189    pub fn selected(mut self, selected: bool) -> Self {
190        self.selected = selected;
191        self
192    }
193
194    /// Available or not. A disabled tile flattens and stops answering.
195    pub fn enabled(mut self, enabled: bool) -> Self {
196        self.enabled = enabled;
197        self
198    }
199
200    /// The message a tap sends.
201    pub fn on_tap(mut self, msg: impl Into<Msg>) -> Self {
202        self.on_tap = Some(msg.into());
203        self
204    }
205
206    /// Room around the content, replacing the 16 / 24 default.
207    pub fn padding(mut self, padding: Insets) -> Self {
208        self.padding = Some(padding);
209        self
210    }
211
212    /// The gap either side of the text.
213    pub fn title_gap(mut self, gap: f32) -> Self {
214        self.title_gap = Some(gap);
215        self
216    }
217
218    /// The narrowest the leading slot may be.
219    pub fn min_leading_width(mut self, width: f32) -> Self {
220        self.min_leading_width = Some(width);
221        self
222    }
223
224    /// A height of the caller's choosing, replacing the one the line count implies.
225    pub fn min_height(mut self, height: f32) -> Self {
226        self.min_height = Some(height);
227        self
228    }
229
230    /// The title's text style.
231    pub fn title_style(mut self, style: TextStyle) -> Self {
232        self.title_style = Some(style);
233        self
234    }
235
236    /// The subtitle's text style.
237    pub fn subtitle_style(mut self, style: TextStyle) -> Self {
238        self.subtitle_style = Some(style);
239        self
240    }
241
242    /// The tile's own background. Transparent by default, as the reference has it: a
243    /// tile takes the colour of whatever it is sitting on.
244    pub fn tile_color(mut self, color: Color) -> Self {
245        self.tile_color = Some(color);
246        self
247    }
248
249    /// The colour the text and the two slots take when the tile is the chosen one.
250    pub fn selected_color(mut self, color: Color) -> Self {
251        self.selected_color = Some(color);
252        self
253    }
254
255    /// **The tile's surface while it is the chosen one** — the reference's
256    /// `selectedTileColor` (`list_tile.dart`).
257    ///
258    /// A selected tile used to change nothing but the colour of its words, which is the
259    /// weakest possible way to say *this is the one you are on*: it is the difference
260    /// between a highlighted row in a navigation list and a row that merely reads
261    /// differently. Unset, the tile keeps its ordinary surface, as the reference's does.
262    pub fn selected_tile_color(mut self, color: Color) -> Self {
263        self.selected_tile_color = Some(color);
264        self
265    }
266
267    /// **The two slots' icon colour**, over what the selection would give them — the
268    /// reference's `iconColor`.
269    pub fn icon_color(mut self, color: Color) -> Self {
270        self.icon_color = Some(color);
271        self
272    }
273
274    /// **The title's and subtitle's colour**, over what the selection would give them —
275    /// the reference's `textColor`.
276    pub fn text_color(mut self, color: Color) -> Self {
277        self.text_color = Some(color);
278        self
279    }
280
281    /// **What shape the tile is** — the reference's `shape`. The surface takes it, and so
282    /// does the ink, so a rounded tile does not splash square corners over a list.
283    #[must_use]
284    pub fn shape(mut self, shape: ShapeBorder) -> Self {
285        self.shape = Some(shape);
286        self
287    }
288
289    /// **What shape the tile paints as**: the caller's, then a plain rectangle — which is
290    /// what a row in a list is unless somebody says otherwise.
291    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    /// The height this tile asks for: the reference's, by line count and density, unless
298    /// the caller named one.
299    pub fn height(&self) -> f32 {
300        self.height_for(&Theme::default())
301    }
302
303    /// The same, against a theme that may have an opinion — the shape [`style`] and
304    /// [`height`] already have between them, one file over.
305    ///
306    /// [`style`]: crate::Widget::style
307    /// [`height`]: Self::height
308    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    /// The colour a line of text resolves to. Disabled wins over chosen, as everywhere
328    /// else: a tile that cannot be picked does not advertise that it was.
329    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    /// Assembles the row, once, under the theme in force where the tile sits.
349    ///
350    /// Not at construction time: a theme reaches text styles and colours, and a tile
351    /// built under `Theme::default()` inside a [`crate::Themed`] subtree would come out
352    /// in the wrong palette.
353    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            // The text column is the one part that gives way when the row is narrow:
371            // the slots either side keep their size and the lines are cut.
372            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                        // A tile is a fixed height, so a line that wrapped would run out
390                        // of the bottom of it — except the subtitle of a three-line tile,
391                        // which is what the extra room is for.
392                        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            // The row is **`Expanded`**, and both halves of that matter.
408            //
409            // It **grows** into the tile rather than hugging its slots: left to hug, the
410            // tile is the right width and the row inside it is not, so the trailing slot
411            // comes to rest against the title instead of at the end of the line — a badge
412            // drawn halfway across a row, and the whole reason the text column between
413            // them is an `Expanded` in the first place.
414            //
415            // And it **gives way** rather than running past the edge. Since milestone 349
416            // nothing is squeezed unless it says so, so a title too long for the row
417            // pushed it straight through the tile: 265 px outside a 200 px box, reported
418            // by the overflow band and drawn off the side of the list. `Expanded` says
419            // both at once — grow, shrink, and no content-sized floor underneath.
420            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    /// It asks to **fill the width it is offered** rather than declaring one.
431    ///
432    /// See [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's
433    /// *resolved* width, which a parent that shrink-wraps does not have yet — so a tile in
434    /// a plain column, the most ordinary thing anybody does with one, came out as wide as
435    /// its own padding and ellipsised its title to nothing.
436    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    /// The width it was offered, not the width its parent came out at — the difference
455    /// between a number known on the way **down** and one only known on the way back up.
456    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        // Transparent by default, as the reference has it, so a tile takes the colour of
470        // whatever it sits on; a state layer over that when it can be tapped.
471        // A selected tile takes its own surface first: the reference's
472        // `selectedTileColor` outranks `tileColor` while the tile is the chosen one.
473        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            // The ink is clipped to the tile, so it has to know the same shape the surface
501            // does — otherwise a rounded tile splashes square corners over the list.
502            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    /// **An application can say it once** — the reference's `ListTileThemeData`, which
517    /// this had none of at all. Every property a tile has was the caller's alone, so an
518    /// application wanting all its tiles rounded said so on every tile it ever built.
519    ///
520    /// The rungs are the usual ones: the tile's own word, then the theme's, then the
521    /// framework's. This walks the ones that show up in a painted frame.
522    #[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        // The tile still outranks it.
564        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        // The box, too: the height and the room inside it.
571        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        // And the height with nothing said is what it always was.
576        assert_eq!(
577            Widget::<Msg>::style_themed(&plain, &Theme::default()).min_height,
578            Dimension::Length(LIST_TILE_HEIGHTS[0])
579        );
580    }
581
582    /// **A selected tile has a surface of its own** (`list_tile.dart`'s
583    /// `selectedTileColor`), which it did not.
584    ///
585    /// Being *the one you are on* used to change nothing but the colour of the words — the
586    /// weakest possible way to say it, and the difference between a highlighted row in a
587    /// navigation list and a row that merely reads differently.
588    #[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        // Nothing said: a tile is transparent and paints no surface at all, as before.
606        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        // And only while it is the chosen one.
620        assert_eq!(
621            surface(&ListTile::<Msg>::new().selected_tile_color(chosen)),
622            None
623        );
624        // It outranks the ordinary surface rather than replacing it everywhere.
625        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    /// **And a shape**, which the surface and the ink both take — a rounded tile that
652    /// splashed square corners would be worse than one that was never rounded.
653    #[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    /// The reference's heights, by line count and density.
687    #[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    /// The four slots are assembled in order, and only those that were given.
702    #[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    /// A disabled tile does not answer, and says so in its colour before it is asked.
721    #[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    /// Chosen wins over the ordinary roles, and disabled wins over chosen: a tile that
734    /// cannot be picked does not advertise that it was.
735    #[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    /// Untouched, the two lines take the reference's roles: `on_surface` for the title,
747    /// `on_surface_variant` for the one below it.
748    #[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    /// The box the trailing slot was drawn in, laid out at a definite width.
775    fn trailing_rect(width: f32) -> Rect {
776        rect_with(width, "Row")
777    }
778
779    /// The same, with the title the caller chooses.
780    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    /// The trailing slot belongs at the **end** of the row.
802    ///
803    /// It did not get there for a long time: the tile was the right width and the row
804    /// inside it was not, so the row hugged its slots and the `Expanded` text column
805    /// between them had nothing to push against — every badge, count and chevron drew
806    /// itself halfway across the line. Milestone 368 found it with a chevron; this is the
807    /// assertion that keeps it found.
808    /// A title too long for the row is **cut**, not carried past the edge.
809    ///
810    /// The same fix has both halves: a row that only grows fills the tile and then runs
811    /// straight through it when its content is wider, because since milestone 349 nothing
812    /// is squeezed unless it says so. The trailing slot ended up 111 px outside a 200 px
813    /// tile, which is a badge drawn off the side of a list.
814    #[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    /// **A widget that fills the width does it in a column too**, which is where the
838    /// framework's own idiom for it was wrong in fifteen places at once.
839    ///
840    /// `width: 100%` resolves against the parent's **resolved** width. A parent that
841    /// shrink-wraps has not got one yet — it is waiting on this very child — so the
842    /// percentage resolves against nothing and the box comes out empty. A list tile in a
843    /// plain column, the most ordinary thing anybody does with one, was as wide as its own
844    /// padding and ellipsised its title away.
845    ///
846    /// The fix is to *ask* rather than declare: `fill_axes` is answered by the walk,
847    /// which knows the room being offered on the way **down**, where a parent's own width
848    /// is only known on the way back up. Both readings are "full width" in English and only
849    /// one of them can be computed in time.
850    ///
851    /// Each widget is checked **alone and in a column** because alone is where the bug
852    /// hides: a percentage against a definite parent is right, so every fixture that gave
853    /// a width passed and no golden ever caught this.
854    #[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            // Milestone 405: shells that want **both** axes. They could not say so while
867            // the hook answered with one direction, so they kept the percentage that made
868            // them vanish.
869            ("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}