1use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4use frus_layout::{Dimension, FlexDirection, Style};
5
6use crate::disabled::disabled_content;
7use crate::interaction::{Interaction, Status};
8use crate::theme::{TapTarget, Theme};
9use crate::widget::Widget;
10
11const DOT: f32 = 20.0;
12const GAP: f32 = 10.0;
13const LABEL_SIZE: f32 = 18.0;
15
16pub struct Radio<Msg = crate::callback::Callback> {
35 label: String,
36 selected: bool,
37 size: f32,
38 colors: RadioColors,
40 enabled: bool,
43 tap_target: Option<TapTarget>,
45 on_click: Option<Msg>,
46}
47
48impl<Msg> Radio<Msg> {
49 pub fn new(selected: bool) -> Self {
52 Self {
53 label: String::new(),
54 selected,
55 size: LABEL_SIZE,
56 colors: RadioColors::default(),
57 enabled: true,
58 tap_target: None,
59 on_click: None,
60 }
61 }
62
63 #[must_use]
66 pub fn label(mut self, label: impl Into<String>) -> Self {
67 self.label = label.into();
68 self
69 }
70
71 #[must_use]
73 pub fn label_size(mut self, size: f32) -> Self {
74 self.size = size;
75 self
76 }
77
78 #[must_use]
80 pub fn on_select(mut self, message: impl Into<Msg>) -> Self {
81 self.on_click = Some(message.into());
82 self
83 }
84
85 #[must_use]
87 pub fn enabled(mut self, enabled: bool) -> Self {
88 self.enabled = enabled;
89 self
90 }
91
92 #[must_use]
94 pub fn selected_color(mut self, color: Color) -> Self {
95 self.colors.selected = Some(color);
96 self
97 }
98
99 #[must_use]
101 pub fn border_color(mut self, color: Color) -> Self {
102 self.colors.border = Some(color);
103 self
104 }
105
106 #[must_use]
108 pub fn active_border_color(mut self, color: Color) -> Self {
109 self.colors.active_border = Some(color);
110 self
111 }
112
113 #[must_use]
115 pub fn label_color(mut self, color: Color) -> Self {
116 self.colors.label = Some(color);
117 self
118 }
119
120 #[must_use]
122 pub fn tap_target(mut self, target: TapTarget) -> Self {
123 self.tap_target = Some(target);
124 self
125 }
126
127 fn label_style(&self) -> ResolvedTextStyle {
131 TextStyle::new(self.size).resolved()
132 }
133
134 fn reserved(&self, theme: &Theme) -> f32 {
137 self.tap_target
138 .or(theme.widgets.radio.tap_target)
139 .unwrap_or(theme.tap_target)
140 .min_side()
141 }
142}
143
144impl<Msg: Clone> Widget<Msg> for Radio<Msg> {
145 fn style(&self) -> Style {
146 Widget::<Msg>::style_themed(self, &Theme::default())
147 }
148
149 fn style_themed(&self, theme: &Theme) -> Style {
154 let least = self.reserved(theme);
155 let style = self.label_style();
156 let line = style.line_height().max(DOT);
157 let label_w = frus_text::measure_resolved(&self.label, &style).width;
158 Style {
159 width: Dimension::Length((DOT + GAP + label_w).max(least).ceil()),
160 height: Dimension::Length(line.max(least).ceil()),
161 ..Default::default()
162 }
163 }
164
165 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
166 &[]
167 }
168
169 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
170 let o = status.opacity;
171 let c = self.colors;
172 let cy = bounds.y + (bounds.height - DOT) * 0.5;
173 let outer = Rect::new(bounds.x, cy, DOT, DOT);
174 let label_style = self.label_style();
175 let resting = c.border.or(theme.widgets.radio.border_color);
187 let chosen = c
188 .selected
189 .or(theme.widgets.radio.selected_color)
190 .unwrap_or(theme.primary);
191 let (ring, dot, label) = if self.enabled {
192 (
193 if self.selected {
194 chosen
195 } else if status.interaction != Interaction::None || status.focused {
196 c.active_border
197 .or(theme.widgets.radio.active_border_color)
198 .or(resting)
199 .unwrap_or(theme.scheme.on_surface)
200 } else {
201 resting.unwrap_or(theme.scheme.on_surface_variant)
202 },
203 chosen,
204 c.label
205 .or(theme.widgets.radio.label_color)
206 .unwrap_or(theme.on_surface),
207 )
208 } else {
209 let dead = disabled_content(theme);
210 (dead, dead, dead)
211 };
212 scene.draw_rect(outer, theme.surface.fade(o), DOT * 0.5, 2.0, ring.fade(o));
213 if self.selected {
214 let inner = DOT * 0.5;
215 let pad = (DOT - inner) * 0.5;
216 scene.draw_rect(
217 Rect::new(outer.x + pad, outer.y + pad, inner, inner),
218 dot.fade(o),
219 inner * 0.5,
220 0.0,
221 Color::TRANSPARENT,
222 );
223 }
224 scene.text(
227 Point::new(
228 bounds.x + DOT + GAP,
229 bounds.y + (bounds.height - label_style.line_height()) * 0.5,
230 ),
231 self.label.clone(),
232 &label_style,
233 label.fade(o),
234 );
235 }
236
237 fn on_click(&self) -> Option<Msg> {
238 if !self.enabled {
239 return None;
240 }
241 self.on_click.clone()
242 }
243
244 fn focusable(&self) -> bool {
245 self.enabled
246 }
247
248 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
249 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::RadioButton)
253 .label(self.label.clone())
254 .toggled(self.selected);
255 Some(if self.enabled {
256 semantics.clickable()
257 } else {
258 semantics.disabled(true)
259 })
260 }
261}
262
263#[derive(Clone, Copy, Debug, Default, PartialEq)]
269struct RadioColors {
270 selected: Option<Color>,
271 border: Option<Color>,
272 active_border: Option<Color>,
273 label: Option<Color>,
274}
275
276pub struct RadioGroup<Msg = crate::callback::Callback> {
278 selected: usize,
279 size: f32,
280 gap: f32,
281 enabled: bool,
282 colors: RadioColors,
283 tap_target: Option<TapTarget>,
284 on_select: Box<dyn Fn(usize) -> Msg>,
285 labels: Vec<String>,
289 options: Vec<Box<dyn Widget<Msg>>>,
290}
291
292impl<Msg: Clone + 'static> RadioGroup<Msg> {
293 pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
295 Self {
296 selected,
297 size: 18.0,
298 gap: 8.0,
299 enabled: true,
300 colors: RadioColors::default(),
301 tap_target: None,
302 on_select: Box::new(on_select),
303 labels: Vec::new(),
304 options: Vec::new(),
305 }
306 }
307
308 pub fn selected_color(mut self, color: Color) -> Self {
310 self.colors.selected = Some(color);
311 self.rebuild();
312 self
313 }
314
315 pub fn border_color(mut self, color: Color) -> Self {
320 self.colors.border = Some(color);
321 self.rebuild();
322 self
323 }
324
325 pub fn active_border_color(mut self, color: Color) -> Self {
327 self.colors.active_border = Some(color);
328 self.rebuild();
329 self
330 }
331
332 pub fn label_color(mut self, color: Color) -> Self {
334 self.colors.label = Some(color);
335 self.rebuild();
336 self
337 }
338
339 pub fn option(mut self, label: impl Into<String>) -> Self {
341 self.labels.push(label.into());
342 self.rebuild();
343 self
344 }
345
346 pub fn enabled(mut self, enabled: bool) -> Self {
353 self.enabled = enabled;
354 self.rebuild();
355 self
356 }
357
358 pub fn tap_target(mut self, target: TapTarget) -> Self {
364 self.tap_target = Some(target);
365 self.rebuild();
366 self
367 }
368
369 fn rebuild(&mut self) {
372 self.options = self
373 .labels
374 .iter()
375 .enumerate()
376 .map(|(index, label)| {
377 Box::new(Radio {
378 label: label.clone(),
379 selected: index == self.selected,
380 size: self.size,
381 colors: self.colors,
382 enabled: self.enabled,
383 tap_target: self.tap_target,
384 on_click: Some((self.on_select)(index)),
385 }) as Box<dyn Widget<Msg>>
386 })
387 .collect();
388 }
389}
390
391impl<Msg: Clone> Widget<Msg> for RadioGroup<Msg> {
392 fn style(&self) -> Style {
393 Style {
394 flex_direction: FlexDirection::Column,
395 gap: self.gap,
396 ..Default::default()
397 }
398 }
399
400 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
401 &self.options
402 }
403
404 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
405
406 fn on_click(&self) -> Option<Msg> {
407 None
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
415
416 #[derive(Clone, Debug, PartialEq)]
417 enum Msg {
418 Pick(usize),
419 }
420
421 #[test]
427 fn an_option_reserves_room_for_a_finger() {
428 let theme = Theme::dark();
429 let option = |target: Option<TapTarget>| Radio::<Msg> {
430 label: "Daily".into(),
431 selected: false,
432 size: 18.0,
433 colors: RadioColors::default(),
434 enabled: true,
435 tap_target: target,
436 on_click: None,
437 };
438 let height =
439 |option: &Radio<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(option, theme)
440 .height
441 {
442 Dimension::Length(h) => h,
443 other => panic!("{other:?}"),
444 };
445 assert_eq!(height(&option(None), &theme), MIN_TAP_TARGET);
446 assert_eq!(
447 height(&option(Some(TapTarget::ShrinkWrap)), &theme),
448 SHRUNK_TAP_TARGET
449 );
450
451 let group = RadioGroup::new(0, Msg::Pick)
453 .option("Daily")
454 .tap_target(TapTarget::ShrinkWrap);
455 for child in Widget::<Msg>::children(&group) {
456 assert_eq!(
457 match child.style_themed(&theme).height {
458 Dimension::Length(h) => h,
459 other => panic!("{other:?}"),
460 },
461 SHRUNK_TAP_TARGET
462 );
463 }
464 }
465
466 #[test]
469 fn the_label_is_centred_in_the_room() {
470 let theme = Theme::dark();
471 let option = Radio::<Msg> {
472 label: "Daily".into(),
473 selected: false,
474 size: 18.0,
475 colors: RadioColors::default(),
476 enabled: true,
477 tap_target: None,
478 on_click: None,
479 };
480 let mut scene = Scene::new();
481 Widget::<Msg>::paint(
482 &option,
483 Rect::new(0.0, 0.0, 200.0, MIN_TAP_TARGET),
484 Status::default(),
485 &theme,
486 &mut scene,
487 );
488 let at = scene
489 .primitives()
490 .iter()
491 .find_map(|p| match p {
492 frus_core::Primitive::Text { position, .. } => Some(*position),
493 _ => None,
494 })
495 .expect("an option paints its label");
496 let line = option.label_style().line_height();
497 assert!(
498 (at.y - (MIN_TAP_TARGET - line) * 0.5).abs() < 0.01,
499 "the label sits in the middle: {at:?}"
500 );
501 }
502
503 #[test]
507 fn an_unselected_ring_is_a_mark_not_a_container_edge() {
508 let theme = Theme::dark();
509 let ring = |selected: bool, interaction, focused| {
510 let mut scene = Scene::new();
511 Widget::<Msg>::paint(
512 &Radio {
513 label: "Daily".into(),
514 selected,
515 size: 18.0,
516 colors: RadioColors::default(),
517 enabled: true,
518 tap_target: None,
519 on_click: Some(Msg::Pick(0)),
520 },
521 Rect::new(0.0, 0.0, 120.0, 20.0),
522 Status {
523 opacity: 1.0,
524 interaction,
525 focused,
526 ..Default::default()
527 },
528 &theme,
529 &mut scene,
530 );
531 scene
532 .primitives()
533 .iter()
534 .find_map(|p| match p {
535 frus_core::Primitive::Rect { border_color, .. } => Some(*border_color),
536 _ => None,
537 })
538 .expect("the ring")
539 };
540 assert_eq!(
541 ring(false, Interaction::None, false),
542 theme.scheme.on_surface_variant,
543 "at rest"
544 );
545 assert_eq!(
546 ring(false, Interaction::Hovered, false),
547 theme.scheme.on_surface,
548 "hovered"
549 );
550 assert_eq!(
551 ring(false, Interaction::None, true),
552 theme.scheme.on_surface,
553 "focused"
554 );
555 for interaction in [
557 Interaction::None,
558 Interaction::Hovered,
559 Interaction::Pressed,
560 ] {
561 assert_eq!(ring(true, interaction, false), theme.primary, "selected");
562 }
563 assert_ne!(
564 ring(false, Interaction::None, false),
565 theme.scheme.outline,
566 "a ring is a mark, not a container's edge"
567 );
568 }
569
570 fn group(enabled_first: bool) -> RadioGroup<Msg> {
571 if enabled_first {
575 RadioGroup::new(1, Msg::Pick)
576 .enabled(false)
577 .option("Daily")
578 .option("Weekly")
579 } else {
580 RadioGroup::new(1, Msg::Pick)
581 .option("Daily")
582 .option("Weekly")
583 .enabled(false)
584 }
585 }
586
587 #[test]
588 fn picking_an_option_reports_its_index() {
589 let live = RadioGroup::new(0, Msg::Pick)
590 .option("Daily")
591 .option("Weekly");
592 let options = Widget::children(&live);
593 assert_eq!(options.len(), 2);
594 assert_eq!(options[1].on_click(), Some(Msg::Pick(1)));
595 }
596
597 #[test]
598 fn a_disabled_group_disables_every_option_whichever_order_it_was_built_in() {
599 for first in [true, false] {
600 let dead = group(first);
601 let options = Widget::children(&dead);
602 assert_eq!(options.len(), 2, "built in order {first}");
603 for (i, option) in options.iter().enumerate() {
604 assert_eq!(option.on_click(), None, "option {i} still answers");
605 assert!(!option.focusable(), "option {i} is still in the tab order");
606 let semantics = option.semantics().expect("still announced");
607 assert!(semantics.disabled, "option {i} does not say it is disabled");
608 }
609 assert_eq!(
611 options[1].semantics().unwrap().toggled,
612 frus_core::Toggled::True
613 );
614 }
615 }
616
617 #[test]
622 fn the_builder_order_does_not_change_what_comes_out() {
623 let before = Widget::children(&group(true))
624 .iter()
625 .map(|o| o.focusable())
626 .collect::<Vec<_>>();
627 let after = Widget::children(&group(false))
628 .iter()
629 .map(|o| o.focusable())
630 .collect::<Vec<_>>();
631 assert_eq!(before, after);
632 assert_eq!(after, vec![false, false]);
633 }
634}
635
636#[cfg(test)]
637mod color_tests {
638 use super::*;
639 use crate::widget::Widget;
640 use frus_core::Primitive;
641
642 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
643 const MARK: Color = Color::rgb(0.9, 0.9, 0.2);
644
645 fn painted(
647 group: &RadioGroup<usize>,
648 index: usize,
649 status: Status,
650 theme: &Theme,
651 ) -> Vec<Color> {
652 let option = &Widget::<usize>::children(group)[index];
653 let mut scene = Scene::new();
654 option.paint(Rect::new(0.0, 0.0, 200.0, DOT), status, theme, &mut scene);
655 scene
656 .primitives()
657 .iter()
658 .filter_map(|p| match p {
659 Primitive::Rect {
660 border_width,
661 border_color,
662 color,
663 ..
664 } => Some(if *border_width > 0.0 {
665 *border_color
666 } else {
667 *color
668 }),
669 Primitive::Text { color, .. } => Some(*color),
670 _ => None,
671 })
672 .collect()
673 }
674
675 fn group() -> RadioGroup<usize> {
676 RadioGroup::new(0, |i| i).option("One").option("Two")
677 }
678
679 fn rest() -> Status {
680 Status {
681 opacity: 1.0,
682 ..Default::default()
683 }
684 }
685
686 #[test]
688 fn the_defaults_are_what_they_were() {
689 let theme = Theme::default();
690 let chosen = painted(&group(), 0, rest(), &theme);
691 assert_eq!(chosen[0], theme.primary, "the ring of the chosen option");
692 assert_eq!(chosen[1], theme.primary, "and its dot");
693 let other = painted(&group(), 1, rest(), &theme);
694 assert_eq!(other[0], theme.scheme.on_surface_variant);
695 }
696
697 #[test]
699 fn the_chosen_option_takes_its_colour() {
700 let theme = Theme::default();
701 let g = group().selected_color(BRAND).label_color(MARK);
702 let chosen = painted(&g, 0, rest(), &theme);
703 assert_eq!((chosen[0], chosen[1]), (BRAND, BRAND));
704 assert_eq!(*chosen.last().expect("the label"), MARK);
705 }
706
707 #[test]
709 fn one_ring_colour_covers_both_states() {
710 let theme = Theme::default();
711 let hovered = Status {
712 opacity: 1.0,
713 interaction: Interaction::Hovered,
714 ..Default::default()
715 };
716 let g = group().border_color(BRAND);
717 assert_eq!(painted(&g, 1, rest(), &theme)[0], BRAND);
718 assert_eq!(painted(&g, 1, hovered, &theme)[0], BRAND);
719
720 let both = group().border_color(BRAND).active_border_color(MARK);
721 assert_eq!(painted(&both, 1, hovered, &theme)[0], MARK);
722 }
723
724 #[test]
726 fn a_colour_set_last_still_reaches_the_options() {
727 let theme = Theme::default();
728 let g = RadioGroup::new(0, |i: usize| i)
729 .option("One")
730 .selected_color(BRAND);
731 assert_eq!(painted(&g, 0, rest(), &theme)[0], BRAND);
732 }
733
734 #[test]
736 fn the_theme_answers_and_the_instance_overrules_it() {
737 let mut theme = Theme::default();
738 theme.widgets.radio.selected_color = Some(MARK);
739 assert_eq!(painted(&group(), 0, rest(), &theme)[0], MARK);
740 assert_eq!(
741 painted(&group().selected_color(BRAND), 0, rest(), &theme)[0],
742 BRAND
743 );
744 }
745}