1use 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
31pub const SEGMENTED_HEIGHT: f32 = 40.0;
33pub const SEGMENTED_PADDING: f32 = 12.0;
35pub const SEGMENTED_BORDER_WIDTH: f32 = 1.0;
37pub const SEGMENTED_ICON_SIZE: f32 = 18.0;
39pub const SEGMENTED_ICON_GAP: f32 = 8.0;
41
42#[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 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 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
152struct Segment<Msg> {
154 label: String,
155 index: usize,
156 count: usize,
157 selected: bool,
158 style: SegmentedStyle,
159 message: Msg,
160 enabled: bool,
163}
164
165impl<Msg> Segment<Msg> {
166 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 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 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 let icon = self.style.icon_size(theme);
249 let shows_icon = self.selected && self.style.show_selected_icon(theme);
250 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 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 Some(if self.enabled {
315 semantics.clickable()
316 } else {
317 semantics.disabled(true)
318 })
319 }
320}
321
322pub struct SegmentedButton<Msg = crate::callback::Callback> {
324 selected: usize,
325 on_select: Box<dyn Fn(usize) -> Msg>,
326 labels: Rc<Vec<String>>,
327 enabled: bool,
330 style: SegmentedStyle,
331 children: Vec<Box<dyn Widget<Msg>>>,
332}
333
334impl<Msg: Clone + 'static> SegmentedButton<Msg> {
335 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 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 pub fn radius(mut self, radius: f32) -> Self {
356 self.style.radius = Some(radius);
357 self.rebuild();
358 self
359 }
360
361 pub fn selected_color(mut self, color: Color) -> Self {
363 self.style.selected_color = Some(color);
364 self.rebuild();
365 self
366 }
367
368 pub fn label_color(mut self, color: Color) -> Self {
370 self.style.label_color = Some(color);
371 self.rebuild();
372 self
373 }
374
375 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 pub fn border_color(mut self, color: Color) -> Self {
384 self.style.border_color = Some(color);
385 self.rebuild();
386 self
387 }
388
389 pub fn border_width(mut self, width: f32) -> Self {
391 self.style.border_width = Some(width);
392 self.rebuild();
393 self
394 }
395
396 pub fn height(mut self, height: f32) -> Self {
398 self.style.height = Some(height);
399 self.rebuild();
400 self
401 }
402
403 pub fn padding(mut self, padding: f32) -> Self {
405 self.style.padding = Some(padding);
406 self.rebuild();
407 self
408 }
409
410 pub fn label_style(mut self, style: TextStyle) -> Self {
412 self.style.label_style = Some(style);
413 self.rebuild();
414 self
415 }
416
417 pub fn enabled(mut self, enabled: bool) -> Self {
421 self.enabled = enabled;
422 self.rebuild();
423 self
424 }
425
426 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 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 max_width: Dimension::Percent(1.0),
468 height: Dimension::Length(self.style.height(theme)),
469 flex_direction: FlexDirection::Row,
470 ..Default::default()
472 }
473 }
474
475 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
476 &self.children
477 }
478
479 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 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 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 #[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 let chosen = segments(&dead)[1].semantics().unwrap();
591 assert_eq!(chosen.toggled, frus_core::Toggled::True);
592 }
593
594 #[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 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 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 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 #[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 assert!(
727 (rects[1] - room).abs() <= 1.0,
728 "the control is {}, not the {room} on offer",
729 rects[1]
730 );
731 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 #[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 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 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}