Skip to main content

frus_widgets/
controltile.rs

1//! [`CheckboxListTile`], [`RadioListTile`] and [`SwitchListTile`]: a row of a list whose
2//! **whole width** works one control.
3//!
4//! Three of the reference's widgets (`checkbox_list_tile.dart`, `radio_list_tile.dart`,
5//! `switch_list_tile.dart`), none of which existed here. The parts did: a
6//! [`ListTile`](crate::ListTile) that takes two slots and a tap, and the three controls.
7//! What was missing is the thing that says *this row and this control are one control* —
8//! which is not a convenience. A settings screen where only the 20-pixel box answers and
9//! the label beside it does nothing is a screen where most of the taps go nowhere.
10
11use std::cell::{OnceCell, RefCell};
12
13use frus_core::{Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
14use frus_layout::{FlexDirection, Style};
15
16use crate::collapsible::ControlAffinity;
17use crate::interaction::Status;
18use crate::theme::Theme;
19use crate::widget::Widget;
20use crate::{Checkbox, ListTile, Radio, Switch};
21
22/// Everything the three share, which is nearly all of it: a list tile's row, and the one
23/// decision about which side the control sits on.
24struct Row<Msg> {
25    title: Option<String>,
26    subtitle: Option<String>,
27    /// The widget in the **other** slot — the reference's `secondary`, usually an icon.
28    /// Taken on the first walk, as [`crate::ExpansionTile`] takes its slots.
29    secondary: RefCell<Option<Box<dyn Widget<Msg>>>>,
30    affinity: ControlAffinity,
31    dense: bool,
32    three_line: bool,
33    selected: bool,
34    enabled: bool,
35    tile_color: Option<Color>,
36    selected_tile_color: Option<Color>,
37    shape: Option<ShapeBorder>,
38    padding: Option<Insets>,
39    title_style: Option<TextStyle>,
40    subtitle_style: Option<TextStyle>,
41}
42
43impl<Msg> Default for Row<Msg> {
44    fn default() -> Self {
45        Self {
46            title: None,
47            subtitle: None,
48            secondary: RefCell::new(None),
49            affinity: ControlAffinity::Trailing,
50            dense: false,
51            three_line: false,
52            selected: false,
53            enabled: true,
54            tile_color: None,
55            selected_tile_color: None,
56            shape: None,
57            padding: None,
58            title_style: None,
59            subtitle_style: None,
60        }
61    }
62}
63
64impl<Msg: Clone + 'static> Row<Msg> {
65    /// Assembles the tile: the control in the slot its affinity names, the `secondary` in
66    /// the other, and **the whole row wired to the same message the control is**.
67    ///
68    /// That last part is the widget. It is what the reference's three do
69    /// (`checkbox_list_tile.dart`), and it is why the label is as good a target as the
70    /// box.
71    fn tile(&self, control: Box<dyn Widget<Msg>>, on_tap: Option<Msg>) -> ListTile<Msg> {
72        let mut tile = ListTile::new();
73        if let Some(title) = &self.title {
74            tile = tile.title(title.clone());
75        }
76        if let Some(subtitle) = &self.subtitle {
77            tile = tile.subtitle(subtitle.clone());
78        }
79        if self.three_line {
80            tile = tile.three_line();
81        }
82        if self.dense {
83            tile = tile.dense();
84        }
85        if let Some(color) = self.tile_color {
86            tile = tile.tile_color(color);
87        }
88        if let Some(color) = self.selected_tile_color {
89            tile = tile.selected_tile_color(color);
90        }
91        if let Some(shape) = self.shape {
92            tile = tile.shape(shape);
93        }
94        if let Some(padding) = self.padding {
95            tile = tile.padding(padding);
96        }
97        if let Some(style) = self.title_style {
98            tile = tile.title_style(style);
99        }
100        if let Some(style) = self.subtitle_style {
101            tile = tile.subtitle_style(style);
102        }
103        tile = tile.selected(self.selected).enabled(self.enabled);
104        if let Some(message) = on_tap {
105            tile = tile.on_tap(message);
106        }
107
108        let other = self.secondary.borrow_mut().take();
109        let (leading, trailing) = match self.affinity {
110            ControlAffinity::Leading => (Some(control), other),
111            ControlAffinity::Trailing => (other, Some(control)),
112        };
113        if let Some(widget) = leading {
114            tile = tile.leading(crate::ConstrainedBox::new_boxed(widget));
115        }
116        if let Some(widget) = trailing {
117            tile = tile.trailing(crate::ConstrainedBox::new_boxed(widget));
118        }
119        tile
120    }
121}
122
123/// The builders the three share, written once against the field they all carry.
124macro_rules! row_builders {
125    () => {
126        /// The row's first line.
127        #[must_use]
128        pub fn title(mut self, title: impl Into<String>) -> Self {
129            self.row.title = Some(title.into());
130            self
131        }
132
133        /// Its second.
134        #[must_use]
135        pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
136            self.row.subtitle = Some(subtitle.into());
137            self
138        }
139
140        /// The widget in the **other** slot — an icon, usually. The control takes the
141        /// side [`control_affinity`](Self::control_affinity) names and this takes the
142        /// side left over.
143        #[must_use]
144        pub fn secondary(self, widget: impl Widget<Msg> + 'static) -> Self {
145            *self.row.secondary.borrow_mut() = Some(Box::new(widget));
146            self
147        }
148
149        /// **Which side the control sits on.** Trailing by default, as the reference's
150        /// is: a column of labels reads down the leading edge, and a column of controls
151        /// down the other.
152        #[must_use]
153        pub fn control_affinity(mut self, affinity: ControlAffinity) -> Self {
154            self.row.affinity = affinity;
155            self
156        }
157
158        /// The tighter row.
159        #[must_use]
160        pub fn dense(mut self) -> Self {
161            self.row.dense = true;
162            self
163        }
164
165        /// A row tall enough for a two-line subtitle.
166        #[must_use]
167        pub fn three_line(mut self) -> Self {
168            self.row.three_line = true;
169            self
170        }
171
172        /// Whether this is the row the reader is on, which is a different question from
173        /// whether the control is on.
174        #[must_use]
175        pub fn selected(mut self, selected: bool) -> Self {
176            self.row.selected = selected;
177            self
178        }
179
180        /// Whether the row and its control can be worked at all. Disabled, **neither**
181        /// answers: a row that still reported a tap while its control refused one would
182        /// be two controls disagreeing.
183        #[must_use]
184        pub fn enabled(mut self, enabled: bool) -> Self {
185            self.row.enabled = enabled;
186            self
187        }
188
189        /// The row's surface.
190        #[must_use]
191        pub fn tile_color(mut self, color: Color) -> Self {
192            self.row.tile_color = Some(color);
193            self
194        }
195
196        /// And its surface while it is the row the reader is on.
197        #[must_use]
198        pub fn selected_tile_color(mut self, color: Color) -> Self {
199            self.row.selected_tile_color = Some(color);
200            self
201        }
202
203        /// What shape the row is.
204        #[must_use]
205        pub fn shape(mut self, shape: ShapeBorder) -> Self {
206            self.row.shape = Some(shape);
207            self
208        }
209
210        /// The room kept inside it.
211        #[must_use]
212        pub fn content_padding(mut self, padding: Insets) -> Self {
213            self.row.padding = Some(padding);
214            self
215        }
216
217        /// The title's type.
218        #[must_use]
219        pub fn title_style(mut self, style: TextStyle) -> Self {
220            self.row.title_style = Some(style);
221            self
222        }
223
224        /// The subtitle's.
225        #[must_use]
226        pub fn subtitle_style(mut self, style: TextStyle) -> Self {
227            self.row.subtitle_style = Some(style);
228            self
229        }
230    };
231}
232
233// ============================================================== the checkbox
234
235/// **A row whose whole width ticks a box.**
236///
237/// ```
238/// # use frus_widgets::CheckboxListTile;
239/// # #[derive(Clone)] enum Msg { Notify }
240/// CheckboxListTile::new(true, Msg::Notify)
241///     .title("Notify me")
242///     .subtitle("About replies to my posts");
243/// ```
244pub struct CheckboxListTile<Msg = crate::callback::Callback> {
245    row: Row<Msg>,
246    value: Option<bool>,
247    on_changed: Msg,
248    active_color: Option<Color>,
249    check_color: Option<Color>,
250    checkbox_radius: Option<f32>,
251    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
252}
253
254impl<Msg: Clone + 'static> CheckboxListTile<Msg> {
255    /// A row carrying a box, ticked or not, and the message a tap on **any part of it**
256    /// sends.
257    pub fn new(value: bool, on_changed: Msg) -> Self {
258        Self {
259            row: Row::default(),
260            value: Some(value),
261            on_changed,
262            active_color: None,
263            check_color: None,
264            checkbox_radius: None,
265            built: OnceCell::new(),
266        }
267    }
268
269    /// The same with a box that can be **neither** — a "select all" over rows that are
270    /// partly ticked. `None` is a third answer, not a second no.
271    pub fn maybe(value: Option<bool>, on_changed: Msg) -> Self {
272        Self {
273            value,
274            ..Self::new(false, on_changed)
275        }
276    }
277
278    row_builders!();
279
280    /// The colour of a ticked box, over the theme's and `primary`.
281    #[must_use]
282    pub fn active_color(mut self, color: Color) -> Self {
283        self.active_color = Some(color);
284        self
285    }
286
287    /// The colour of the tick itself.
288    #[must_use]
289    pub fn check_color(mut self, color: Color) -> Self {
290        self.check_color = Some(color);
291        self
292    }
293
294    /// The box's corner.
295    #[must_use]
296    pub fn checkbox_radius(mut self, radius: f32) -> Self {
297        self.checkbox_radius = Some(radius);
298        self
299    }
300
301    fn assemble(&self) -> Box<dyn Widget<Msg>> {
302        let mut checkbox = Checkbox::<Msg>::maybe(self.value).enabled(self.row.enabled);
303        if let Some(color) = self.active_color {
304            checkbox = checkbox.fill_color(color);
305        }
306        if let Some(color) = self.check_color {
307            checkbox = checkbox.check_color(color);
308        }
309        if let Some(radius) = self.checkbox_radius {
310            checkbox = checkbox.radius(radius);
311        }
312        // The box answers with the row's message, ignoring the value it computed: the
313        // caller said what a change means, and a control tile's control is not the one
314        // deciding what the next value is.
315        if self.row.enabled {
316            let message = self.on_changed.clone();
317            checkbox = checkbox.on_change(move |_| message.clone());
318        }
319        let on_tap = self.row.enabled.then(|| self.on_changed.clone());
320        Box::new(self.row.tile(Box::new(checkbox), on_tap))
321    }
322}
323
324// ================================================================= the radio
325
326/// **A row whose whole width chooses one option.**
327///
328/// It carries `selected` rather than a value and a group value: this framework's
329/// [`Radio`] reports being pressed and says nothing about what the answer becomes, so the
330/// application already holds the choice.
331pub struct RadioListTile<Msg = crate::callback::Callback> {
332    row: Row<Msg>,
333    selected: bool,
334    on_changed: Msg,
335    active_color: Option<Color>,
336    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
337}
338
339impl<Msg: Clone + 'static> RadioListTile<Msg> {
340    /// A row carrying a radio, chosen or not, and the message a tap on **any part of it**
341    /// sends.
342    pub fn new(selected: bool, on_changed: Msg) -> Self {
343        Self {
344            row: Row::default(),
345            selected,
346            on_changed,
347            active_color: None,
348            built: OnceCell::new(),
349        }
350    }
351
352    row_builders!();
353
354    /// The ring and the dot while it is the chosen one, over the theme's and `primary`.
355    #[must_use]
356    pub fn active_color(mut self, color: Color) -> Self {
357        self.active_color = Some(color);
358        self
359    }
360
361    fn assemble(&self) -> Box<dyn Widget<Msg>> {
362        let mut radio = Radio::<Msg>::new(self.selected).enabled(self.row.enabled);
363        if let Some(color) = self.active_color {
364            radio = radio.selected_color(color);
365        }
366        if self.row.enabled {
367            radio = radio.on_select(self.on_changed.clone());
368        }
369        let on_tap = self.row.enabled.then(|| self.on_changed.clone());
370        Box::new(self.row.tile(Box::new(radio), on_tap))
371    }
372}
373
374// ================================================================ the switch
375
376/// **A row whose whole width flips a switch.** The commonest shape a settings screen has.
377pub struct SwitchListTile<Msg = crate::callback::Callback> {
378    row: Row<Msg>,
379    value: bool,
380    on_changed: Msg,
381    active_color: Option<Color>,
382    track_color: Option<Color>,
383    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
384}
385
386impl<Msg: Clone + 'static> SwitchListTile<Msg> {
387    /// A row carrying a switch, on or off, and the message a tap on **any part of it**
388    /// sends.
389    pub fn new(value: bool, on_changed: Msg) -> Self {
390        Self {
391            row: Row::default(),
392            value,
393            on_changed,
394            active_color: None,
395            track_color: None,
396            built: OnceCell::new(),
397        }
398    }
399
400    row_builders!();
401
402    /// The thumb while it is on, over the theme's.
403    #[must_use]
404    pub fn active_color(mut self, color: Color) -> Self {
405        self.active_color = Some(color);
406        self
407    }
408
409    /// The track while it is on.
410    #[must_use]
411    pub fn active_track_color(mut self, color: Color) -> Self {
412        self.track_color = Some(color);
413        self
414    }
415
416    fn assemble(&self) -> Box<dyn Widget<Msg>> {
417        let mut switch = Switch::<Msg>::new(self.value).enabled(self.row.enabled);
418        if let Some(color) = self.active_color {
419            switch = switch.thumb_color(color);
420        }
421        if let Some(color) = self.track_color {
422            switch = switch.track_color(color);
423        }
424        if self.row.enabled {
425            let message = self.on_changed.clone();
426            switch = switch.on_toggle(move |_| message.clone());
427        }
428        let on_tap = self.row.enabled.then(|| self.on_changed.clone());
429        Box::new(self.row.tile(Box::new(switch), on_tap))
430    }
431}
432
433/// The three `Widget` implementations, **written out** rather than produced by a macro.
434///
435/// They are three near-identical copies and a macro would say them once, which is what
436/// this module did until the crate's own guard failed on it: milestone 322's
437/// `every_control_with_an_enabled_flag_honours_all_four` reads the **source** of every
438/// module carrying an `enabled` flag and checks that each of the four hooks consults it.
439/// A hook inside a `macro_rules!` body is text the guard cannot parse, so the macro put
440/// this module in the one place the net does not reach.
441///
442/// A safety net a widget can hide from is a net with a hole in it. Three copies is the
443/// cheaper of the two prices.
444impl<Msg: Clone + 'static> Widget<Msg> for CheckboxListTile<Msg> {
445    /// A column of one, which is what the tile is. Not a default `Style`: a wrapper that
446    /// forgets to say *column* turns its child sideways, milestone 425's rule.
447    fn style(&self) -> Style {
448        Style {
449            flex_direction: FlexDirection::Column,
450            ..Default::default()
451        }
452    }
453
454    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
455        self.built.get().map(Vec::as_slice).unwrap_or(&[])
456    }
457
458    /// Assembled on the first walk, as [`crate::ExpansionTile`]'s row is, so that **the
459    /// order the builders were called in cannot change what comes out**.
460    fn build_themed(&self, _theme: &Theme) {
461        let _ = self.built.set(vec![self.assemble()]);
462    }
463
464    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
465
466    /// The row answers, not this. Wiring a click here as well would send the message
467    /// twice for one tap, and the row already refuses while disabled.
468    fn on_click(&self) -> Option<Msg> {
469        None
470    }
471}
472
473impl<Msg: Clone + 'static> Widget<Msg> for RadioListTile<Msg> {
474    fn style(&self) -> Style {
475        Style {
476            flex_direction: FlexDirection::Column,
477            ..Default::default()
478        }
479    }
480
481    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
482        self.built.get().map(Vec::as_slice).unwrap_or(&[])
483    }
484
485    fn build_themed(&self, _theme: &Theme) {
486        let _ = self.built.set(vec![self.assemble()]);
487    }
488
489    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
490
491    /// The row answers, not this.
492    fn on_click(&self) -> Option<Msg> {
493        None
494    }
495}
496
497impl<Msg: Clone + 'static> Widget<Msg> for SwitchListTile<Msg> {
498    fn style(&self) -> Style {
499        Style {
500            flex_direction: FlexDirection::Column,
501            ..Default::default()
502        }
503    }
504
505    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
506        self.built.get().map(Vec::as_slice).unwrap_or(&[])
507    }
508
509    fn build_themed(&self, _theme: &Theme) {
510        let _ = self.built.set(vec![self.assemble()]);
511    }
512
513    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
514
515    /// The row answers, not this.
516    fn on_click(&self) -> Option<Msg> {
517        None
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::{build_ui, Icon, Icons, Point as P, Runtime, Size};
525
526    #[derive(Clone, Debug, PartialEq)]
527    enum Msg {
528        Toggle,
529        Other,
530    }
531
532    fn frame(widget: &dyn Widget<Msg>) -> crate::Ui<Msg> {
533        build_ui(
534            widget,
535            Size::new(320.0, 120.0),
536            &Runtime::default(),
537            &Theme::default(),
538        )
539    }
540
541    /// **The whole row is the control.** This is the widget: a settings screen where only
542    /// the 20-pixel box answers, and the label beside it does nothing, is a screen where
543    /// most of the taps go nowhere. The reference's three do exactly this
544    /// (`checkbox_list_tile.dart`), and the parts to do it with have been here all along.
545    #[test]
546    fn a_tap_anywhere_on_the_row_works_the_control() {
547        for tile in [
548            Box::new(CheckboxListTile::new(false, Msg::Toggle).title("Notify me"))
549                as Box<dyn Widget<Msg>>,
550            Box::new(RadioListTile::new(false, Msg::Toggle).title("Notify me")),
551            Box::new(SwitchListTile::new(false, Msg::Toggle).title("Notify me")),
552        ] {
553            let ui = frame(tile.as_ref());
554            // Far from the control, on the words: the middle of the row's leading half.
555            let hit = ui.hit(P::new(80.0, 24.0)).expect("the row is hittable");
556            assert_eq!(
557                ui.msg_for(hit),
558                Some(Msg::Toggle),
559                "the label answers, not only the box"
560            );
561        }
562    }
563
564    /// The control **and** the row send the same message, so a tap on either does the
565    /// same thing. A row wired to one message and a control wired to another would be two
566    /// controls in one row disagreeing about what they are.
567    #[test]
568    fn the_control_and_the_row_say_the_same_thing() {
569        let tile = CheckboxListTile::new(false, Msg::Toggle).title("Notify me");
570        let ui = frame(&tile);
571        // The trailing edge, where the box is.
572        let box_hit = ui.hit(P::new(300.0, 24.0)).expect("the box is hittable");
573        assert_eq!(ui.msg_for(box_hit), Some(Msg::Toggle));
574    }
575
576    /// **Disabled, neither answers.** A row that still reported a tap while its control
577    /// refused one would be two controls disagreeing, and the one a reader can see is the
578    /// one that looks unavailable.
579    #[test]
580    fn a_disabled_row_answers_nowhere() {
581        let tile = SwitchListTile::new(true, Msg::Toggle)
582            .title("Notify me")
583            .enabled(false);
584        let ui = frame(&tile);
585        for x in [80.0, 300.0] {
586            if let Some(hit) = ui.hit(P::new(x, 24.0)) {
587                assert_ne!(ui.msg_for(hit), Some(Msg::Toggle), "at x = {x}");
588            }
589        }
590    }
591
592    /// **Which side the control sits on**, and the other slot going to `secondary`. The
593    /// reference's `controlAffinity` (`checkbox_list_tile.dart`), with trailing the
594    /// default: a column of labels reads down the leading edge and a column of controls
595    /// down the other.
596    #[test]
597    fn the_control_takes_the_side_it_was_given_and_the_icon_the_other() {
598        let ends = |tile: &dyn Widget<Msg>| {
599            let ui = frame(tile);
600            // Where the row's own painted marks are: the leftmost and rightmost thing
601            // drawn inside it that is not the row's surface.
602            let mut xs: Vec<f32> = Vec::new();
603            fn walk(primitives: &[frus_core::Primitive], xs: &mut Vec<f32>) {
604                for p in primitives {
605                    match p {
606                        frus_core::Primitive::Rect { rect, .. } if rect.width < 100.0 => {
607                            xs.push(rect.x)
608                        }
609                        frus_core::Primitive::Path { .. } => {}
610                        frus_core::Primitive::Layer { primitives, .. } => walk(primitives, xs),
611                        _ => {}
612                    }
613                }
614            }
615            walk(ui.scene().primitives(), &mut xs);
616            xs
617        };
618
619        let trailing = CheckboxListTile::new(true, Msg::Toggle)
620            .title("Notify me")
621            .secondary(Icon::new(Icons::ADD));
622        let leading = CheckboxListTile::new(true, Msg::Toggle)
623            .title("Notify me")
624            .secondary(Icon::new(Icons::ADD))
625            .control_affinity(ControlAffinity::Leading);
626
627        let (right, left) = (ends(&trailing), ends(&leading));
628        assert!(!right.is_empty() && !left.is_empty(), "both drew a box");
629        let far_right = right.iter().cloned().fold(f32::MIN, f32::max);
630        let far_left = left.iter().cloned().fold(f32::MIN, f32::max);
631        assert!(
632            far_right > far_left,
633            "a trailing box sits further along the row than a leading one: \
634             {far_right} vs {far_left}"
635        );
636    }
637
638    /// **The order the builders were written in cannot change what comes out.** The row
639    /// is assembled on the first walk, as [`crate::ExpansionTile`]'s is — the trap
640    /// milestone 458 found in `BottomSheet`, where the panel is built by one particular
641    /// method and anything said after it is dropped.
642    #[test]
643    fn the_builder_order_does_not_change_what_comes_out() {
644        let first = SwitchListTile::new(true, Msg::Toggle)
645            .dense()
646            .title("Notify me")
647            .tile_color(Color::rgb(0.2, 0.3, 0.4));
648        let last = SwitchListTile::new(true, Msg::Toggle)
649            .tile_color(Color::rgb(0.2, 0.3, 0.4))
650            .title("Notify me")
651            .dense();
652        let paint = |tile: &SwitchListTile<Msg>| {
653            let ui = frame(tile);
654            format!("{:?}", ui.scene().primitives())
655        };
656        assert_eq!(paint(&first), paint(&last));
657    }
658
659    /// A row that is the one the reader is on says so, which is a different question from
660    /// whether the control is on. Both travel to the tile.
661    #[test]
662    fn being_the_chosen_row_is_not_being_on() {
663        let tile = RadioListTile::new(false, Msg::Toggle)
664            .title("Every day")
665            .selected(true)
666            .selected_tile_color(Color::rgb(0.6, 0.4, 0.2));
667        let ui = frame(&tile);
668        fn find(primitives: &[frus_core::Primitive], want: Color) -> bool {
669            primitives.iter().any(|p| match p {
670                frus_core::Primitive::Rect { color, .. } => *color == want,
671                frus_core::Primitive::Layer { primitives, .. } => find(primitives, want),
672                _ => false,
673            })
674        }
675        assert!(
676            find(ui.scene().primitives(), Color::rgb(0.6, 0.4, 0.2)),
677            "the selected surface is painted"
678        );
679        assert_ne!(Msg::Other, Msg::Toggle);
680    }
681}