1use std::time::Duration;
8
9use tessera_foundation::gesture::TapRecognizer;
10use tessera_ui::{
11 AccessibilityActionHandler, AccessibilityNode, AxisConstraint, Callback, CallbackWith, Color,
12 Dp, FocusDirection, FocusManager, FocusRequester, FocusState, FocusTraversalPolicy, Modifier,
13 Px, PxSize, RenderSlotWith, SemanticsModifierNode, State,
14 accesskit::{Orientation, Role, Toggled},
15 current_frame_nanos,
16 layout::layout,
17 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
18 receive_frame_nanos, remember, tessera, use_context,
19};
20
21use crate::{
22 alignment::Alignment,
23 animation,
24 boxed::boxed,
25 modifier::{InteractionState, ModifierExt as _, PointerEventContext, SelectableArgs},
26 ripple_state::{RippleSpec, RippleState},
27 shape_def::Shape,
28 surface::{SurfaceStyle, surface},
29 theme::{MaterialAlpha, MaterialTheme},
30};
31
32const RADIO_ANIMATION_DURATION: Duration = Duration::from_millis(200);
33
34#[cfg(all(test, not(target_family = "wasm")))]
35mod tests;
36
37struct RadioSemantics {
38 group: bool,
39 selected: bool,
40 label: Option<String>,
41 description: Option<String>,
42 orientation: Option<Orientation>,
43}
44
45impl SemanticsModifierNode for RadioSemantics {
46 fn apply(
47 &self,
48 node: &mut AccessibilityNode,
49 handler: &mut Option<AccessibilityActionHandler>,
50 ) {
51 node.role = Some(if self.group {
52 Role::RadioGroup
53 } else {
54 Role::RadioButton
55 });
56 if let Some(label) = &self.label {
57 node.label = Some(label.clone());
58 }
59 if let Some(description) = &self.description {
60 node.description = Some(description.clone());
61 }
62 if let Some(orientation) = self.orientation {
63 node.orientation = Some(orientation);
64 }
65 if !self.group {
66 node.toggled = Some(if self.selected {
67 Toggled::True
68 } else {
69 Toggled::False
70 });
71 node.disabled = true;
72 node.focusable = false;
73 node.actions.clear();
74 *handler = None;
75 }
76 }
77}
78
79#[derive(Clone, Copy, PartialEq)]
81pub struct RadioGroupSelection {
82 selected: State<Option<State<RadioButtonController>>>,
83}
84
85fn claim_group_selection(
86 group: State<Option<State<RadioButtonController>>>,
87 controller: State<RadioButtonController>,
88) {
89 if group.with(|selected| *selected == Some(controller)) {
90 return;
91 }
92 let previous = group.with_mut(|selected| selected.replace(controller));
93 if let Some(previous) = previous
94 && previous.is_alive()
95 {
96 previous.with_mut(|controller| controller.set_selected(false));
97 }
98}
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum RadioGroupOrientation {
103 #[default]
105 Vertical,
106 Horizontal,
108}
109
110pub struct RadioButtonDefaults;
112
113impl RadioButtonDefaults {
114 pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
116}
117
118#[derive(Clone, PartialEq)]
121pub struct RadioButtonController {
122 selected: bool,
123 progress: f32,
124 start_progress: f32,
125 last_change_frame_nanos: Option<u64>,
126}
127
128impl Default for RadioButtonController {
129 fn default() -> Self {
130 Self::new(false)
131 }
132}
133
134impl RadioButtonController {
135 pub fn new(selected: bool) -> Self {
137 let progress = if selected { 1.0 } else { 0.0 };
138 Self {
139 selected,
140 progress,
141 start_progress: progress,
142 last_change_frame_nanos: None,
143 }
144 }
145
146 pub fn is_selected(&self) -> bool {
148 self.selected
149 }
150
151 pub fn set_selected(&mut self, selected: bool) {
153 if self.selected != selected {
154 self.selected = selected;
155 self.start_progress = self.progress;
156 self.last_change_frame_nanos = Some(current_frame_nanos());
157 }
158 }
159
160 pub fn select(&mut self) -> bool {
163 if self.selected {
164 return false;
165 }
166 self.set_selected(true);
167 true
168 }
169
170 fn update_animation(&mut self, frame_nanos: u64) {
171 if let Some(start_frame_nanos) = self.last_change_frame_nanos {
172 let elapsed_nanos = frame_nanos.saturating_sub(start_frame_nanos);
173 let animation_nanos = RADIO_ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
174 let fraction = (elapsed_nanos as f32 / animation_nanos as f32).min(1.0);
175 let target = if self.selected { 1.0 } else { 0.0 };
176 self.progress = self.start_progress + (target - self.start_progress) * fraction;
177 if fraction >= 1.0 {
178 self.last_change_frame_nanos = None;
179 self.progress = target;
180 self.start_progress = target;
181 }
182 }
183 }
184
185 fn animation_progress(&self) -> f32 {
186 self.progress
187 }
188
189 fn is_animating(&self) -> bool {
190 self.last_change_frame_nanos.is_some()
191 }
192}
193
194fn interpolate_color(a: Color, b: Color, t: f32) -> Color {
195 let factor = t.clamp(0.0, 1.0);
196 Color {
197 r: a.r + (b.r - a.r) * factor,
198 g: a.g + (b.g - a.g) * factor,
199 b: a.b + (b.b - a.b) * factor,
200 a: a.a + (b.a - a.a) * factor,
201 }
202}
203
204#[tessera]
250pub fn radio_group(
251 modifier: Option<Modifier>,
252 orientation: Option<RadioGroupOrientation>,
253 wrap: Option<bool>,
254 #[prop(into)] accessibility_label: Option<String>,
255 #[prop(into)] accessibility_description: Option<String>,
256 content: Option<RenderSlotWith<RadioGroupSelection>>,
257) {
258 let modifier = modifier.unwrap_or_default();
259 let wrap = wrap.unwrap_or(true);
260 let selected = remember(|| None::<State<RadioButtonController>>);
261 let orientation = match orientation.unwrap_or_default() {
262 RadioGroupOrientation::Horizontal => Orientation::Horizontal,
263 RadioGroupOrientation::Vertical => Orientation::Vertical,
264 };
265 let modifier = modifier.focus_group().focus_traversal_policy(
266 FocusTraversalPolicy::linear()
267 .wrap(wrap)
268 .single_tab_stop(true),
269 );
270 let modifier = modifier.push_semantics(RadioSemantics {
271 group: true,
272 selected: false,
273 label: accessibility_label,
274 description: accessibility_description,
275 orientation: Some(orientation),
276 });
277 layout().modifier(modifier).child(move || {
278 if let Some(content) = content {
279 content.render(RadioGroupSelection { selected });
280 }
281 });
282}
283
284#[tessera]
332pub fn radio_button(
333 modifier: Option<Modifier>,
334 focus_requester: Option<FocusRequester>,
335 on_select: Option<CallbackWith<bool, ()>>,
336 selected: Option<bool>,
337 group: Option<RadioGroupSelection>,
338 size: Option<Dp>,
339 touch_target_size: Option<Dp>,
340 stroke_width: Option<Dp>,
341 dot_size: Option<Dp>,
342 selected_color: Option<Color>,
343 unselected_color: Option<Color>,
344 disabled_selected_color: Option<Color>,
345 disabled_unselected_color: Option<Color>,
346 enabled: Option<bool>,
347 #[prop(into)] accessibility_label: Option<String>,
348 #[prop(into)] accessibility_description: Option<String>,
349 controller: Option<State<RadioButtonController>>,
350) {
351 let modifier = modifier.unwrap_or_default();
352 let enabled = enabled.unwrap_or(true);
353 let scheme = use_context::<MaterialTheme>()
354 .expect("MaterialTheme must be provided")
355 .get()
356 .color_scheme;
357 let size = size.unwrap_or(Dp(20.0));
358 let touch_target_size = touch_target_size.unwrap_or(Dp(48.0));
359 let stroke_width = stroke_width.unwrap_or(Dp(2.0));
360 let dot_size = dot_size.unwrap_or(Dp(10.0));
361 let selected_color = selected_color.unwrap_or(scheme.primary);
362 let unselected_interaction_color = unselected_color.unwrap_or(scheme.on_surface);
363 let unselected_color = unselected_color.unwrap_or(scheme.on_surface_variant);
364 let disabled_selected_color = disabled_selected_color.unwrap_or(
365 scheme
366 .on_surface
367 .with_alpha(MaterialAlpha::DISABLED_CONTENT),
368 );
369 let disabled_unselected_color = disabled_unselected_color.unwrap_or(
370 scheme
371 .on_surface
372 .with_alpha(MaterialAlpha::DISABLED_CONTENT),
373 );
374 let on_select = on_select.unwrap_or_else(CallbackWith::default_value);
375
376 let controller = if let Some(controller) = controller {
377 controller
378 } else {
379 remember(|| RadioButtonController::new(selected.unwrap_or(false)))
380 };
381
382 if let Some(selected) = selected
383 && controller.with(|c| c.is_selected()) != selected
384 {
385 controller.with_mut(|c| c.set_selected(selected));
386 }
387
388 radio_button_inner()
389 .focus_requester_optional(focus_requester)
390 .modifier(modifier)
391 .on_select_shared(on_select)
392 .selected_optional(selected)
393 .group_optional(group)
394 .size(size)
395 .touch_target_size(touch_target_size)
396 .stroke_width(stroke_width)
397 .dot_size(dot_size)
398 .selected_color(selected_color)
399 .unselected_color(unselected_color)
400 .unselected_interaction_color(unselected_interaction_color)
401 .disabled_selected_color(disabled_selected_color)
402 .disabled_unselected_color(disabled_unselected_color)
403 .enabled(enabled)
404 .controller(controller)
405 .accessibility_label_optional(accessibility_label)
406 .accessibility_description_optional(accessibility_description);
407}
408
409#[tessera]
410fn radio_button_inner(
411 modifier: Option<Modifier>,
412 focus_requester: Option<FocusRequester>,
413 on_select: Option<CallbackWith<bool, ()>>,
414 selected: Option<bool>,
415 group: Option<RadioGroupSelection>,
416 size: Option<Dp>,
417 touch_target_size: Option<Dp>,
418 stroke_width: Option<Dp>,
419 dot_size: Option<Dp>,
420 selected_color: Option<Color>,
421 unselected_color: Option<Color>,
422 unselected_interaction_color: Option<Color>,
423 disabled_selected_color: Option<Color>,
424 disabled_unselected_color: Option<Color>,
425 enabled: Option<bool>,
426 accessibility_label: Option<String>,
427 accessibility_description: Option<String>,
428 controller: Option<State<RadioButtonController>>,
429) {
430 let modifier = modifier.unwrap_or_default();
431 let size = size.unwrap_or(Dp(20.0));
432 let touch_target_size = touch_target_size.unwrap_or(Dp(48.0));
433 let stroke_width = stroke_width.unwrap_or(Dp(2.0));
434 let dot_size = dot_size.unwrap_or(Dp(10.0));
435 let selected_color = selected_color.unwrap_or(Color::TRANSPARENT);
436 let unselected_color = unselected_color.unwrap_or(Color::TRANSPARENT);
437 let unselected_interaction_color = unselected_interaction_color.unwrap_or(unselected_color);
438 let disabled_selected_color = disabled_selected_color.unwrap_or(Color::TRANSPARENT);
439 let disabled_unselected_color = disabled_unselected_color.unwrap_or(Color::TRANSPARENT);
440 let enabled = enabled.unwrap_or(true);
441 let radio_group = group;
442 let controller = controller.expect("radio_button_inner requires controller to be set");
443 let group_selection = if selected.is_none() {
444 radio_group.map(|group| group.selected)
445 } else {
446 None
447 };
448 if let Some(group_selection) = group_selection
449 && controller.with(|controller| controller.is_selected())
450 {
451 claim_group_selection(group_selection, controller);
452 }
453 let interaction_state = if enabled {
454 Some(remember(InteractionState::new))
455 } else {
456 None
457 };
458 let ripple_state = if enabled {
459 Some(remember(RippleState::new))
460 } else {
461 None
462 };
463 let tap_recognizer = if enabled {
464 Some(remember(TapRecognizer::default))
465 } else {
466 None
467 };
468 if controller.with(|c| c.is_animating()) {
469 receive_frame_nanos(move |frame_nanos| {
470 let is_animating = controller.with_mut(|controller| {
471 controller.update_animation(frame_nanos);
472 controller.is_animating()
473 });
474 if is_animating {
475 tessera_ui::FrameNanosControl::Continue
476 } else {
477 tessera_ui::FrameNanosControl::Stop
478 }
479 });
480 }
481 let progress = controller.with(|c| c.animation_progress());
482 let eased_progress = animation::easing(progress);
483 let is_selected = controller.with(|c| c.is_selected());
484 let on_select = on_select.unwrap_or_else(CallbackWith::default_value);
485
486 let target_size = Dp(touch_target_size.0.max(size.0));
487
488 let unselected_color = if enabled
489 && interaction_state.is_some_and(|state| {
490 state.with(|state| state.is_hovered() || state.is_focused() || state.is_pressed())
491 }) {
492 unselected_interaction_color
493 } else {
494 unselected_color
495 };
496 let ring_color = if enabled {
497 interpolate_color(unselected_color, selected_color, progress)
498 } else if is_selected {
499 disabled_selected_color
500 } else {
501 disabled_unselected_color
502 };
503
504 let ripple_color = ring_color;
505
506 let target_dot_color = if enabled {
507 selected_color
508 } else {
509 disabled_selected_color
510 };
511 let active_dot_color = interpolate_color(Color::TRANSPARENT, target_dot_color, eased_progress);
512
513 let on_click = Callback::new(move || {
514 if let Some(group_selection) = group_selection {
515 claim_group_selection(group_selection, controller);
516 }
517 if controller.with_mut(|c| c.select()) {
518 on_select.call(true);
519 }
520 });
521
522 let state_layer_size = RadioButtonDefaults::STATE_LAYER_SIZE;
523 let state_layer_radius = Dp(state_layer_size.0 / 2.0);
524
525 let mut modifier = modifier.size(target_size, target_size);
526 if enabled {
527 let ripple_spec = RippleSpec {
528 bounded: false,
529 radius: Some(state_layer_radius),
530 };
531 let ripple_size = PxSize::new(state_layer_size.to_px(), state_layer_size.to_px());
532 let press_handler = ripple_state.map(|state| {
533 let spec = ripple_spec;
534 let size = ripple_size;
535 move |ctx: PointerEventContext| {
536 state.with_mut(|s| s.start_animation_with_spec(ctx.normalized_pos, size, spec));
537 }
538 });
539 let release_handler = ripple_state
540 .map(|state| move |_ctx: PointerEventContext| state.with_mut(|s| s.release()));
541 let selectable_args = SelectableArgs {
542 focus_requester,
543 selected: is_selected,
544 on_click,
545 enabled: true,
546 role: Some(Role::RadioButton),
547 label: accessibility_label.clone(),
548 description: accessibility_description.clone(),
549 interaction_state,
550 on_press: press_handler.map(Into::into),
551 on_release: release_handler.map(Into::into),
552 tap_recognizer,
553 ..Default::default()
554 };
555 modifier = modifier.selectable_with(selectable_args);
556 if radio_group.is_some() {
557 modifier = modifier.preferred_tab_stop(is_selected).on_focus_changed(
558 move |focus_state: FocusState| {
559 let pointer_pressed = interaction_state
560 .is_some_and(|state| state.with(|state| state.is_pressed()));
561 if let Some(interaction_state) = interaction_state {
562 interaction_state
563 .with_mut(|state| state.set_focused(focus_state.has_focus()));
564 }
565 let directional = matches!(
566 FocusManager::current().focus_change_direction(),
567 Some(
568 FocusDirection::Left
569 | FocusDirection::Right
570 | FocusDirection::Up
571 | FocusDirection::Down
572 )
573 );
574 if focus_state.is_focused() && directional && !pointer_pressed {
575 on_click.call();
576 }
577 },
578 );
579 }
580 } else {
581 modifier = modifier.push_semantics(RadioSemantics {
582 group: false,
583 selected: is_selected,
584 label: accessibility_label,
585 description: accessibility_description,
586 orientation: None,
587 });
588 }
589
590 boxed()
591 .modifier(modifier)
592 .alignment(Alignment::Center)
593 .children(move || {
594 surface()
595 .modifier(Modifier::new().size(state_layer_size, state_layer_size))
596 .shape(Shape::Ellipse)
597 .enabled(enabled)
598 .style(SurfaceStyle::Filled {
599 color: Color::TRANSPARENT,
600 })
601 .ripple_bounded(false)
602 .ripple_radius(state_layer_radius)
603 .ripple_color(ripple_color)
604 .interaction_state_optional(interaction_state)
605 .ripple_state_optional(ripple_state)
606 .child({
607 move || {
608 boxed()
609 .alignment(Alignment::Center)
610 .modifier(Modifier::new().fill_max_size())
611 .children(move || {
612 surface()
613 .modifier(Modifier::new().size(size, size))
614 .shape(Shape::Ellipse)
615 .style(SurfaceStyle::Outlined {
616 color: ring_color,
617 width: stroke_width,
618 })
619 .child({
620 move || {
621 let animated_size = (dot_size.to_px().0 as f32
622 * eased_progress)
623 .round()
624 as i32;
625 if animated_size > 0 {
626 boxed()
627 .alignment(Alignment::Center)
628 .modifier(Modifier::new().size(size, size))
629 .children(move || {
630 surface()
631 .modifier(Modifier::new().constrain(
632 Some(AxisConstraint::exact(Px(
633 animated_size,
634 ))),
635 Some(AxisConstraint::exact(Px(
636 animated_size,
637 ))),
638 ))
639 .shape(Shape::Ellipse)
640 .style(SurfaceStyle::Filled {
641 color: active_dot_color,
642 })
643 .child(|| {});
644 });
645 }
646 }
647 });
648 });
649 }
650 });
651 });
652}