Skip to main content

frus_widgets/
collapsible.rs

1//! [`ExpansionTile`]: a tile that opens to show more.
2//!
3//! It is a [`crate::ListTile`] with a chevron and a body underneath — which is what the
4//! reference's is, and the reason it is built out of one here rather than painting a
5//! header of its own. Everything a tile can carry, this carries: something in front, a
6//! subtitle, a trailing widget of your own in place of the chevron, the tile's
7//! measurements and its colours.
8
9use 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/// Which side the chevron sits on — the reference's `ListTileControlAffinity`.
21#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
22pub enum ControlAffinity {
23    /// In front of the title, where a checkbox would go. What a file tree wants: the
24    /// chevrons line up down the left and the indentation reads as a hierarchy.
25    Leading,
26    /// After it, at the end of the row. The reference's default, and this one's.
27    #[default]
28    Trailing,
29}
30
31/// The chevron's side, matching the reference's `expand_more`.
32const CHEVRON: f32 = 24.0;
33/// The room kept round what a tile hides while it is showing. Nothing above it, so the
34/// body sits against the row; sixteen on the other three sides.
35const CHILDREN_PADDING: Insets = Insets::new(0.0, 16.0, 16.0, 16.0);
36
37/// A tile that opens to show more.
38///
39/// **Controlled**, like everything else here: it is told whether it is open and emits a
40/// message asking to change, rather than keeping the answer to itself. That is what lets
41/// a column of them behave as an accordion — the application decides only one is open —
42/// without the widget knowing anything about its siblings.
43///
44/// ```
45/// # use frus_widgets::{ExpansionTile, Text};
46/// # #[derive(Clone)] enum Msg { Toggle }
47/// ExpansionTile::new("Delivery", true, Msg::Toggle)
48///     .subtitle("Arrives Thursday")
49///     .content(Text::new("Two parcels, one signature."));
50/// ```
51pub struct ExpansionTile<Msg = crate::callback::Callback> {
52    title: String,
53    /// A header **widget** in place of the title line; see
54    /// [`ExpansionTile::title_child`].
55    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    /// **An `Option`, not an `Insets`.** A bare `Insets` cannot tell *nothing was said*
67    /// from *zero on every side*, so a theme rung under it could never fire {em} the same
68    /// trap `AccessibilityOverrides` was built to avoid in milestone 407.
69    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    /// `[tile]`, or `[tile, body]` when it is open and has one. Assembled on the first
79    /// walk, as [`crate::ListTile`] assembles its row, so that the order the builders
80    /// were called in cannot change what comes out.
81    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
82}
83
84impl<Msg: Clone + 'static> ExpansionTile<Msg> {
85    /// A tile with a title, whether it is open, and the message that asks to change that.
86    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    /// The first line as a **widget**, for a header the tile would not have made — a name
114    /// with a badge beside it, a row of chips. The reference's `headerBuilder`.
115    ///
116    /// The chevron, the tap target and the row around it stay the tile's: what this
117    /// replaces is the line of text, not the row it sits in.
118    pub fn title_child(self, title: impl Widget<Msg> + 'static) -> Self {
119        self.title_child_boxed(Box::new(title))
120    }
121
122    /// The same, taking an **already boxed** widget — what a list of panels has to hand.
123    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    /// The body, shown only while the tile is open.
129    ///
130    /// It is not built when the tile is shut — the reference's `maintainState` is what
131    /// asks for the other behaviour, and there is nothing here that a closed subtree
132    /// would be keeping alive.
133    pub fn content(self, content: impl Widget<Msg> + 'static) -> Self {
134        *self.content.borrow_mut() = Some(Box::new(content));
135        self
136    }
137
138    /// A second line under the title.
139    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
140        self.subtitle = Some(subtitle.into());
141        self
142    }
143
144    /// What goes in front of the title: an icon, an avatar, a checkbox.
145    ///
146    /// With [`ControlAffinity::Leading`] the chevron takes this place, and whatever is
147    /// given here is dropped rather than fought over — the reference does the same, and
148    /// two things claiming one slot is a bug the layout cannot report.
149    pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
150        *self.leading.borrow_mut() = Some(Box::new(leading));
151        self
152    }
153
154    /// A widget of your own at the end of the row, **in place of** the chevron: a badge,
155    /// a count, a switch. It replaces the chevron rather than joining it.
156    pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
157        *self.trailing.borrow_mut() = Some(Box::new(trailing));
158        self
159    }
160
161    /// Hides the chevron altogether, for a tile whose open state is read from something
162    /// else on the row.
163    pub fn show_trailing_icon(mut self, show: bool) -> Self {
164        self.show_trailing_icon = show;
165        self
166    }
167
168    /// Which side the chevron sits on; the end of the row by default.
169    pub fn control_affinity(mut self, affinity: ControlAffinity) -> Self {
170        self.affinity = affinity;
171        self
172    }
173
174    /// A **dense** tile: the same shape, less room around it.
175    pub fn dense(mut self) -> Self {
176        self.dense = true;
177        self
178    }
179
180    /// The room inside the tile row itself.
181    pub fn tile_padding(mut self, padding: Insets) -> Self {
182        self.tile_padding = Some(padding);
183        self
184    }
185
186    /// The room around the body, inside the tile's width.
187    pub fn children_padding(mut self, padding: Insets) -> Self {
188        self.children_padding = Some(padding);
189        self
190    }
191
192    /// The row's background **while open**.
193    pub fn background_color(mut self, color: Color) -> Self {
194        self.background = Some(color);
195        self
196    }
197
198    /// The row's background while shut.
199    pub fn collapsed_background_color(mut self, color: Color) -> Self {
200        self.collapsed_background = Some(color);
201        self
202    }
203
204    /// The title's colour while open.
205    pub fn text_color(mut self, color: Color) -> Self {
206        self.text_color = Some(color);
207        self
208    }
209
210    /// The title's colour while shut.
211    pub fn collapsed_text_color(mut self, color: Color) -> Self {
212        self.collapsed_text_color = Some(color);
213        self
214    }
215
216    /// The chevron's colour while open.
217    pub fn icon_color(mut self, color: Color) -> Self {
218        self.icon_color = Some(color);
219        self
220    }
221
222    /// The chevron's colour while shut.
223    pub fn collapsed_icon_color(mut self, color: Color) -> Self {
224        self.collapsed_icon_color = Some(color);
225        self
226    }
227
228    /// **What shape the row is while the tile is open**, over the theme's and the
229    /// framework's. The row is a [`ListTile`](crate::ListTile), which has taken a shape
230    /// since milestone 457 — its surface **and** its ink, so a rounded tile does not
231    /// splash square corners.
232    #[must_use]
233    pub fn shape(mut self, shape: ShapeBorder) -> Self {
234        self.shape = Some(shape);
235        self
236    }
237
238    /// And while it is shut. A tile that says nothing here is **not** given
239    /// [`shape`](Self::shape): the reference keeps the two apart
240    /// (`expansion_tile_theme.dart:55`), and square-when-shut, rounded-when-open is a
241    /// design rather than an oversight.
242    #[must_use]
243    pub fn collapsed_shape(mut self, shape: ShapeBorder) -> Self {
244        self.collapsed_shape = Some(shape);
245        self
246    }
247
248    /// The shorthand for a rounded rectangle on **both** states, which is what an
249    /// application usually means.
250    #[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    /// The chevron, pointing down when the tile is open and along the reading direction
257    /// when it is shut — the same pair the section header drew before this was a tile.
258    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    /// Assembles the row and, when it is open, the body under it.
278    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        // The chevron claims one slot, and whichever slot it claims wins: a widget given
284        // for the same side is dropped rather than stacked, which is what the reference
285        // does and the only answer a row of fixed slots can give.
286        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        // The two shapes stay apart on the way down as they do in the theme: a tile that
329        // named only one of them gets that one in that state and the framework's in the
330        // other.
331        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            // **No** width of its own: a cross axis left at `Auto` is stretched by the
361            // column above it, and an explicit percentage would overrule that stretch
362            // and resolve against a parent that is not definite yet — which comes out
363            // as a hug, with the chevron against the last letter of the title.
364            flex_direction: FlexDirection::Column,
365            ..Default::default()
366        }
367    }
368
369    /// Assembled on the way down, under the theme the tile actually sits in: it reaches
370    /// for text styles and colours, and a tile built at construction time inside a
371    /// [`crate::Themed`] subtree would come out in the wrong palette.
372    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    /// A tile as the walk sees it: assembled under a theme, children resolved.
400    fn assembled(tile: &ExpansionTile<Msg>) -> &[Box<dyn Widget<Msg>>] {
401        Widget::build_themed(tile, &Theme::default());
402        Widget::children(tile)
403    }
404
405    /// The row's surface and its corners, as the tile actually paints them.
406    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    /// **Every property was a builder and none of them was a theme.** A settings screen
431    /// is a column of these, so an application that wants its open sections tinted said
432    /// so on each of them — the same gap `ListTile` had until milestone 458, on the
433    /// widget that is usually built *out of* list tiles.
434    #[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        // The tile outranks it.
462        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    /// **A shut tile does not borrow the open one's shape.** The reference keeps `shape`
475    /// and `collapsedShape` apart (`expansion_tile_theme.dart:55`), and square-when-shut,
476    /// rounded-when-open is a design rather than an oversight — the same reasoning as
477    /// the drawer's `end_shape` in milestone 456.
478    #[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        // And `radius` is the shorthand that says both.
492        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    /// **`children_padding` could not be told to be zero.** It was a bare `Insets` with a
502    /// value baked in, so *nothing was said* and *zero on every side* were the same
503    /// answer, and a theme rung under it could never have fired. An `Option` is the fix,
504    /// and it is the trap `AccessibilityOverrides` was built to avoid in milestone 407.
505    #[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            // The children exist only once the tile has been walked under a theme, and a
510            // `OnceCell` means once: each case below builds its own tile.
511            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    /// Shut, only the row is built — the body is not a subtree kept alive out of sight.
537    #[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    /// Open, the body follows the row and the row asks to close.
544    #[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    /// It is a `ListTile`, so it is the reference's height rather than a header's guess.
553    #[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    /// The number of slots the row filled, leading first.
575    fn slots(tile: &ExpansionTile<Msg>) -> usize {
576        let row = &assembled(tile)[0];
577        // ListTile assembles a single row; its children are the filled slots.
578        row.build_themed(&Theme::default());
579        row.children()
580            .first()
581            .map(|inner| inner.children().len())
582            .unwrap_or(0)
583    }
584
585    /// A chevron on its own fills the trailing slot; a leading widget adds a second.
586    #[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    /// A trailing widget **replaces** the chevron: a row whose end carries a switch is
595    /// not also carrying an arrow.
596    #[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    /// Asked for nothing at the end, there is one slot fewer than with a chevron.
605    #[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    /// Leading affinity moves the chevron to the front, and the widget that wanted that
613    /// slot is dropped rather than stacked — two widgets in one slot is a bug a row of
614    /// fixed slots cannot report.
615    #[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    /// The colours are asked for, not assumed — and the open and shut states differ.
630    #[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    /// The message is the caller's, not a shape the widget invented.
658    #[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}