1use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4
5use crate::widgetstate::{WidgetState, WidgetStates};
6use frus_layout::{Dimension, FlexDirection, Style};
7
8use crate::disabled::disabled_content;
9use crate::interaction::Status;
10use crate::theme::{TapTarget, Theme};
11use crate::widget::Widget;
12
13const OUTER: f32 = 8.0;
15const INNER: f32 = 4.5;
17const STROKE: f32 = 2.0;
19const TOGGLE_SECONDS: f32 = 0.2;
21const LABEL_SIZE: f32 = 18.0;
23
24pub struct Radio<Msg = crate::callback::Callback> {
43 label: String,
44 selected: bool,
45 size: f32,
46 colors: RadioColors,
48 enabled: bool,
51 tap_target: Option<TapTarget>,
53 visual_density: Option<crate::VisualDensity>,
55 splash_radius: Option<f32>,
57 on_click: Option<Msg>,
58}
59
60impl<Msg> Radio<Msg> {
61 pub fn new(selected: bool) -> Self {
64 Self {
65 label: String::new(),
66 selected,
67 size: LABEL_SIZE,
68 colors: RadioColors::default(),
69 enabled: true,
70 tap_target: None,
71 visual_density: None,
72 splash_radius: None,
73 on_click: None,
74 }
75 }
76
77 #[must_use]
80 pub fn label(mut self, label: impl Into<String>) -> Self {
81 self.label = label.into();
82 self
83 }
84
85 #[must_use]
87 pub fn label_size(mut self, size: f32) -> Self {
88 self.size = size;
89 self
90 }
91
92 #[must_use]
94 pub fn on_select(mut self, message: impl Into<Msg>) -> Self {
95 self.on_click = Some(message.into());
96 self
97 }
98
99 #[must_use]
101 pub fn enabled(mut self, enabled: bool) -> Self {
102 self.enabled = enabled;
103 self
104 }
105
106 #[must_use]
108 pub fn selected_color(mut self, color: Color) -> Self {
109 self.colors.selected = Some(color);
110 self
111 }
112
113 #[must_use]
115 pub fn border_color(mut self, color: Color) -> Self {
116 self.colors.border = Some(color);
117 self
118 }
119
120 #[must_use]
122 pub fn active_border_color(mut self, color: Color) -> Self {
123 self.colors.active_border = Some(color);
124 self
125 }
126
127 #[must_use]
129 pub fn label_color(mut self, color: Color) -> Self {
130 self.colors.label = Some(color);
131 self
132 }
133
134 #[must_use]
137 pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
138 self.visual_density = Some(density);
139 self
140 }
141
142 #[must_use]
145 pub fn splash_radius(mut self, radius: f32) -> Self {
146 self.splash_radius = Some(radius);
147 self
148 }
149
150 #[must_use]
152 pub fn tap_target(mut self, target: TapTarget) -> Self {
153 self.tap_target = Some(target);
154 self
155 }
156
157 fn label_style(&self) -> ResolvedTextStyle {
161 TextStyle::new(self.size).resolved()
162 }
163
164 fn square(&self, theme: &Theme) -> f32 {
167 let target = self
168 .tap_target
169 .or(theme.widgets.radio.tap_target)
170 .unwrap_or(theme.tap_target);
171 let side = match target {
172 TapTarget::Padded => crate::MIN_TAP_TARGET,
173 TapTarget::ShrinkWrap => crate::MIN_TAP_TARGET - 8.0,
174 };
175 let density = self
176 .visual_density
177 .or(theme.widgets.radio.visual_density)
178 .unwrap_or_else(|| theme.visual_density());
179 (side + density.base_size_adjustment().1).max(2.0 * (OUTER + STROKE * 0.5))
180 }
181
182 fn fill(&self, theme: &Theme, states: WidgetStates) -> Color {
186 let c = self.colors;
187 if states.contains(WidgetState::Disabled) {
188 return disabled_content(theme);
189 }
190 if states.contains(WidgetState::Selected) {
191 return c
192 .selected
193 .or(theme.widgets.radio.selected_color)
194 .unwrap_or(theme.scheme.primary);
195 }
196 let resting = c.border.or(theme.widgets.radio.border_color);
197 let active = states.contains(WidgetState::Pressed)
198 || states.contains(WidgetState::Hovered)
199 || states.contains(WidgetState::Focused);
200 if active {
201 c.active_border
202 .or(theme.widgets.radio.active_border_color)
203 .or(resting)
204 .unwrap_or(theme.scheme.on_surface)
205 } else {
206 resting.unwrap_or(theme.scheme.on_surface_variant)
207 }
208 }
209
210 fn states(&self, status: &Status) -> WidgetStates {
212 let states = if self.enabled {
213 status.states()
214 } else {
215 WidgetStates::of(WidgetState::Disabled)
216 };
217 states.set(WidgetState::Selected, self.selected)
218 }
219}
220
221impl<Msg: Clone> Widget<Msg> for Radio<Msg> {
222 fn style(&self) -> Style {
223 Widget::<Msg>::style_themed(self, &Theme::default())
224 }
225
226 fn style_themed(&self, theme: &Theme) -> Style {
229 let square = self.square(theme);
230 let style = self.label_style();
231 let label_w = if self.label.is_empty() {
232 0.0
233 } else {
234 frus_text::measure_resolved(&self.label, &style).width
235 };
236 Style {
237 width: Dimension::Length((square + label_w).ceil()),
238 height: Dimension::Length(style.line_height().max(square).ceil()),
239 ..Default::default()
240 }
241 }
242
243 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
244 &[]
245 }
246
247 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
248 let o = status.opacity;
249 let square = self.square(theme);
250 let centre = Point::new(bounds.x + square * 0.5, bounds.y + bounds.height * 0.5);
251 let states = self.states(&status);
252
253 if self.enabled {
254 let radius = self
255 .splash_radius
256 .or(theme.widgets.radio.splash_radius)
257 .unwrap_or(crate::toggleable::SPLASH);
258 let overlay = theme.widgets.radio.overlay_color.as_ref();
259 crate::toggleable::paint_halos(scene, centre, radius, &status, |state| {
260 let s = states.set(state, true);
261 overlay
262 .and_then(|p| p.resolve(s).copied())
263 .unwrap_or_else(|| crate::toggleable::overlay(theme, s))
264 });
265 }
266
267 let t = status.value.clamp(0.0, 1.0);
270 let off = self.fill(theme, states.set(WidgetState::Selected, false));
271 let on = self.fill(theme, states.set(WidgetState::Selected, true));
272 let ring = if t >= 1.0 {
274 on
275 } else if t <= 0.0 {
276 off
277 } else {
278 off.lerp(on, t)
279 };
280 let edge = OUTER + STROKE * 0.5;
283 scene.draw_rect(
284 Rect::new(centre.x - edge, centre.y - edge, 2.0 * edge, 2.0 * edge),
285 Color::TRANSPARENT,
286 edge,
287 STROKE,
288 ring.fade(o),
289 );
290 if t > 0.0 {
291 let r = INNER * t;
292 scene.draw_rect(
293 Rect::new(centre.x - r, centre.y - r, 2.0 * r, 2.0 * r),
294 ring.fade(o),
295 r,
296 0.0,
297 Color::TRANSPARENT,
298 );
299 }
300
301 if !self.label.is_empty() {
302 let colour = if self.enabled {
303 self.colors
304 .label
305 .or(theme.widgets.radio.label_color)
306 .unwrap_or(theme.on_surface)
307 } else {
308 disabled_content(theme)
309 };
310 let style = self.label_style();
311 scene.text(
312 Point::new(
313 bounds.x + square,
314 bounds.y + (bounds.height - style.line_height()) * 0.5,
315 ),
316 self.label.clone(),
317 &style,
318 colour.fade(o),
319 );
320 }
321 }
322
323 fn anim_target(&self) -> Option<f32> {
325 Some(if self.selected { 1.0 } else { 0.0 })
326 }
327
328 fn anim_duration(&self) -> f32 {
329 TOGGLE_SECONDS
330 }
331
332 fn on_click(&self) -> Option<Msg> {
333 if !self.enabled {
334 return None;
335 }
336 self.on_click.clone()
337 }
338
339 fn focusable(&self) -> bool {
340 self.enabled
341 }
342
343 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
344 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::RadioButton)
348 .label(self.label.clone())
349 .toggled(self.selected);
350 Some(if self.enabled {
351 semantics.clickable()
352 } else {
353 semantics.disabled(true)
354 })
355 }
356}
357
358#[derive(Clone, Copy, Debug, Default, PartialEq)]
364struct RadioColors {
365 selected: Option<Color>,
366 border: Option<Color>,
367 active_border: Option<Color>,
368 label: Option<Color>,
369}
370
371pub struct RadioGroup<Msg = crate::callback::Callback> {
373 selected: usize,
374 size: f32,
375 gap: f32,
376 enabled: bool,
377 colors: RadioColors,
378 tap_target: Option<TapTarget>,
379 on_select: Box<dyn Fn(usize) -> Msg>,
380 labels: Vec<String>,
384 options: Vec<Box<dyn Widget<Msg>>>,
385}
386
387impl<Msg: Clone + 'static> RadioGroup<Msg> {
388 pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
390 Self {
391 selected,
392 size: 18.0,
393 gap: 8.0,
394 enabled: true,
395 colors: RadioColors::default(),
396 tap_target: None,
397 on_select: Box::new(on_select),
398 labels: Vec::new(),
399 options: Vec::new(),
400 }
401 }
402
403 pub fn selected_color(mut self, color: Color) -> Self {
405 self.colors.selected = Some(color);
406 self.rebuild();
407 self
408 }
409
410 pub fn border_color(mut self, color: Color) -> Self {
415 self.colors.border = Some(color);
416 self.rebuild();
417 self
418 }
419
420 pub fn active_border_color(mut self, color: Color) -> Self {
422 self.colors.active_border = Some(color);
423 self.rebuild();
424 self
425 }
426
427 pub fn label_color(mut self, color: Color) -> Self {
429 self.colors.label = Some(color);
430 self.rebuild();
431 self
432 }
433
434 pub fn option(mut self, label: impl Into<String>) -> Self {
436 self.labels.push(label.into());
437 self.rebuild();
438 self
439 }
440
441 pub fn enabled(mut self, enabled: bool) -> Self {
448 self.enabled = enabled;
449 self.rebuild();
450 self
451 }
452
453 pub fn tap_target(mut self, target: TapTarget) -> Self {
459 self.tap_target = Some(target);
460 self.rebuild();
461 self
462 }
463
464 fn rebuild(&mut self) {
467 self.options = self
468 .labels
469 .iter()
470 .enumerate()
471 .map(|(index, label)| {
472 Box::new(Radio {
473 label: label.clone(),
474 selected: index == self.selected,
475 size: self.size,
476 colors: self.colors,
477 enabled: self.enabled,
478 tap_target: self.tap_target,
479 visual_density: None,
480 splash_radius: None,
481 on_click: Some((self.on_select)(index)),
482 }) as Box<dyn Widget<Msg>>
483 })
484 .collect();
485 }
486}
487
488impl<Msg: Clone> Widget<Msg> for RadioGroup<Msg> {
489 fn style(&self) -> Style {
490 Style {
491 flex_direction: FlexDirection::Column,
492 gap: self.gap,
493 ..Default::default()
494 }
495 }
496
497 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
498 &self.options
499 }
500
501 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
502
503 fn on_click(&self) -> Option<Msg> {
504 None
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::interaction::Interaction;
512 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
513
514 #[derive(Clone, Debug, PartialEq)]
515 enum Msg {
516 Pick(usize),
517 }
518
519 #[test]
525 fn an_option_reserves_room_for_a_finger() {
526 let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Android);
528 let option = |target: Option<TapTarget>| Radio::<Msg> {
529 label: "Daily".into(),
530 selected: false,
531 size: 18.0,
532 colors: RadioColors::default(),
533 enabled: true,
534 tap_target: target,
535 visual_density: None,
536 splash_radius: None,
537 on_click: None,
538 };
539 let height =
540 |option: &Radio<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(option, theme)
541 .height
542 {
543 Dimension::Length(h) => h,
544 other => panic!("{other:?}"),
545 };
546 assert_eq!(height(&option(None), &theme), MIN_TAP_TARGET);
547 assert_eq!(
548 height(&option(Some(TapTarget::ShrinkWrap)), &theme),
549 SHRUNK_TAP_TARGET
550 );
551
552 let group = RadioGroup::new(0, Msg::Pick)
554 .option("Daily")
555 .tap_target(TapTarget::ShrinkWrap);
556 for child in Widget::<Msg>::children(&group) {
557 assert_eq!(
558 match child.style_themed(&theme).height {
559 Dimension::Length(h) => h,
560 other => panic!("{other:?}"),
561 },
562 SHRUNK_TAP_TARGET
563 );
564 }
565 }
566
567 #[test]
570 fn the_label_is_centred_in_the_room() {
571 let theme = Theme::dark();
572 let option = Radio::<Msg> {
573 label: "Daily".into(),
574 selected: false,
575 size: 18.0,
576 colors: RadioColors::default(),
577 enabled: true,
578 tap_target: None,
579 visual_density: None,
580 splash_radius: None,
581 on_click: None,
582 };
583 let mut scene = Scene::new();
584 Widget::<Msg>::paint(
585 &option,
586 Rect::new(0.0, 0.0, 200.0, MIN_TAP_TARGET),
587 Status::default(),
588 &theme,
589 &mut scene,
590 );
591 let at = scene
592 .primitives()
593 .iter()
594 .find_map(|p| match p {
595 frus_core::Primitive::Text { position, .. } => Some(*position),
596 _ => None,
597 })
598 .expect("an option paints its label");
599 let line = option.label_style().line_height();
600 assert!(
601 (at.y - (MIN_TAP_TARGET - line) * 0.5).abs() < 0.01,
602 "the label sits in the middle: {at:?}"
603 );
604 }
605
606 #[test]
610 fn an_unselected_ring_is_a_mark_not_a_container_edge() {
611 let theme = Theme::dark();
612 let ring = |selected: bool, interaction, focused| {
613 let mut scene = Scene::new();
614 Widget::<Msg>::paint(
615 &Radio {
616 label: "Daily".into(),
617 selected,
618 size: 18.0,
619 colors: RadioColors::default(),
620 enabled: true,
621 tap_target: None,
622 visual_density: None,
623 splash_radius: None,
624 on_click: Some(Msg::Pick(0)),
625 },
626 Rect::new(0.0, 0.0, 120.0, 20.0),
627 Status {
628 opacity: 1.0,
629 interaction,
630 focused,
631 value: if selected { 1.0 } else { 0.0 },
633 ..Default::default()
634 },
635 &theme,
636 &mut scene,
637 );
638 scene
639 .primitives()
640 .iter()
641 .find_map(|p| match p {
642 frus_core::Primitive::Rect {
644 border_color,
645 border_width,
646 ..
647 } if *border_width > 0.0 => Some(*border_color),
648 _ => None,
649 })
650 .expect("the ring")
651 };
652 assert_eq!(
653 ring(false, Interaction::None, false),
654 theme.scheme.on_surface_variant,
655 "at rest"
656 );
657 assert_eq!(
658 ring(false, Interaction::Hovered, false),
659 theme.scheme.on_surface,
660 "hovered"
661 );
662 assert_eq!(
663 ring(false, Interaction::None, true),
664 theme.scheme.on_surface,
665 "focused"
666 );
667 for interaction in [
669 Interaction::None,
670 Interaction::Hovered,
671 Interaction::Pressed,
672 ] {
673 assert_eq!(ring(true, interaction, false), theme.primary, "selected");
674 }
675 assert_ne!(
676 ring(false, Interaction::None, false),
677 theme.scheme.outline,
678 "a ring is a mark, not a container's edge"
679 );
680 }
681
682 #[test]
686 fn the_ring_and_the_dot_are_the_reference_s() {
687 let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Android);
688 let painted = |value: f32| {
689 let mut scene = Scene::new();
690 Widget::<Msg>::paint(
691 &Radio::<Msg>::new(true),
692 Rect::new(0.0, 0.0, 48.0, 48.0),
693 Status {
694 opacity: 1.0,
695 value,
696 ..Default::default()
697 },
698 &theme,
699 &mut scene,
700 );
701 scene
702 .primitives()
703 .iter()
704 .filter_map(|p| match p {
705 frus_core::Primitive::Rect {
706 rect, border_width, ..
707 } => Some((*rect, *border_width)),
708 _ => None,
709 })
710 .collect::<Vec<_>>()
711 };
712 let settled = painted(1.0);
713 assert_eq!(
714 settled[0],
715 (Rect::new(15.0, 15.0, 18.0, 18.0), 2.0),
716 "the ring"
717 );
718 assert_eq!(
719 settled[1].0,
720 Rect::new(19.5, 19.5, 9.0, 9.0),
721 "the dot, 4.5 round"
722 );
723 let half = painted(0.5);
724 assert_eq!(half[1].0, Rect::new(21.75, 21.75, 4.5, 4.5), "half grown");
725 assert_eq!(painted(0.0).len(), 1, "no dot before it is chosen");
726 }
727
728 fn group(enabled_first: bool) -> RadioGroup<Msg> {
729 if enabled_first {
733 RadioGroup::new(1, Msg::Pick)
734 .enabled(false)
735 .option("Daily")
736 .option("Weekly")
737 } else {
738 RadioGroup::new(1, Msg::Pick)
739 .option("Daily")
740 .option("Weekly")
741 .enabled(false)
742 }
743 }
744
745 #[test]
746 fn picking_an_option_reports_its_index() {
747 let live = RadioGroup::new(0, Msg::Pick)
748 .option("Daily")
749 .option("Weekly");
750 let options = Widget::children(&live);
751 assert_eq!(options.len(), 2);
752 assert_eq!(options[1].on_click(), Some(Msg::Pick(1)));
753 }
754
755 #[test]
756 fn a_disabled_group_disables_every_option_whichever_order_it_was_built_in() {
757 for first in [true, false] {
758 let dead = group(first);
759 let options = Widget::children(&dead);
760 assert_eq!(options.len(), 2, "built in order {first}");
761 for (i, option) in options.iter().enumerate() {
762 assert_eq!(option.on_click(), None, "option {i} still answers");
763 assert!(!option.focusable(), "option {i} is still in the tab order");
764 let semantics = option.semantics().expect("still announced");
765 assert!(semantics.disabled, "option {i} does not say it is disabled");
766 }
767 assert_eq!(
769 options[1].semantics().unwrap().toggled,
770 frus_core::Toggled::True
771 );
772 }
773 }
774
775 #[test]
780 fn the_builder_order_does_not_change_what_comes_out() {
781 let before = Widget::children(&group(true))
782 .iter()
783 .map(|o| o.focusable())
784 .collect::<Vec<_>>();
785 let after = Widget::children(&group(false))
786 .iter()
787 .map(|o| o.focusable())
788 .collect::<Vec<_>>();
789 assert_eq!(before, after);
790 assert_eq!(after, vec![false, false]);
791 }
792}
793
794#[cfg(test)]
795mod color_tests {
796 use super::*;
797 use crate::interaction::Interaction;
798 use crate::widget::Widget;
799 use frus_core::Primitive;
800
801 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
802 const MARK: Color = Color::rgb(0.9, 0.9, 0.2);
803
804 fn painted(
806 group: &RadioGroup<usize>,
807 index: usize,
808 status: Status,
809 theme: &Theme,
810 ) -> Vec<Color> {
811 let option = &Widget::<usize>::children(group)[index];
812 let mut scene = Scene::new();
813 let status = Status {
815 value: if index == 0 { 1.0 } else { 0.0 },
816 ..status
817 };
818 option.paint(Rect::new(0.0, 0.0, 200.0, 48.0), status, theme, &mut scene);
819 scene
820 .primitives()
821 .iter()
822 .filter_map(|p| match p {
823 Primitive::Rect {
824 border_width,
825 border_color,
826 color,
827 ..
828 } => Some(if *border_width > 0.0 {
829 *border_color
830 } else {
831 *color
832 }),
833 Primitive::Text { color, .. } => Some(*color),
834 _ => None,
835 })
836 .collect()
837 }
838
839 fn group() -> RadioGroup<usize> {
840 RadioGroup::new(0, |i| i).option("One").option("Two")
841 }
842
843 fn rest() -> Status {
844 Status {
845 opacity: 1.0,
846 ..Default::default()
847 }
848 }
849
850 #[test]
852 fn the_defaults_are_what_they_were() {
853 let theme = Theme::default();
854 let chosen = painted(&group(), 0, rest(), &theme);
855 assert_eq!(chosen[0], theme.primary, "the ring of the chosen option");
856 assert_eq!(chosen[1], theme.primary, "and its dot");
857 let other = painted(&group(), 1, rest(), &theme);
858 assert_eq!(other[0], theme.scheme.on_surface_variant);
859 }
860
861 #[test]
863 fn the_chosen_option_takes_its_colour() {
864 let theme = Theme::default();
865 let g = group().selected_color(BRAND).label_color(MARK);
866 let chosen = painted(&g, 0, rest(), &theme);
867 assert_eq!((chosen[0], chosen[1]), (BRAND, BRAND));
868 assert_eq!(*chosen.last().expect("the label"), MARK);
869 }
870
871 #[test]
873 fn one_ring_colour_covers_both_states() {
874 let theme = Theme::default();
875 let hovered = Status {
876 opacity: 1.0,
877 interaction: Interaction::Hovered,
878 ..Default::default()
879 };
880 let g = group().border_color(BRAND);
881 assert_eq!(painted(&g, 1, rest(), &theme)[0], BRAND);
882 assert_eq!(painted(&g, 1, hovered, &theme)[0], BRAND);
883
884 let both = group().border_color(BRAND).active_border_color(MARK);
885 assert_eq!(painted(&both, 1, hovered, &theme)[0], MARK);
886 }
887
888 #[test]
890 fn a_colour_set_last_still_reaches_the_options() {
891 let theme = Theme::default();
892 let g = RadioGroup::new(0, |i: usize| i)
893 .option("One")
894 .selected_color(BRAND);
895 assert_eq!(painted(&g, 0, rest(), &theme)[0], BRAND);
896 }
897
898 #[test]
900 fn the_theme_answers_and_the_instance_overrules_it() {
901 let mut theme = Theme::default();
902 theme.widgets.radio.selected_color = Some(MARK);
903 assert_eq!(painted(&group(), 0, rest(), &theme)[0], MARK);
904 assert_eq!(
905 painted(&group().selected_color(BRAND), 0, rest(), &theme)[0],
906 BRAND
907 );
908 }
909}