Skip to main content

frus_widgets/
segmented.rs

1//! [`SegmentedButton`]: a **controlled** segmented picker — one outline around several
2//! segments, of which one is chosen.
3//!
4//! It is one control rather than a row of buttons: a single outline runs around the group,
5//! hairlines divide the segments, and the chosen one fills with the tonal container and
6//! takes a checkmark. That is the shape of the reference's segmented button, and it is what
7//! distinguishes the control from three buttons that happen to be touching — which is what
8//! this was until milestone 314.
9//!
10//! ```ignore
11//! SegmentedButton::new(selected, Msg::Pick)
12//!     .segment("Day")
13//!     .segment("Week")
14//!     .segment("Month")
15//! ```
16//!
17//! Every measurement and colour is overridable, per call or through
18//! [`SegmentedTheme`](crate::SegmentedTheme).
19
20use std::rc::Rc;
21
22use frus_core::{BorderRadius, Color, Point, Rect, Scene, TextStyle};
23use frus_layout::{Dimension, FlexDirection, Style};
24
25use crate::disabled::{disabled_container, disabled_content};
26use crate::icons::Icons;
27use crate::interaction::Status;
28use crate::theme::Theme;
29use crate::widget::Widget;
30
31/// The control's height.
32pub const SEGMENTED_HEIGHT: f32 = 40.0;
33/// The room either side of a segment's label.
34pub const SEGMENTED_PADDING: f32 = 12.0;
35/// The outline around the group, and the hairlines between its segments.
36pub const SEGMENTED_BORDER_WIDTH: f32 = 1.0;
37/// The checkmark on the chosen segment.
38pub const SEGMENTED_ICON_SIZE: f32 = 18.0;
39/// Between that checkmark and the label.
40pub const SEGMENTED_ICON_GAP: f32 = 8.0;
41
42/// Everything about the control's appearance, each part `None` until someone says
43/// otherwise. Shared by the control and its segments, so that the outline the one draws and
44/// the fills the others draw agree about where the edges are.
45#[derive(Clone, Copy, Debug, Default, PartialEq)]
46pub(crate) struct SegmentedStyle {
47    pub selected_color: Option<Color>,
48    pub label_color: Option<Color>,
49    pub selected_label_color: Option<Color>,
50    pub border_color: Option<Color>,
51    pub border_width: Option<f32>,
52    pub radius: Option<f32>,
53    pub height: Option<f32>,
54    pub padding: Option<f32>,
55    pub label_style: Option<TextStyle>,
56    pub icon_size: Option<f32>,
57    pub show_selected_icon: Option<bool>,
58}
59
60impl SegmentedStyle {
61    fn selected_color(&self, theme: &Theme) -> Color {
62        self.selected_color
63            .or(theme.widgets.segmented.selected_color)
64            .unwrap_or(theme.scheme.secondary_container)
65    }
66
67    fn label_color(&self, theme: &Theme, selected: bool) -> Color {
68        if selected {
69            self.selected_label_color
70                .or(theme.widgets.segmented.selected_label_color)
71                .unwrap_or(theme.scheme.on_secondary_container)
72        } else {
73            self.label_color
74                .or(theme.widgets.segmented.label_color)
75                .unwrap_or(theme.scheme.on_surface)
76        }
77    }
78
79    fn border_color(&self, theme: &Theme) -> Color {
80        self.border_color
81            .or(theme.widgets.segmented.border_color)
82            .unwrap_or(theme.scheme.outline)
83    }
84
85    fn border_width(&self, theme: &Theme) -> f32 {
86        self.border_width
87            .or(theme.widgets.segmented.border_width)
88            .unwrap_or(SEGMENTED_BORDER_WIDTH)
89    }
90
91    fn height(&self, theme: &Theme) -> f32 {
92        self.height
93            .or(theme.widgets.segmented.height)
94            .unwrap_or(SEGMENTED_HEIGHT)
95    }
96
97    /// The group's outer corners. Unset, the ends are **stadium** — half the height — which
98    /// is what the reference's shape means when the height is free to change.
99    fn radius(&self, theme: &Theme) -> f32 {
100        self.radius
101            .or(theme.widgets.segmented.radius)
102            .unwrap_or(self.height(theme) / 2.0)
103    }
104
105    fn padding(&self, theme: &Theme) -> f32 {
106        self.padding
107            .or(theme.widgets.segmented.padding)
108            .unwrap_or(SEGMENTED_PADDING)
109    }
110
111    fn label_style(&self, theme: &Theme) -> TextStyle {
112        self.label_style
113            .or(theme.widgets.segmented.label_style)
114            .unwrap_or(theme.text.label_large)
115    }
116
117    fn icon_size(&self, theme: &Theme) -> f32 {
118        self.icon_size
119            .or(theme.widgets.segmented.icon_size)
120            .unwrap_or(SEGMENTED_ICON_SIZE)
121    }
122
123    fn show_selected_icon(&self, theme: &Theme) -> bool {
124        self.show_selected_icon
125            .or(theme.widgets.segmented.show_selected_icon)
126            .unwrap_or(true)
127    }
128
129    /// The width **every** segment takes: the widest label, plus the checkmark's room if
130    /// there is one, plus the padding.
131    ///
132    /// The reference gives every segment the width of the widest, which is why this is
133    /// asked of the **control** rather than of a segment: a segment that sized itself
134    /// would make a control whose divisions move when a label is renamed. It is the
135    /// control's natural width; the segments then divide whatever width it is granted,
136    /// equally, which comes to the same thing whenever there is room for it.
137    fn segment_width(&self, theme: &Theme, labels: &[String]) -> f32 {
138        let style = self.label_style(theme);
139        let widest = labels
140            .iter()
141            .map(|label| frus_text::measure_style(label, style).width)
142            .fold(0.0_f32, f32::max);
143        let icon = if self.show_selected_icon(theme) {
144            self.icon_size(theme) + SEGMENTED_ICON_GAP
145        } else {
146            0.0
147        };
148        (widest + icon + self.padding(theme) * 2.0).ceil()
149    }
150}
151
152/// One segment: a fill when it is the chosen one, a label, and a tap.
153struct Segment<Msg> {
154    label: String,
155    index: usize,
156    count: usize,
157    selected: bool,
158    style: SegmentedStyle,
159    message: Msg,
160    /// The control's availability. A segment is never disabled on its own here — the
161    /// reference can disable one of several, which is noted as missing.
162    enabled: bool,
163}
164
165impl<Msg> Segment<Msg> {
166    /// The segment's own corners: the group's radius on the outside, square at the joints.
167    fn radius(&self, theme: &Theme) -> BorderRadius {
168        let r = self.style.radius(theme);
169        match (self.index == 0, self.index + 1 == self.count) {
170            (true, true) => BorderRadius::uniform(r),
171            (true, false) => BorderRadius {
172                top_left: r,
173                bottom_left: r,
174                top_right: 0.0,
175                bottom_right: 0.0,
176            },
177            (false, true) => BorderRadius {
178                top_right: r,
179                bottom_right: r,
180                top_left: 0.0,
181                bottom_left: 0.0,
182            },
183            (false, false) => BorderRadius::ZERO,
184        }
185    }
186}
187
188impl<Msg: Clone> Widget<Msg> for Segment<Msg> {
189    fn style(&self) -> Style {
190        Widget::<Msg>::style_themed(self, &Theme::default())
191    }
192
193    fn style_themed(&self, _theme: &Theme) -> Style {
194        Style {
195            // **An equal share**, not a fixed width. The reference sizes every segment
196            // alike and caps that size at `maxWidth / count`, so the control never runs
197            // past its parent; a fixed width sized from the widest label cannot do the
198            // capping half, and on a phone the last segment was simply drawn outside the
199            // card (found by the overflow survey, milestone 335).
200            flex_basis: Dimension::Length(0.0),
201            flex_grow: 1.0,
202            min_width: Dimension::Length(0.0),
203            height: Dimension::Percent(1.0),
204            ..Default::default()
205        }
206    }
207
208    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
209        &[]
210    }
211
212    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
213        let o = status.opacity;
214        let border = self.style.border_width(theme);
215        let label_style = self.style.label_style(theme);
216        let color = if self.enabled {
217            self.style.label_color(theme, self.selected)
218        } else {
219            disabled_content(theme)
220        };
221
222        // The fill sits **inside** the group's outline rather than over it: the control
223        // draws one outline around the lot, and a fill painted edge to edge would rub out
224        // the part of it that runs along the chosen segment.
225        if self.selected {
226            let inset = Rect::new(
227                bounds.x + border,
228                bounds.y + border,
229                (bounds.width - border * 2.0).max(0.0),
230                (bounds.height - border * 2.0).max(0.0),
231            );
232            scene.draw_rect(
233                inset,
234                if self.enabled {
235                    self.style.selected_color(theme)
236                } else {
237                    disabled_container(theme)
238                }
239                .fade(o),
240                self.radius(theme),
241                0.0,
242                Color::TRANSPARENT,
243            );
244        }
245
246        // The checkmark and the label are centred **together**, so that the pair reads as
247        // one thing rather than a label pushed aside by a tick.
248        let icon = self.style.icon_size(theme);
249        let shows_icon = self.selected && self.style.show_selected_icon(theme);
250        // Cut to the room left once the padding and any checkmark are taken out. A
251        // segment that has had to share a narrow control shows an ellipsis rather than a
252        // label running into its neighbour.
253        let room = bounds.width
254            - self.style.padding(theme) * 2.0
255            - if shows_icon {
256                icon + SEGMENTED_ICON_GAP
257            } else {
258                0.0
259            };
260        let label = crate::text::truncate(&self.label, &label_style.resolved(), room);
261        let measured = frus_text::measure_style(&label, label_style);
262        let group = measured.width
263            + if shows_icon {
264                icon + SEGMENTED_ICON_GAP
265            } else {
266                0.0
267            };
268        let mut x = bounds.x + (bounds.width - group) / 2.0;
269        if shows_icon {
270            let path = Icons::CHECK.placed(
271                icon,
272                x,
273                bounds.y + (bounds.height - icon) / 2.0,
274                theme.direction,
275            );
276            scene.fill_path(&path, color.fade(o));
277            x += icon + SEGMENTED_ICON_GAP;
278        }
279        scene.text(
280            Point::new(x, bounds.y + (bounds.height - measured.height) / 2.0),
281            label,
282            &label_style.resolved(),
283            color.fade(o),
284        );
285    }
286
287    fn on_click(&self) -> Option<Msg> {
288        self.enabled.then(|| self.message.clone())
289    }
290
291    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
292        // No splash where there is nothing to answer it.
293        if !self.enabled {
294            return None;
295        }
296        let splash = self.style.label_color(theme, self.selected).fade(0.10);
297        Some(
298            crate::InkStyle::of(theme)
299                .color(splash)
300                .radius(self.radius(theme)),
301        )
302    }
303
304    fn focusable(&self) -> bool {
305        self.enabled
306    }
307
308    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
309        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button)
310            .label(self.label.clone())
311            .toggled(self.selected);
312        // Still announced, and still saying which one is chosen: a disabled control is
313        // read-only, not invisible.
314        Some(if self.enabled {
315            semantics.clickable()
316        } else {
317            semantics.disabled(true)
318        })
319    }
320}
321
322/// A single-selection segmented control.
323pub struct SegmentedButton<Msg = crate::callback::Callback> {
324    selected: usize,
325    on_select: Box<dyn Fn(usize) -> Msg>,
326    labels: Rc<Vec<String>>,
327    /// A control that is shown but cannot be used: greyed out and inert, its labels and
328    /// its current choice still legible. The reference dims rather than hides.
329    enabled: bool,
330    style: SegmentedStyle,
331    children: Vec<Box<dyn Widget<Msg>>>,
332}
333
334impl<Msg: Clone + 'static> SegmentedButton<Msg> {
335    /// Creates a control: `selected` is the active index, `on_select(i)` fires on click.
336    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
337        Self {
338            selected,
339            on_select: Box::new(on_select),
340            labels: Rc::new(Vec::new()),
341            enabled: true,
342            style: SegmentedStyle::default(),
343            children: Vec::new(),
344        }
345    }
346
347    /// Adds a segment.
348    pub fn segment(mut self, label: impl Into<String>) -> Self {
349        Rc::make_mut(&mut self.labels).push(label.into());
350        self.rebuild();
351        self
352    }
353
354    /// The radius of the group's outer corners. Unset, the ends are stadium-rounded.
355    pub fn radius(mut self, radius: f32) -> Self {
356        self.style.radius = Some(radius);
357        self.rebuild();
358        self
359    }
360
361    /// The fill under the chosen segment.
362    pub fn selected_color(mut self, color: Color) -> Self {
363        self.style.selected_color = Some(color);
364        self.rebuild();
365        self
366    }
367
368    /// The labels' colour.
369    pub fn label_color(mut self, color: Color) -> Self {
370        self.style.label_color = Some(color);
371        self.rebuild();
372        self
373    }
374
375    /// The chosen segment's label colour, and its checkmark's.
376    pub fn selected_label_color(mut self, color: Color) -> Self {
377        self.style.selected_label_color = Some(color);
378        self.rebuild();
379        self
380    }
381
382    /// The outline's colour, which is also the hairlines' between segments.
383    pub fn border_color(mut self, color: Color) -> Self {
384        self.style.border_color = Some(color);
385        self.rebuild();
386        self
387    }
388
389    /// The outline's thickness; `0.0` removes it, hairlines included.
390    pub fn border_width(mut self, width: f32) -> Self {
391        self.style.border_width = Some(width);
392        self.rebuild();
393        self
394    }
395
396    /// The control's height.
397    pub fn height(mut self, height: f32) -> Self {
398        self.style.height = Some(height);
399        self.rebuild();
400        self
401    }
402
403    /// The room either side of a label.
404    pub fn padding(mut self, padding: f32) -> Self {
405        self.style.padding = Some(padding);
406        self.rebuild();
407        self
408    }
409
410    /// The labels' type. Defaults to the theme's `label_large` step.
411    pub fn label_style(mut self, style: TextStyle) -> Self {
412        self.style.label_style = Some(style);
413        self.rebuild();
414        self
415    }
416
417    /// Greys the whole control out and makes it inert: no press, no ink, out of the tab
418    /// order, and announced as disabled. The labels and the current choice stay legible —
419    /// a disabled control is read-only, not invisible.
420    pub fn enabled(mut self, enabled: bool) -> Self {
421        self.enabled = enabled;
422        self.rebuild();
423        self
424    }
425
426    /// Whether the chosen segment carries a checkmark. On by default; turning it off gives
427    /// every segment back the room it reserved.
428    pub fn show_selected_icon(mut self, show: bool) -> Self {
429        self.style.show_selected_icon = Some(show);
430        self.rebuild();
431        self
432    }
433
434    /// (Re)builds the segments. Every builder calls it, so that the order of the chain is
435    /// not part of the API.
436    fn rebuild(&mut self) {
437        let count = self.labels.len();
438        self.children = (0..count)
439            .map(|i| {
440                Box::new(Segment {
441                    label: self.labels[i].clone(),
442                    index: i,
443                    count,
444                    selected: i == self.selected,
445                    style: self.style,
446                    message: (self.on_select)(i),
447                    enabled: self.enabled,
448                }) as Box<dyn Widget<Msg>>
449            })
450            .collect();
451    }
452}
453
454impl<Msg: Clone> Widget<Msg> for SegmentedButton<Msg> {
455    fn style(&self) -> Style {
456        Widget::<Msg>::style_themed(self, &Theme::default())
457    }
458
459    fn style_themed(&self, theme: &Theme) -> Style {
460        Style {
461            width: Dimension::Length(
462                self.style.segment_width(theme, &self.labels) * self.labels.len() as f32,
463            ),
464            // The cap. With room, the control is its natural width and the segments each
465            // get exactly `segment_width` back — the same picture as before. Without, it
466            // stops at the parent's edge and the segments divide what there is.
467            max_width: Dimension::Percent(1.0),
468            height: Dimension::Length(self.style.height(theme)),
469            flex_direction: FlexDirection::Row,
470            // No gap: the segments touch, and the hairline between them is drawn here.
471            ..Default::default()
472        }
473    }
474
475    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
476        &self.children
477    }
478
479    /// The **group's** outline and the hairlines between its segments — drawn by the
480    /// control, under the segments, because they belong to the control rather than to any
481    /// one of them. Segments that drew their own would double every join.
482    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
483        let count = self.labels.len();
484        let width = self.style.border_width(theme);
485        if count == 0 || width <= 0.0 || bounds.width <= 0.0 {
486            return;
487        }
488        let o = status.opacity;
489        // Disabled flattens to `on_surface` at 12 %, as `Button` and `Chip` do: the same
490        // grey everywhere says unavailable, where a faded accent only says quieter.
491        let color = if self.enabled {
492            self.style.border_color(theme)
493        } else {
494            disabled_container(theme)
495        }
496        .fade(o);
497        scene.draw_rect(
498            bounds,
499            Color::TRANSPARENT,
500            BorderRadius::uniform(self.style.radius(theme)),
501            width,
502            color,
503        );
504        // The divisions are the control's **own box** divided by their number, which is
505        // exactly how the segments divide it. Taken from the natural segment width
506        // instead, the two agreed only while the control had all the room it wanted: on a
507        // phone, where milestone 335 caps it, the hairlines stayed at their roomy spacing
508        // and stopped falling where the segments meet — visible on a device before this
509        // line was changed, and the reason a fix should be looked at as well as measured.
510        let segment = bounds.width / count as f32;
511        for i in 1..count {
512            scene.fill_rect(
513                Rect::new(
514                    bounds.x + segment * i as f32,
515                    bounds.y,
516                    width,
517                    bounds.height,
518                ),
519                color,
520            );
521        }
522    }
523
524    fn on_click(&self) -> Option<Msg> {
525        None
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::{build_ui, Runtime, Size};
533    use frus_core::Primitive;
534
535    #[derive(Clone, Debug, PartialEq)]
536    enum Msg {
537        Select(usize),
538    }
539
540    fn three(selected: usize) -> SegmentedButton<Msg> {
541        SegmentedButton::new(selected, Msg::Select)
542            .segment("One")
543            .segment("Two")
544            .segment("Three")
545    }
546
547    fn primitives(control: SegmentedButton<Msg>) -> Vec<Primitive> {
548        let root = crate::flex::Flex::column()
549            .width(400.0)
550            .height(60.0)
551            .child(control);
552        build_ui(
553            &root,
554            Size::new(400.0, 60.0),
555            &Runtime::default(),
556            &Theme::default(),
557        )
558        .scene()
559        .primitives()
560        .to_vec()
561    }
562
563    /// The gap milestones 312, 313 and 314 each recorded as missing. Greying out is the
564    /// easy half: a disabled control must also be out of the tab order, refuse the press,
565    /// splash at nothing, and still tell a reader which segment is chosen — read-only, not
566    /// invisible.
567    #[test]
568    fn a_disabled_control_is_inert_but_still_readable() {
569        let live = three(1);
570        let dead = three(1).enabled(false);
571        fn segments(c: &SegmentedButton<Msg>) -> &[Box<dyn Widget<Msg>>] {
572            Widget::<Msg>::children(c)
573        }
574        for seg in segments(&live) {
575            assert!(seg.on_click().is_some());
576            assert!(seg.focusable());
577        }
578        for seg in segments(&dead) {
579            assert_eq!(seg.on_click(), None, "disabled: the press goes nowhere");
580            assert!(!seg.focusable(), "disabled: out of the tab order");
581            assert!(
582                seg.ink(&Theme::default()).is_none(),
583                "disabled: nothing to splash for"
584            );
585            let semantics = seg.semantics().expect("still announced");
586            assert!(semantics.disabled, "and announced as disabled");
587        }
588        // The chosen segment is still identifiable to a reader: a control that stops
589        // saying which one is on has become a row of words.
590        let chosen = segments(&dead)[1].semantics().unwrap();
591        assert_eq!(chosen.toggled, frus_core::Toggled::True);
592    }
593
594    /// Disabled flattens to one grey — the reference collapses every state to
595    /// `on_surface` at 12 % under a label at 38 %, so unavailable reads as unavailable
596    /// rather than as a quieter version of the accent.
597    #[test]
598    fn a_disabled_control_flattens_rather_than_fades() {
599        let theme = Theme::default();
600        let accent = SegmentedStyle::default().selected_color(&theme);
601        let fills = |c: SegmentedButton<Msg>| -> Vec<frus_core::Color> {
602            primitives(c)
603                .into_iter()
604                .filter_map(|p| match p {
605                    Primitive::Rect { color, .. } if color.a > 0.0 => Some(color),
606                    _ => None,
607                })
608                .collect()
609        };
610        assert!(
611            fills(three(1)).contains(&accent),
612            "a live control fills the chosen segment with the accent"
613        );
614        assert!(
615            !fills(three(1).enabled(false)).contains(&accent),
616            "a disabled one never uses it"
617        );
618    }
619
620    #[test]
621    fn click_emits_the_index() {
622        let seg = three(0);
623        let children = Widget::<Msg>::children(&seg);
624        assert_eq!(children.len(), 3);
625        assert_eq!(children[2].on_click(), Some(Msg::Select(2)));
626    }
627
628    #[test]
629    fn one_outline_runs_around_the_group() {
630        // Not three outlines touching: a single stroked box with the group's radius, and
631        // hairlines where the segments meet.
632        let theme = Theme::default();
633        let painted = primitives(three(0));
634        let outlines: Vec<(Rect, BorderRadius)> = painted
635            .iter()
636            .filter_map(|p| match p {
637                Primitive::Rect {
638                    rect,
639                    radius,
640                    border_width,
641                    ..
642                } if *border_width > 0.0 => Some((*rect, *radius)),
643                _ => None,
644            })
645            .collect();
646        assert_eq!(outlines.len(), 1, "one outline, not one per segment");
647        assert_eq!(
648            outlines[0].1,
649            BorderRadius::uniform(SEGMENTED_HEIGHT / 2.0),
650            "stadium ends"
651        );
652        // Two hairlines for three segments, in the outline's colour.
653        let hairlines = painted
654            .iter()
655            .filter(|p| {
656                matches!(p, Primitive::Rect { rect, color, border_width, .. }
657                    if *border_width == 0.0
658                        && *color == theme.scheme.outline
659                        && rect.width == SEGMENTED_BORDER_WIDTH)
660            })
661            .count();
662        assert_eq!(hairlines, 2);
663        // And they fall where the segments actually meet.
664        // Asked of the **control**: a segment no longer carries a width of its own, it
665        // takes an equal share of whatever the control is granted (milestone 335). With
666        // room, that share is the natural width, which is what this reads.
667        let control = three(0);
668        let seg_width = match Widget::<Msg>::style_themed(&control, &theme).width {
669            Dimension::Length(w) => w / 3.0,
670            other => panic!("{other:?}"),
671        };
672        let xs: Vec<f32> = painted
673            .iter()
674            .filter_map(|p| match p {
675                Primitive::Rect {
676                    rect,
677                    color,
678                    border_width,
679                    ..
680                } if *border_width == 0.0
681                    && *color == theme.scheme.outline
682                    && rect.width == SEGMENTED_BORDER_WIDTH =>
683                {
684                    Some(rect.x)
685                }
686                _ => None,
687            })
688            .collect();
689        assert_eq!(xs, vec![seg_width, seg_width * 2.0]);
690    }
691
692    /// The reference caps a segment at `maxWidth / count`, so the control never runs past
693    /// its parent. Ours sized every segment from the widest label and let the sum go where
694    /// it liked: on a phone the chart dashboard's four segments came to 584 px in a 363 px
695    /// row and the last one was drawn 221 px outside the card, which the overflow survey
696    /// of milestone 335 found on its first run.
697    #[test]
698    fn a_control_too_wide_for_its_room_divides_it_instead_of_leaving_it() {
699        use crate::interaction::WidgetId;
700        use crate::runtime::Runtime;
701
702        let theme = Theme::default();
703        let natural = match Widget::<Msg>::style_themed(&three(0), &theme).width {
704            Dimension::Length(w) => w,
705            other => panic!("{other:?}"),
706        };
707        let room = natural / 2.0;
708
709        let row = crate::Flex::row().child(three(0)).width(room);
710        let runtime = Runtime::default();
711        let mut layout = frus_layout::Layout::new();
712        let node = crate::ui::build_layout(&row, WidgetId::ROOT, &runtime, &theme, &mut layout);
713        layout.compute_filled(node, room, 60.0);
714
715        assert!(
716            layout.overflows(node).is_empty(),
717            "it fits: {:?}",
718            layout.overflows(node)
719        );
720        let rects: Vec<f32> = layout
721            .absolute_rects(node)
722            .iter()
723            .map(|(r, _)| r.width)
724            .collect();
725        // Taffy rounds a box to whole pixels, so "exactly the room" is to within one.
726        assert!(
727            (rects[1] - room).abs() <= 1.0,
728            "the control is {}, not the {room} on offer",
729            rects[1]
730        );
731        // Three segments, an equal share each.
732        for (i, w) in rects[2..5].iter().enumerate() {
733            assert!(
734                (w - room / 3.0).abs() <= 1.0,
735                "segment {i} is {w}, not a third of {room}"
736            );
737        }
738    }
739
740    /// And the hairlines follow. They used to be spaced by the natural segment width,
741    /// which is the same number only while the control has all the room it wants.
742    #[test]
743    fn the_hairlines_fall_where_the_segments_meet_however_narrow_it_is() {
744        let theme = Theme::default();
745        let control = three(0);
746        let bounds = Rect::new(0.0, 0.0, 120.0, 40.0);
747        let mut scene = Scene::new();
748        Widget::<Msg>::paint(&control, bounds, Status::default(), &theme, &mut scene);
749        let xs: Vec<f32> = scene
750            .primitives()
751            .iter()
752            .filter_map(|p| match p {
753                Primitive::Rect {
754                    rect, border_width, ..
755                } if *border_width == 0.0 && rect.width == SEGMENTED_BORDER_WIDTH => Some(rect.x),
756                _ => None,
757            })
758            .collect();
759        assert_eq!(xs, vec![40.0, 80.0], "thirds of the box it was given");
760    }
761
762    #[test]
763    fn the_chosen_segment_fills_and_takes_a_checkmark() {
764        let theme = Theme::default();
765        let painted = primitives(three(1));
766        let fills = painted
767            .iter()
768            .filter(|p| {
769                matches!(p, Primitive::Rect { color, .. } if *color == theme.scheme.secondary_container)
770            })
771            .count();
772        assert_eq!(fills, 1, "one segment fills, and it is the tonal container");
773        let checks = painted
774            .iter()
775            .filter(|p| matches!(p, Primitive::Path { .. }))
776            .count();
777        assert_eq!(checks, 1, "and it carries a checkmark");
778        assert_eq!(
779            primitives(three(1).show_selected_icon(false))
780                .iter()
781                .filter(|p| matches!(p, Primitive::Path { .. }))
782                .count(),
783            0,
784            "which can be refused"
785        );
786    }
787
788    #[test]
789    fn the_fill_sits_inside_the_outline() {
790        // A fill painted edge to edge would rub out the part of the group's outline that
791        // runs along the chosen segment.
792        let theme = Theme::default();
793        let painted = primitives(three(0));
794        let outline = painted
795            .iter()
796            .find_map(|p| match p {
797                Primitive::Rect {
798                    rect, border_width, ..
799                } if *border_width > 0.0 => Some(*rect),
800                _ => None,
801            })
802            .expect("an outline");
803        let fill = painted
804            .iter()
805            .find_map(|p| match p {
806                Primitive::Rect { rect, color, .. }
807                    if *color == theme.scheme.secondary_container =>
808                {
809                    Some(*rect)
810                }
811                _ => None,
812            })
813            .expect("a fill");
814        assert!(
815            fill.y > outline.y,
816            "the fill starts below the outline's edge"
817        );
818        assert!(fill.y + fill.height < outline.y + outline.height);
819    }
820
821    #[test]
822    fn every_segment_is_as_wide_as_the_widest() {
823        // The reference gives every segment the width of the widest, so that renaming one
824        // does not move the divisions between the others.
825        let theme = Theme::default();
826        let seg = three(0);
827        let widths: Vec<Dimension> = Widget::<Msg>::children(&seg)
828            .iter()
829            .map(|child| child.style_themed(&theme).width)
830            .collect();
831        assert_eq!(widths[0], widths[1]);
832        assert_eq!(widths[1], widths[2]);
833    }
834
835    #[test]
836    fn a_segment_says_whether_it_is_the_chosen_one() {
837        let seg = three(1);
838        let children = Widget::<Msg>::children(&seg);
839        assert_eq!(
840            children[1].semantics().unwrap().toggled,
841            frus_core::Toggled::True
842        );
843        assert_eq!(
844            children[0].semantics().unwrap().toggled,
845            frus_core::Toggled::False
846        );
847    }
848
849    #[test]
850    fn every_measurement_is_the_callers_and_then_the_themes() {
851        let mut theme = Theme::default();
852        theme.widgets.segmented.height = Some(48.0);
853        let height =
854            |control: SegmentedButton<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(
855                &control, theme,
856            )
857            .height
858            {
859                Dimension::Length(h) => h,
860                other => panic!("{other:?}"),
861            };
862        assert_eq!(
863            height(three(0), &Theme::default()),
864            SEGMENTED_HEIGHT,
865            "the framework's"
866        );
867        assert_eq!(height(three(0), &theme), 48.0, "the theme's");
868        assert_eq!(
869            height(three(0).height(32.0), &theme),
870            32.0,
871            "the caller's, over the theme's"
872        );
873    }
874}