1use 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
57pub const TOGGLE_BUTTON_MIN_SIZE: f32 = 48.0;
60
61pub const TOGGLE_BUTTONS_BORDER_WIDTH: f32 = 1.0;
63
64const SELECTED_FILL_OPACITY: f32 = 0.12;
66
67const CONTENT_OPACITY: f32 = 0.87;
69
70const BORDER_OPACITY: f32 = 0.12;
72
73#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
80pub enum ToggleAxis {
81 #[default]
83 Horizontal,
84 Vertical,
86}
87
88#[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 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 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 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 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 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 .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 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 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 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
271pub 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 declared: RefCell<Vec<Box<dyn Widget<Msg>>>>,
281 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
282}
283
284impl<Msg: Clone + 'static> ToggleButtons<Msg> {
285 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 #[must_use]
313 pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
314 self.child_boxed(Box::new(child))
315 }
316
317 #[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 #[must_use]
334 pub fn enabled(mut self, enabled: bool) -> Self {
335 self.enabled = enabled;
336 self
337 }
338
339 #[must_use]
341 pub fn direction(mut self, axis: ToggleAxis) -> Self {
342 self.axis = axis;
343 self
344 }
345
346 #[must_use]
350 pub fn vertical_direction(mut self, direction: VerticalDirection) -> Self {
351 self.vertical_direction = direction;
352 self
353 }
354
355 #[must_use]
357 pub fn render_border(mut self, render: bool) -> Self {
358 self.style.render_border = render;
359 self
360 }
361
362 #[must_use]
365 pub fn border_width(mut self, width: f32) -> Self {
366 self.style.border_width = Some(width);
367 self
368 }
369
370 #[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 #[must_use]
381 pub fn border_color(mut self, color: Color) -> Self {
382 self.style.border_color = Some(color);
383 self
384 }
385
386 #[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 #[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 #[must_use]
404 pub fn color(mut self, color: Color) -> Self {
405 self.style.color = Some(color);
406 self
407 }
408
409 #[must_use]
411 pub fn selected_color(mut self, color: Color) -> Self {
412 self.style.selected_color = Some(color);
413 self
414 }
415
416 #[must_use]
418 pub fn disabled_color(mut self, color: Color) -> Self {
419 self.style.disabled_color = Some(color);
420 self
421 }
422
423 #[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 #[must_use]
439 pub fn text_style(mut self, style: TextStyle) -> Self {
440 self.style.text_style = Some(style);
441 self
442 }
443
444 #[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 #[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 fn count(&self) -> usize {
464 self.declared.borrow().len().min(self.selected.len())
465 }
466
467 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 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 align: Align::Stretch,
526 justify: Justify::Start,
527 ..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
543struct ToggleButton<Msg> {
545 child: Vec<Box<dyn Widget<Msg>>>,
546 index: usize,
547 count: usize,
548 selected: bool,
549 previous_selected: bool,
552 enabled: bool,
553 axis: ToggleAxis,
554 reversed: bool,
558 style: ToggleStyle,
559 message: Msg,
560}
561
562impl<Msg> ToggleButton<Msg> {
563 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 assert_eq!(seams(vec![false, false, false]), vec![plain, plain, plain]);
959 assert_eq!(seams(vec![false, true, false]), vec![plain, marked, marked]);
963 assert_eq!(seams(vec![true, false, false]), vec![marked, marked, plain]);
965 assert_eq!(seams(vec![false, false, true]), vec![plain, plain, marked]);
967 }
968
969 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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}