Skip to main content

frus_widgets/
togglebuttons.rs

1//! [`ToggleButtons`]: a bank of buttons that **share their edges**, of which any number
2//! can be on at once.
3//!
4//! ```ignore
5//! ToggleButtons::new(vec![true, false, true], Msg::Toggle)
6//!     .child(Text::new("B").weight(FontWeight::Bold))
7//!     .child(Text::new("I").italic())
8//!     .child(Text::new("U").underline())
9//! ```
10//!
11//! It is **not** [`SegmentedButton`](crate::SegmentedButton), and the difference is worth
12//! knowing before reaching for either. A segmented button is one control with one answer:
13//! the caller hands it labels and gets back an index. This hands the caller the buttons
14//! themselves — each child is an arbitrary widget — and asks a separate yes or no about
15//! every one of them. A bold/italic/underline bank is three answers, not one, and no
16//! amount of styling turns a single-selection control into that.
17//!
18//! ## The edges are shared
19//!
20//! Three buttons touching have **four** vertical lines between and around them, not six.
21//! Each button draws the edge facing the button before it — its *leading* border — and
22//! only the last one draws the edge at the far end. So a seam is drawn once, at one
23//! border's width, rather than twice at two, which is what a row of outlined buttons
24//! pushed together gives and what makes that arrangement read as three controls instead
25//! of one.
26//!
27//! A seam belongs to the two buttons either side of it, so **either** of them being on
28//! colours it (`toggle_buttons.dart:596`). The reference's own defaults make all three
29//! border colours the same, so nothing shows until a caller names
30//! [`selected_border_color`](ToggleButtons::selected_border_color) — which is the point at
31//! which getting the rule right starts to matter.
32//!
33//! ## Colour reaches the children through the theme
34//!
35//! The reference paints its children by handing the button a `foregroundColor` that
36//! descendant `Text` and `Icon` widgets inherit. This does the same thing through the
37//! mechanism this framework already has: each child is wrapped in a
38//! [`Themed`](crate::Themed) that sets the text and icon colour for its subtree. A caller
39//! who put a `Text` in a button gets the selected colour on it without saying so, and one
40//! who set a colour on that `Text` themselves still wins — which is the same order of
41//! precedence, expressed with the framework's own parts.
42
43use std::cell::{OnceCell, RefCell};
44
45use frus_core::{BorderRadius, Color, Insets, Rect, Scene, TextDirection, TextStyle};
46use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
47
48use crate::disabled::{disabled_content, over_surface};
49use crate::interaction::Status;
50use crate::rowcolumn::VerticalDirection;
51use crate::theme::Theme;
52use crate::themed::Themed;
53use crate::widget::Widget;
54use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
55use crate::widgettheme::ToggleButtonsTheme;
56
57/// The smallest a toggle button may be, either way: the framework's tap target
58/// (`toggle_buttons.dart:762`, which reads `kMinInteractiveDimension`).
59pub const TOGGLE_BUTTON_MIN_SIZE: f32 = 48.0;
60
61/// The hairline around and between the buttons (`toggle_buttons.dart:243`).
62pub const TOGGLE_BUTTONS_BORDER_WIDTH: f32 = 1.0;
63
64/// The opacity of a selected button's fill over the surface (`toggle_buttons.dart:929`).
65const SELECTED_FILL_OPACITY: f32 = 0.12;
66
67/// The opacity of an unselected button's content (`toggle_buttons.dart:751`).
68const CONTENT_OPACITY: f32 = 0.87;
69
70/// The opacity of the hairline, untold (`toggle_buttons.dart:601`).
71const BORDER_OPACITY: f32 = 0.12;
72
73/// **Which way a bank runs.**
74///
75/// Two variants and not three: [`crate::Axis`] carries a `Both`, which is a scrollable's
76/// answer and not a direction anything can be laid out along. The crate already names an
77/// axis per family for this reason ([`crate::ReorderAxis`], [`crate::IntrinsicAxis`]); that
78/// there are now five of them is on the roadmap rather than solved here.
79#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
80pub enum ToggleAxis {
81    /// A row. The default.
82    #[default]
83    Horizontal,
84    /// A column.
85    Vertical,
86}
87
88/// Everything a caller may say about a bank's appearance, each part `None` until they do.
89///
90/// One struct rather than fourteen fields threaded one at a time: the buttons are built
91/// from it, and a property added here is a property every button already reads.
92#[derive(Clone, Debug, PartialEq)]
93struct ToggleStyle {
94    render_border: bool,
95    border_width: Option<f32>,
96    border_radius: Option<BorderRadius>,
97    border_color: Option<Color>,
98    selected_border_color: Option<Color>,
99    disabled_border_color: Option<Color>,
100    color: Option<Color>,
101    selected_color: Option<Color>,
102    disabled_color: Option<Color>,
103    fill_color: Option<WidgetStateProperty<Color>>,
104    text_style: Option<TextStyle>,
105    min_width: Option<f32>,
106    min_height: Option<f32>,
107    max_width: Option<f32>,
108    max_height: Option<f32>,
109}
110
111impl Default for ToggleStyle {
112    fn default() -> Self {
113        Self {
114            // The one field whose untold answer is not `None`: the reference renders the
115            // border unless told otherwise (`toggle_buttons.dart:233`), and a bank with no
116            // edges is a row of loose children.
117            render_border: true,
118            border_width: None,
119            border_radius: None,
120            border_color: None,
121            selected_border_color: None,
122            disabled_border_color: None,
123            color: None,
124            selected_color: None,
125            disabled_color: None,
126            fill_color: None,
127            text_style: None,
128            min_width: None,
129            min_height: None,
130            max_width: None,
131            max_height: None,
132        }
133    }
134}
135
136impl ToggleStyle {
137    fn themed(theme: &Theme) -> &ToggleButtonsTheme {
138        &theme.widgets.toggle_buttons
139    }
140
141    /// The hairline's width — zero when the bank was told to draw none, so that every
142    /// caller of this gets the *effective* number and the flag is tested once.
143    fn border_width(&self, theme: &Theme) -> f32 {
144        if !self.render_border {
145            return 0.0;
146        }
147        self.border_width
148            .or(Self::themed(theme).border_width)
149            .unwrap_or(TOGGLE_BUTTONS_BORDER_WIDTH)
150            .max(0.0)
151    }
152
153    /// The bank's corners — **the bank's**, not a button's. Each button takes the pair at
154    /// its own end of this and squares off the rest; see [`ToggleButton::corners`].
155    fn radius(&self, theme: &Theme) -> BorderRadius {
156        self.border_radius
157            .or(Self::themed(theme).border_radius)
158            .unwrap_or(BorderRadius::ZERO)
159    }
160
161    /// A hairline's colour, for a button in the state given.
162    ///
163    /// All three rungs default to the same value (`toggle_buttons.dart:601`, `:635`,
164    /// `:651`), which is the reference's M3 answer and is deliberate: the bank is one
165    /// object, and its outline does not break up because one of its buttons is on. The
166    /// three are still named apart because a caller who wants the seam to move — a
167    /// selected pair marked out from the rest — needs somewhere to say so.
168    fn border(&self, theme: &Theme, enabled: bool, selected: bool) -> Color {
169        let t = Self::themed(theme);
170        let told = if !enabled {
171            self.disabled_border_color.or(t.disabled_border_color)
172        } else if selected {
173            self.selected_border_color.or(t.selected_border_color)
174        } else {
175            self.border_color.or(t.border_color)
176        };
177        told.unwrap_or_else(|| over_surface(theme, BORDER_OPACITY))
178    }
179
180    /// What is drawn **in** a button: its label, its glyph, whatever it holds.
181    fn ink(&self, theme: &Theme, enabled: bool, selected: bool) -> Color {
182        let t = Self::themed(theme);
183        if !enabled {
184            return self
185                .disabled_color
186                .or(t.disabled_color)
187                // `on_surface` at 38 %, resolved opaque rather than handed to the GPU as
188                // an alpha — the crate's one answer for unavailable.
189                .unwrap_or_else(|| disabled_content(theme));
190        }
191        if selected {
192            self.selected_color
193                .or(t.selected_color)
194                .unwrap_or(theme.scheme.primary)
195        } else {
196            self.color
197                .or(t.color)
198                .unwrap_or_else(|| over_surface(theme, CONTENT_OPACITY))
199        }
200    }
201
202    /// A button's ground.
203    ///
204    /// Untold, a selected button takes the accent at 12 % **resolved in sRGB** rather than
205    /// left translucent (`toggle_buttons.dart:929`). The reference's opacity tokens are a
206    /// design language written in the space the colours are written in; this one blends in
207    /// linear light, where 12 % paints at roughly what a third would give. Same reasoning
208    /// as [`over_surface`], with the accent as the ink instead of `on_surface`.
209    ///
210    /// An unselected button has **no** ground at all, which is not the same as a
211    /// transparent one being painted: nothing is drawn, so whatever the bank was put on
212    /// shows through.
213    fn fill(&self, theme: &Theme, enabled: bool, selected: bool) -> Color {
214        let states = WidgetStates::EMPTY
215            .set(WidgetState::Selected, enabled && selected)
216            .set(WidgetState::Disabled, !enabled);
217        // A colour the caller gave for *every* state answers here too: the reference's
218        // field is a `Color?` that may hold a state property, and a plain colour there
219        // fills selected and unselected alike (`toggle_buttons.dart:912`).
220        if let Some(told) = self
221            .fill_color
222            .as_ref()
223            .and_then(|own| own.resolve(states))
224            .or_else(|| {
225                Self::themed(theme)
226                    .fill_color
227                    .as_ref()
228                    .and_then(|w| w.resolve(states))
229            })
230        {
231            return *told;
232        }
233        if enabled && selected {
234            theme
235                .scheme
236                .surface
237                .lerp(theme.scheme.primary, SELECTED_FILL_OPACITY)
238        } else {
239            Color::TRANSPARENT
240        }
241    }
242
243    /// The type a button's text takes (`toggle_buttons.dart:759`).
244    fn text_style(&self, theme: &Theme) -> TextStyle {
245        self.text_style
246            .or(Self::themed(theme).text_style)
247            .unwrap_or_else(|| crate::theme::type_scale(Some(theme)).body_medium)
248    }
249
250    fn min_width(&self, theme: &Theme) -> f32 {
251        self.min_width
252            .or(Self::themed(theme).min_width)
253            .unwrap_or(TOGGLE_BUTTON_MIN_SIZE)
254    }
255
256    fn min_height(&self, theme: &Theme) -> f32 {
257        self.min_height
258            .or(Self::themed(theme).min_height)
259            .unwrap_or(TOGGLE_BUTTON_MIN_SIZE)
260    }
261
262    fn max_width(&self, theme: &Theme) -> Option<f32> {
263        self.max_width.or(Self::themed(theme).max_width)
264    }
265
266    fn max_height(&self, theme: &Theme) -> Option<f32> {
267        self.max_height.or(Self::themed(theme).max_height)
268    }
269}
270
271/// A bank of buttons sharing their edges, any number of which may be on.
272pub struct ToggleButtons<Msg = crate::callback::Callback> {
273    selected: Vec<bool>,
274    on_pressed: Box<dyn Fn(usize) -> Msg>,
275    enabled: bool,
276    axis: ToggleAxis,
277    vertical_direction: VerticalDirection,
278    style: ToggleStyle,
279    /// What the caller handed over, before it was wrapped. Taken once, on assembly.
280    declared: RefCell<Vec<Box<dyn Widget<Msg>>>>,
281    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
282}
283
284impl<Msg: Clone + 'static> ToggleButtons<Msg> {
285    /// A bank whose buttons are on or off as `selected` says, and which emits
286    /// `on_pressed(index)` when one of them is pressed.
287    ///
288    /// The list is the application's, not the widget's: a bank does not remember which of
289    /// its buttons are down, it draws the answer it was given. That is the shape every
290    /// control in this framework has, and it is why there is no controller here where the
291    /// reference has one — the model already holds the booleans.
292    ///
293    /// `selected` decides how many buttons there are as much as
294    /// [`child`](Self::child) does; a child with no boolean beside it is not drawn, and a
295    /// boolean with no child is nothing. The reference asserts the two lists are the same
296    /// length, and this takes the shorter of them rather than panicking in a `view`.
297    pub fn new(selected: Vec<bool>, on_pressed: impl Fn(usize) -> Msg + 'static) -> Self {
298        Self {
299            selected,
300            on_pressed: Box::new(on_pressed),
301            enabled: true,
302            axis: ToggleAxis::Horizontal,
303            vertical_direction: VerticalDirection::Down,
304            style: ToggleStyle::default(),
305            declared: RefCell::new(Vec::new()),
306            built: OnceCell::new(),
307        }
308    }
309
310    /// Adds a button holding `child` — typically a [`Text`](crate::Text) or an
311    /// [`Icon`](crate::Icon), but anything at all (`toggle_buttons.dart:254`).
312    #[must_use]
313    pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
314        self.child_boxed(Box::new(child))
315    }
316
317    /// The same, for a child that is already boxed.
318    #[must_use]
319    pub fn child_boxed(self, child: Box<dyn Widget<Msg>>) -> Self {
320        self.declared.borrow_mut().push(child);
321        self
322    }
323
324    /// Whether the bank can be pressed at all.
325    ///
326    /// The reference disables a bank by passing a null `onPressed`
327    /// (`toggle_buttons.dart:269`), which is not expressible here — the callback is how
328    /// the bank knows what to emit, and taking it away would take the type with it. So the
329    /// flag is separate, and everything downstream of it is the same: no message, no tab
330    /// stop, no splash, the disabled colours, and **no fill on a selected button**, which
331    /// is the part that is easy to miss (a disabled bank never enters the selected state
332    /// at all, `toggle_buttons.dart:739`).
333    #[must_use]
334    pub fn enabled(mut self, enabled: bool) -> Self {
335        self.enabled = enabled;
336        self
337    }
338
339    /// Which way the bank runs (`toggle_buttons.dart:454`). A row by default.
340    #[must_use]
341    pub fn direction(mut self, axis: ToggleAxis) -> Self {
342        self.axis = axis;
343        self
344    }
345
346    /// For a vertical bank, whether the first button is at the top or at the bottom
347    /// (`toggle_buttons.dart:458`). Ignored by a row, whose order is the reading
348    /// direction's to decide and which mirrors with the rest of the frame.
349    #[must_use]
350    pub fn vertical_direction(mut self, direction: VerticalDirection) -> Self {
351        self.vertical_direction = direction;
352        self
353    }
354
355    /// Whether the hairlines are drawn at all (`toggle_buttons.dart:400`).
356    #[must_use]
357    pub fn render_border(mut self, render: bool) -> Self {
358        self.style.render_border = render;
359        self
360    }
361
362    /// The hairline's width, around the bank and between its buttons
363    /// (`toggle_buttons.dart:441`). Untold, [`TOGGLE_BUTTONS_BORDER_WIDTH`].
364    #[must_use]
365    pub fn border_width(mut self, width: f32) -> Self {
366        self.style.border_width = Some(width);
367        self
368    }
369
370    /// The **bank's** corners (`toggle_buttons.dart:449`): the first button takes the pair
371    /// at its end and the last one the pair at the other, and the buttons between them are
372    /// square. Untold, square all round, as the reference is.
373    #[must_use]
374    pub fn border_radius(mut self, radius: impl Into<BorderRadius>) -> Self {
375        self.style.border_radius = Some(radius.into());
376        self
377    }
378
379    /// The hairline around and beside a button that is off (`toggle_buttons.dart:409`).
380    #[must_use]
381    pub fn border_color(mut self, color: Color) -> Self {
382        self.style.border_color = Some(color);
383        self
384    }
385
386    /// The hairline around and beside a button that is on (`toggle_buttons.dart:417`).
387    /// A seam between an on button and an off one takes **this**, from either side.
388    #[must_use]
389    pub fn selected_border_color(mut self, color: Color) -> Self {
390        self.style.selected_border_color = Some(color);
391        self
392    }
393
394    /// The hairline when the bank cannot be pressed (`toggle_buttons.dart:425`).
395    #[must_use]
396    pub fn disabled_border_color(mut self, color: Color) -> Self {
397        self.style.disabled_border_color = Some(color);
398        self
399    }
400
401    /// What is drawn in a button that is off (`toggle_buttons.dart:310`) — its text, its
402    /// glyph, anything in its subtree that reads the theme for a colour.
403    #[must_use]
404    pub fn color(mut self, color: Color) -> Self {
405        self.style.color = Some(color);
406        self
407    }
408
409    /// The same, for a button that is on (`toggle_buttons.dart:322`).
410    #[must_use]
411    pub fn selected_color(mut self, color: Color) -> Self {
412        self.style.selected_color = Some(color);
413        self
414    }
415
416    /// The same, when the bank cannot be pressed (`toggle_buttons.dart:333`).
417    #[must_use]
418    pub fn disabled_color(mut self, color: Color) -> Self {
419        self.style.disabled_color = Some(color);
420        self
421    }
422
423    /// A button's ground, per state (`toggle_buttons.dart:348`).
424    ///
425    /// The reference's field is a `Color?` that may secretly hold a state property, tested
426    /// for at run time; here it is the state property, and
427    /// `WidgetStateProperty::all(colour)` is the plain-colour case — which, as there, then
428    /// fills every button rather than only the ones that are on.
429    #[must_use]
430    pub fn fill_color(mut self, fill: WidgetStateProperty<Color>) -> Self {
431        self.style.fill_color = Some(fill);
432        self
433    }
434
435    /// The type any text in the bank takes (`toggle_buttons.dart:291`). Its colour is
436    /// ignored, as the reference ignores it: that is [`color`](Self::color)'s to answer,
437    /// and it depends on the state the button is in.
438    #[must_use]
439    pub fn text_style(mut self, style: TextStyle) -> Self {
440        self.style.text_style = Some(style);
441        self
442    }
443
444    /// The smallest a button may be (`toggle_buttons.dart:299`). Untold,
445    /// [`TOGGLE_BUTTON_MIN_SIZE`] both ways — the tap target, which is a floor and not a
446    /// size: a wider child makes a wider button.
447    #[must_use]
448    pub fn min_size(mut self, width: f32, height: f32) -> Self {
449        self.style.min_width = Some(width);
450        self.style.min_height = Some(height);
451        self
452    }
453
454    /// The largest a button may be. Untold, unbounded.
455    #[must_use]
456    pub fn max_size(mut self, width: f32, height: f32) -> Self {
457        self.style.max_width = Some(width);
458        self.style.max_height = Some(height);
459        self
460    }
461
462    /// How many buttons there actually are: a child needs a boolean beside it.
463    fn count(&self) -> usize {
464        self.declared.borrow().len().min(self.selected.len())
465    }
466
467    /// Wraps each declared child in its button, once.
468    ///
469    /// The colour cascade is a [`Themed`](crate::Themed) rather than a value computed
470    /// here, because the theme is not known when a tree is built — the walk resolves it on
471    /// the way down. The closure runs with the theme the button will actually be under,
472    /// which is also what lets [`ToggleButtonsTheme`] have the last word.
473    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
474        let count = self.count();
475        let mut declared = self.declared.borrow_mut();
476        declared.truncate(count);
477        declared
478            .drain(..)
479            .enumerate()
480            .map(|(index, child)| {
481                let selected = self.selected[index];
482                let enabled = self.enabled;
483                let style = self.style.clone();
484                let cascade = style.clone();
485                let painted = Themed::tweak(
486                    move |theme| {
487                        let ink = cascade.ink(theme, enabled, selected);
488                        let mut text = cascade.text_style(theme);
489                        text.color = Some(ink);
490                        theme.widgets.text.style = text;
491                        theme.widgets.icon.color = Some(ink);
492                    },
493                    child,
494                );
495                Box::new(ToggleButton {
496                    child: vec![Box::new(painted)],
497                    index,
498                    count,
499                    selected,
500                    // The seam faces the button before this one, and belongs to both.
501                    previous_selected: index > 0 && self.selected[index - 1],
502                    enabled: self.enabled,
503                    axis: self.axis,
504                    reversed: self.vertical_direction == VerticalDirection::Up,
505                    style,
506                    message: (self.on_pressed)(index),
507                }) as Box<dyn Widget<Msg>>
508            })
509            .collect()
510    }
511}
512
513impl<Msg: Clone + 'static> Widget<Msg> for ToggleButtons<Msg> {
514    fn style(&self) -> Style {
515        Style {
516            flex_direction: match (self.axis, self.vertical_direction) {
517                (ToggleAxis::Horizontal, _) => FlexDirection::Row,
518                (ToggleAxis::Vertical, VerticalDirection::Down) => FlexDirection::Column,
519                (ToggleAxis::Vertical, VerticalDirection::Up) => FlexDirection::ColumnReverse,
520            },
521            // **Every button is as tall as the tallest**, which is what makes the bank one
522            // object: the reference wraps the row in an `IntrinsicHeight`
523            // (`toggle_buttons.dart:856`) to the same end. Stretching is the flexbox
524            // spelling of it and costs no extra pass.
525            align: Align::Stretch,
526            justify: Justify::Start,
527            // No gap. The buttons touch, and the line where they meet is a border.
528            ..Default::default()
529        }
530    }
531
532    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
533        self.built.get_or_init(|| self.assemble())
534    }
535
536    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
537
538    fn on_click(&self) -> Option<Msg> {
539        None
540    }
541}
542
543/// One button of a bank: the child, the ground under it, and the edges it owns.
544struct ToggleButton<Msg> {
545    child: Vec<Box<dyn Widget<Msg>>>,
546    index: usize,
547    count: usize,
548    selected: bool,
549    /// Whether the button **before** this one is on — the other half of the shared-edge
550    /// rule, and the only thing a button needs to know about its neighbours.
551    previous_selected: bool,
552    enabled: bool,
553    axis: ToggleAxis,
554    /// Whether the buttons appear in the reverse of the order they were declared in. For a
555    /// column this is [`VerticalDirection::Up`]; for a row it is the reading direction,
556    /// which is not known until paint.
557    reversed: bool,
558    style: ToggleStyle,
559    message: Msg,
560}
561
562impl<Msg> ToggleButton<Msg> {
563    /// Whether this button appears first — leftmost in a row, topmost in a column.
564    fn visually_first(&self, flipped: bool) -> bool {
565        if flipped {
566            self.index + 1 == self.count
567        } else {
568            self.index == 0
569        }
570    }
571
572    /// Whether it appears last.
573    fn visually_last(&self, flipped: bool) -> bool {
574        if flipped {
575            self.index == 0
576        } else {
577            self.index + 1 == self.count
578        }
579    }
580
581    /// **This button's** corners, out of the bank's.
582    ///
583    /// The pair at the bank's near end goes to whichever button is drawn there and the
584    /// pair at the far end to the one at the other; everything between is square. A bank
585    /// of one keeps all four (`toggle_buttons.dart:503`).
586    fn corners(&self, theme: &Theme, flipped: bool) -> BorderRadius {
587        let r = self.style.radius(theme);
588        if self.count <= 1 {
589            return r;
590        }
591        let first = self.visually_first(flipped);
592        let last = self.visually_last(flipped);
593        match self.axis {
594            ToggleAxis::Horizontal if first => BorderRadius {
595                top_left: r.top_left,
596                bottom_left: r.bottom_left,
597                top_right: 0.0,
598                bottom_right: 0.0,
599            },
600            ToggleAxis::Horizontal if last => BorderRadius {
601                top_left: 0.0,
602                bottom_left: 0.0,
603                top_right: r.top_right,
604                bottom_right: r.bottom_right,
605            },
606            ToggleAxis::Vertical if first => BorderRadius {
607                top_left: r.top_left,
608                top_right: r.top_right,
609                bottom_left: 0.0,
610                bottom_right: 0.0,
611            },
612            ToggleAxis::Vertical if last => BorderRadius {
613                top_left: 0.0,
614                top_right: 0.0,
615                bottom_left: r.bottom_left,
616                bottom_right: r.bottom_right,
617            },
618            _ => BorderRadius::ZERO,
619        }
620    }
621
622    /// The strip this button's **leading** border occupies: the edge facing the button
623    /// before it, which is the start side in a row and the top in a column.
624    fn leading_strip(&self, bounds: Rect, flipped: bool, width: f32) -> Rect {
625        match (self.axis, flipped) {
626            (ToggleAxis::Horizontal, false) => Rect::new(bounds.x, bounds.y, width, bounds.height),
627            (ToggleAxis::Horizontal, true) => Rect::new(
628                bounds.x + bounds.width - width,
629                bounds.y,
630                width,
631                bounds.height,
632            ),
633            (ToggleAxis::Vertical, false) => Rect::new(bounds.x, bounds.y, bounds.width, width),
634            (ToggleAxis::Vertical, true) => Rect::new(
635                bounds.x,
636                bounds.y + bounds.height - width,
637                bounds.width,
638                width,
639            ),
640        }
641    }
642
643    /// The box the outline is drawn over, **grown by one hairline** past the seam when
644    /// there is a next button.
645    ///
646    /// This is the whole trick of a shared edge, and it is worth writing down. A rounded
647    /// rectangle is one call and gets its corners right; four strips are four calls and do
648    /// not. So each button draws a whole outline — and every button but the last puts its
649    /// far edge exactly where the next button's leading border will land, so the next one,
650    /// painted after, covers it. The bank ends up with one hairline per seam, every corner
651    /// an arc, and no button drawing an edge that is not its own.
652    fn outline_box(&self, bounds: Rect, flipped: bool, width: f32) -> Rect {
653        if self.index + 1 == self.count {
654            return bounds;
655        }
656        match (self.axis, flipped) {
657            (ToggleAxis::Horizontal, false) => {
658                Rect::new(bounds.x, bounds.y, bounds.width + width, bounds.height)
659            }
660            (ToggleAxis::Horizontal, true) => Rect::new(
661                bounds.x - width,
662                bounds.y,
663                bounds.width + width,
664                bounds.height,
665            ),
666            (ToggleAxis::Vertical, false) => {
667                Rect::new(bounds.x, bounds.y, bounds.width, bounds.height + width)
668            }
669            (ToggleAxis::Vertical, true) => Rect::new(
670                bounds.x,
671                bounds.y - width,
672                bounds.width,
673                bounds.height + width,
674            ),
675        }
676    }
677}
678
679impl<Msg: Clone> Widget<Msg> for ToggleButton<Msg> {
680    fn style(&self) -> Style {
681        Widget::<Msg>::style_themed(self, &Theme::default())
682    }
683
684    /// The child's box plus the edges this button owns.
685    ///
686    /// The borders are **padding**, not decoration drawn over the content: the reference's
687    /// render object deflates its child by exactly the sides it draws
688    /// (`toggle_buttons.dart:1254`), so a hairline never crosses a letter. A row keeps the
689    /// leading side on the left and lets the frame's mirror move it in a right-to-left
690    /// reading; a column has no mirror, so it swaps the two ends itself.
691    fn style_themed(&self, theme: &Theme) -> Style {
692        let width = self.style.border_width(theme);
693        let leading = width;
694        let trailing = if self.index + 1 == self.count {
695            width
696        } else {
697            0.0
698        };
699        let padding = match (self.axis, self.reversed) {
700            (ToggleAxis::Horizontal, _) => Insets::new(width, trailing, width, leading),
701            (ToggleAxis::Vertical, false) => Insets::new(leading, width, trailing, width),
702            (ToggleAxis::Vertical, true) => Insets::new(trailing, width, leading, width),
703        };
704        let cross = width * 2.0;
705        let main = leading + trailing;
706        let (extra_w, extra_h) = match self.axis {
707            ToggleAxis::Horizontal => (main, cross),
708            ToggleAxis::Vertical => (cross, main),
709        };
710        Style {
711            padding,
712            min_width: Dimension::Length(self.style.min_width(theme) + extra_w),
713            min_height: Dimension::Length(self.style.min_height(theme) + extra_h),
714            max_width: self
715                .style
716                .max_width(theme)
717                .map_or(Dimension::Auto, |w| Dimension::Length(w + extra_w)),
718            max_height: self
719                .style
720                .max_height(theme)
721                .map_or(Dimension::Auto, |h| Dimension::Length(h + extra_h)),
722            flex_direction: FlexDirection::Row,
723            justify: Justify::Center,
724            align: Align::Center,
725            ..Default::default()
726        }
727    }
728
729    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
730        &self.child
731    }
732
733    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
734        let o = status.opacity;
735        // A row mirrors with the frame; a column is told which way it runs.
736        let flipped = match self.axis {
737            ToggleAxis::Horizontal => theme.direction == TextDirection::Rtl,
738            ToggleAxis::Vertical => self.reversed,
739        };
740        let corners = self.corners(theme, flipped);
741
742        // The ground, and the state layer over it. An unselected button has no ground of
743        // its own, so the layer is measured from the surface it stands on — and when
744        // nothing is happening, nothing is painted at all, rather than a transparent
745        // rectangle rubbing out what the bank was put over.
746        let base = self.style.fill(theme, self.enabled, self.selected);
747        let ground = if base.a > 0.0 {
748            base
749        } else {
750            theme.scheme.surface
751        };
752        let fill = if self.enabled {
753            let lit =
754                theme.state_layer(ground, self.style.ink(theme, true, self.selected), &status);
755            if lit == ground {
756                base
757            } else {
758                lit
759            }
760        } else {
761            base
762        };
763        if fill.a > 0.0 {
764            scene.draw_rect(bounds, fill.fade(o), corners, 0.0, Color::TRANSPARENT);
765        }
766
767        let width = self.style.border_width(theme);
768        if width <= 0.0 {
769            return;
770        }
771        let side = self.style.border(theme, self.enabled, self.selected);
772        scene.draw_rect(
773            self.outline_box(bounds, flipped, width),
774            Color::TRANSPARENT,
775            corners,
776            width,
777            side.fade(o),
778        );
779
780        // **The shared edge.** It belongs to this button and the one before it, so either
781        // being on colours it (`toggle_buttons.dart:596`) — which the outline above cannot
782        // express, drawing one colour on four sides. Redrawn only when the two answers
783        // differ, and when they differ the edge is always an inner seam: the outer end of
784        // a bank is the first button's leading side, whose neighbour does not exist, so
785        // there the two rules give the same colour and the arc the outline drew survives.
786        let shared =
787            self.style
788                .border(theme, self.enabled, self.selected || self.previous_selected);
789        if shared != side {
790            scene.fill_rect(self.leading_strip(bounds, flipped, width), shared.fade(o));
791        }
792    }
793
794    fn on_click(&self) -> Option<Msg> {
795        self.enabled.then(|| self.message.clone())
796    }
797
798    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
799        if !self.enabled {
800            return None;
801        }
802        let splash = self.style.ink(theme, true, self.selected).fade(0.10);
803        Some(crate::InkStyle::of(theme).color(splash))
804    }
805
806    fn focusable(&self) -> bool {
807        self.enabled
808    }
809
810    /// A button, and whether it is on (`toggle_buttons.dart:836`).
811    ///
812    /// No label: the child is the caller's and could be anything, so the name a reader
813    /// hears is whatever that child announces. What this adds is the part the child cannot
814    /// know — that it is pressable, and that it is currently down.
815    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
816        let semantics =
817            frus_core::SemanticsProperties::new(frus_core::Role::Button).toggled(self.selected);
818        Some(if self.enabled {
819            semantics.clickable()
820        } else {
821            semantics.disabled(true)
822        })
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use crate::{build_ui, Runtime, Size, Text};
830    use frus_core::Primitive;
831
832    #[derive(Clone, Debug, PartialEq)]
833    enum Msg {
834        Toggle(usize),
835    }
836
837    fn bank(selected: Vec<bool>) -> ToggleButtons<Msg> {
838        let mut buttons = ToggleButtons::new(selected, Msg::Toggle);
839        for label in ["B", "I", "U"] {
840            buttons = buttons.child(Text::new(label));
841        }
842        buttons
843    }
844
845    fn scene_of(buttons: ToggleButtons<Msg>) -> Vec<Primitive> {
846        let root = crate::flex::Flex::column()
847            .width(400.0)
848            .height(120.0)
849            .child(buttons);
850        build_ui(
851            &root,
852            Size::new(400.0, 120.0),
853            &Runtime::default(),
854            &Theme::default(),
855        )
856        .scene()
857        .primitives()
858        .to_vec()
859    }
860
861    /// Every rectangle drawn with a border, and the colour of that border.
862    fn outlines(primitives: &[Primitive]) -> Vec<(Rect, Color)> {
863        primitives
864            .iter()
865            .filter_map(|p| match p {
866                Primitive::Rect {
867                    rect,
868                    border_width,
869                    border_color,
870                    ..
871                } if *border_width > 0.0 => Some((*rect, *border_color)),
872                _ => None,
873            })
874            .collect()
875    }
876
877    fn buttons_of(bank: &ToggleButtons<Msg>) -> &[Box<dyn Widget<Msg>>] {
878        Widget::<Msg>::children(bank)
879    }
880
881    /// A child with no boolean beside it is not a button. The reference asserts the two
882    /// lists match; a `view` is not a place to panic, so the shorter one wins.
883    #[test]
884    fn a_bank_is_as_long_as_its_shorter_list() {
885        assert_eq!(buttons_of(&bank(vec![false, false, false])).len(), 3);
886        assert_eq!(buttons_of(&bank(vec![false])).len(), 1);
887        let extra = ToggleButtons::new(vec![false, false], Msg::Toggle).child(Text::new("B"));
888        assert_eq!(buttons_of(&extra).len(), 1);
889    }
890
891    /// Three buttons, four lines — not six. Each button owns the edge facing the one
892    /// before it, and only the last owns the far end; the outlines overlap by exactly one
893    /// hairline so that a seam is drawn once.
894    #[test]
895    fn the_buttons_share_their_edges() {
896        let primitives = scene_of(bank(vec![false, false, false]));
897        let drawn = outlines(&primitives);
898        assert_eq!(drawn.len(), 3, "one outline per button");
899        let mut boxes: Vec<Rect> = drawn.iter().map(|(r, _)| *r).collect();
900        boxes.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
901        for pair in boxes.windows(2) {
902            let (left, right) = (pair[0], pair[1]);
903            assert!(
904                (left.x + left.width - right.x - TOGGLE_BUTTONS_BORDER_WIDTH).abs() < 0.01,
905                "the outlines meet on the seam, overlapping by one hairline: {left:?} {right:?}"
906            );
907        }
908        // And the last one stops at its own edge rather than reaching past it.
909        let last = boxes.last().unwrap();
910        let bank_right = boxes[0].x + boxes.iter().map(|b| b.width).sum::<f32>()
911            - TOGGLE_BUTTONS_BORDER_WIDTH * 2.0;
912        assert!((last.x + last.width - bank_right).abs() < 0.01);
913    }
914
915    /// The rule a row of separate buttons cannot express: a seam belongs to both of the
916    /// buttons it separates, so **either** being on colours it.
917    #[test]
918    fn a_seam_takes_the_selected_colour_from_either_side() {
919        let marked = Color::rgb(1.0, 0.0, 0.0);
920        let plain = Color::rgb(0.0, 0.0, 1.0);
921        // What is actually drawn on each button's leading edge, in order: the strip when
922        // one was needed, and otherwise the outline that would have been covered by it.
923        // Both are the same answer, which is the point — the extra strip exists only
924        // where the shared rule and the button's own rule disagree.
925        let seams = |selected: Vec<bool>| -> Vec<Color> {
926            let primitives = scene_of(
927                bank(selected)
928                    .selected_border_color(marked)
929                    .border_color(plain),
930            );
931            let mut drawn = outlines(&primitives);
932            drawn.sort_by(|a, b| a.0.x.partial_cmp(&b.0.x).unwrap());
933            let strips: Vec<(Rect, Color)> = primitives
934                .iter()
935                .filter_map(|p| match p {
936                    Primitive::Rect {
937                        rect,
938                        color,
939                        border_width,
940                        ..
941                    } if *border_width == 0.0 && rect.width == TOGGLE_BUTTONS_BORDER_WIDTH => {
942                        Some((*rect, *color))
943                    }
944                    _ => None,
945                })
946                .collect();
947            drawn
948                .into_iter()
949                .map(|(rect, outline)| {
950                    strips
951                        .iter()
952                        .find(|(strip, _)| (strip.x - rect.x).abs() < 0.01)
953                        .map_or(outline, |(_, colour)| *colour)
954                })
955                .collect()
956        };
957        // Nothing on: every edge is the plain one.
958        assert_eq!(seams(vec![false, false, false]), vec![plain, plain, plain]);
959        // The middle button on marks the seams on **both** sides of it — its own leading
960        // edge, which its outline already carries, and the leading edge of the button
961        // after it, which needs the strip.
962        assert_eq!(seams(vec![false, true, false]), vec![plain, marked, marked]);
963        // The first button on marks the bank's own end and the seam after it.
964        assert_eq!(seams(vec![true, false, false]), vec![marked, marked, plain]);
965        // And the last one marks the seam before it, from its own side.
966        assert_eq!(seams(vec![false, false, true]), vec![plain, plain, marked]);
967    }
968
969    /// Only the two ends of a bank are rounded; the buttons between them are square, or
970    /// the corners would appear four times across one control.
971    #[test]
972    fn only_the_ends_of_a_bank_are_rounded() {
973        let primitives = scene_of(bank(vec![false, false, false]).border_radius(8.0));
974        let mut drawn: Vec<(Rect, BorderRadius)> = primitives
975            .iter()
976            .filter_map(|p| match p {
977                Primitive::Rect {
978                    rect,
979                    radius,
980                    border_width,
981                    ..
982                } if *border_width > 0.0 => Some((*rect, *radius)),
983                _ => None,
984            })
985            .collect();
986        drawn.sort_by(|a, b| a.0.x.partial_cmp(&b.0.x).unwrap());
987        let radii: Vec<BorderRadius> = drawn.into_iter().map(|(_, r)| r).collect();
988        assert_eq!(radii[0].top_left, 8.0);
989        assert_eq!(
990            radii[0].top_right, 0.0,
991            "the first button's inner end is square"
992        );
993        assert_eq!(radii[1], BorderRadius::ZERO, "and the middle one entirely");
994        assert_eq!(radii[2].top_right, 8.0);
995        assert_eq!(radii[2].top_left, 0.0);
996    }
997
998    /// A bank of one is not a first button with no last: it keeps all four corners.
999    #[test]
1000    fn a_bank_of_one_keeps_every_corner() {
1001        let one = ToggleButtons::new(vec![false], Msg::Toggle)
1002            .child(Text::new("B"))
1003            .border_radius(8.0);
1004        let primitives = scene_of(one);
1005        let radii: Vec<BorderRadius> = primitives
1006            .iter()
1007            .filter_map(|p| match p {
1008                Primitive::Rect {
1009                    radius,
1010                    border_width,
1011                    ..
1012                } if *border_width > 0.0 => Some(*radius),
1013                _ => None,
1014            })
1015            .collect();
1016        assert_eq!(radii, vec![BorderRadius::uniform(8.0)]);
1017    }
1018
1019    /// The colour reaches the child through the theme, which is what makes a plain `Text`
1020    /// in a button take the selected colour without being told.
1021    #[test]
1022    fn the_state_colours_the_children() {
1023        let theme = Theme::default();
1024        let text_colours = |selected: Vec<bool>, enabled: bool| -> Vec<Color> {
1025            scene_of(bank(selected).enabled(enabled))
1026                .into_iter()
1027                .filter_map(|p| match p {
1028                    Primitive::Text { color, .. } => Some(color),
1029                    _ => None,
1030                })
1031                .collect()
1032        };
1033        let live = text_colours(vec![true, false, false], true);
1034        assert_eq!(live[0], theme.scheme.primary, "the button that is on");
1035        assert_eq!(
1036            live[1],
1037            over_surface(&theme, CONTENT_OPACITY),
1038            "and the ones that are not"
1039        );
1040        for colour in text_colours(vec![true, false, false], false) {
1041            assert_eq!(
1042                colour,
1043                disabled_content(&theme),
1044                "a disabled bank flattens to one grey, its selected button included"
1045            );
1046        }
1047    }
1048
1049    /// Disabled is the four-part contract: no message, no tab stop, no splash, and the
1050    /// disabled colours — plus the part specific to this widget, that a disabled bank
1051    /// never enters the selected state, so nothing is filled.
1052    #[test]
1053    fn a_disabled_bank_is_inert_but_still_readable() {
1054        let theme = Theme::default();
1055        let dead = bank(vec![true, false, false]).enabled(false);
1056        for button in buttons_of(&dead) {
1057            assert_eq!(button.on_click(), None);
1058            assert!(!button.focusable());
1059            assert!(button.ink(&theme).is_none());
1060            assert!(button.semantics().expect("still announced").disabled);
1061        }
1062        // Still says which one is on: a disabled control is read-only, not blank.
1063        assert_eq!(
1064            buttons_of(&dead)[0].semantics().unwrap().toggled,
1065            frus_core::Toggled::True
1066        );
1067        let accent = theme
1068            .scheme
1069            .surface
1070            .lerp(theme.scheme.primary, SELECTED_FILL_OPACITY);
1071        assert!(
1072            !scene_of(bank(vec![true, false, false]).enabled(false))
1073                .into_iter()
1074                .any(|p| matches!(p, Primitive::Rect { color, .. } if color == accent)),
1075            "and never fills with the accent"
1076        );
1077        assert!(
1078            scene_of(bank(vec![true, false, false]))
1079                .into_iter()
1080                .any(|p| matches!(p, Primitive::Rect { color, .. } if color == accent)),
1081            "which a live one does"
1082        );
1083    }
1084
1085    /// A button is at least the tap target however small its child, and the borders are
1086    /// added to that rather than eaten out of it.
1087    #[test]
1088    fn a_button_is_at_least_the_tap_target() {
1089        let theme = Theme::default();
1090        for (i, button) in buttons_of(&bank(vec![false, false, false]))
1091            .iter()
1092            .enumerate()
1093        {
1094            let style = button.style_themed(&theme);
1095            let last = i == 2;
1096            let expected = TOGGLE_BUTTON_MIN_SIZE
1097                + TOGGLE_BUTTONS_BORDER_WIDTH
1098                + if last {
1099                    TOGGLE_BUTTONS_BORDER_WIDTH
1100                } else {
1101                    0.0
1102                };
1103            assert_eq!(style.min_width, Dimension::Length(expected));
1104            assert_eq!(
1105                style.min_height,
1106                Dimension::Length(TOGGLE_BUTTON_MIN_SIZE + TOGGLE_BUTTONS_BORDER_WIDTH * 2.0)
1107            );
1108        }
1109    }
1110
1111    /// The hairline is padding, so it never crosses the child — and only the last button
1112    /// pads the far end, which is the layout half of the shared edge.
1113    #[test]
1114    fn the_hairline_is_room_and_not_decoration() {
1115        let theme = Theme::default();
1116        let buttons = bank(vec![false, false, false]);
1117        let padding = |i: usize| buttons_of(&buttons)[i].style_themed(&theme).padding;
1118        assert_eq!(padding(0).left, TOGGLE_BUTTONS_BORDER_WIDTH);
1119        assert_eq!(padding(0).right, 0.0, "the seam is the next button's");
1120        assert_eq!(padding(2).right, TOGGLE_BUTTONS_BORDER_WIDTH);
1121        assert_eq!(padding(1).top, TOGGLE_BUTTONS_BORDER_WIDTH);
1122        // Told to draw no border, a button takes no room for one either.
1123        let bare = bank(vec![false, false, false]).render_border(false);
1124        assert_eq!(
1125            buttons_of(&bare)[0].style_themed(&theme).padding,
1126            Insets::ZERO
1127        );
1128    }
1129
1130    /// A column runs the other way, and told to run upwards it swaps which end of each
1131    /// button carries its leading hairline — there being no mirror for a column the way
1132    /// there is for a row.
1133    #[test]
1134    fn a_vertical_bank_stacks() {
1135        let theme = Theme::default();
1136        let down = bank(vec![false, false, false]).direction(ToggleAxis::Vertical);
1137        assert_eq!(
1138            Widget::<Msg>::style(&down).flex_direction,
1139            FlexDirection::Column
1140        );
1141        assert_eq!(
1142            buttons_of(&down)[0].style_themed(&theme).padding.top,
1143            TOGGLE_BUTTONS_BORDER_WIDTH
1144        );
1145        let up = bank(vec![false, false, false])
1146            .direction(ToggleAxis::Vertical)
1147            .vertical_direction(VerticalDirection::Up);
1148        assert_eq!(
1149            Widget::<Msg>::style(&up).flex_direction,
1150            FlexDirection::ColumnReverse
1151        );
1152        assert_eq!(
1153            buttons_of(&up)[0].style_themed(&theme).padding.bottom,
1154            TOGGLE_BUTTONS_BORDER_WIDTH
1155        );
1156        assert_eq!(buttons_of(&up)[0].style_themed(&theme).padding.top, 0.0);
1157    }
1158
1159    /// The theme is the last word before the framework's own, for every property the
1160    /// builders answer.
1161    #[test]
1162    fn the_theme_answers_what_the_caller_did_not() {
1163        let mut theme = Theme::default();
1164        let told = Color::rgb(0.0, 1.0, 0.0);
1165        theme.widgets.toggle_buttons.border_color = Some(told);
1166        theme.widgets.toggle_buttons.border_width = Some(3.0);
1167        let style = ToggleStyle::default();
1168        assert_eq!(style.border(&theme, true, false), told);
1169        assert_eq!(style.border_width(&theme), 3.0);
1170        // And the caller still outranks it.
1171        let mine = Color::rgb(1.0, 0.0, 1.0);
1172        let own = ToggleStyle {
1173            border_color: Some(mine),
1174            ..ToggleStyle::default()
1175        };
1176        assert_eq!(own.border(&theme, true, false), mine);
1177    }
1178
1179    /// A message per index, and the bank itself emits nothing: it is the buttons that are
1180    /// pressed.
1181    #[test]
1182    fn each_button_emits_its_own_index() {
1183        let buttons = bank(vec![false, false, false]);
1184        assert_eq!(Widget::<Msg>::on_click(&buttons), None);
1185        for (i, button) in buttons_of(&buttons).iter().enumerate() {
1186            assert_eq!(button.on_click(), Some(Msg::Toggle(i)));
1187        }
1188    }
1189}