tessera_ui_basic_components/
radio_button.rs

1//! Material Design 3 radio button with animated selection feedback.
2//! ## Usage Add single-choice selectors to forms, filters, and settings panes.
3
4use std::{
5    sync::Arc,
6    time::{Duration, Instant},
7};
8
9use closure::closure;
10use derive_builder::Builder;
11use parking_lot::RwLock;
12use tessera_ui::{
13    Color, DimensionValue, Dp, Px,
14    accesskit::{Action, Role, Toggled},
15    tessera,
16};
17
18use crate::{
19    RippleState,
20    alignment::Alignment,
21    animation,
22    boxed::{BoxedArgsBuilder, boxed},
23    material_color,
24    shape_def::Shape,
25    surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
26};
27
28const RADIO_ANIMATION_DURATION: Duration = Duration::from_millis(200);
29const HOVER_STATE_LAYER_OPACITY: f32 = 0.08;
30const RIPPLE_OPACITY: f32 = 0.1;
31
32/// Shared state for the `radio_button` component, including ripple feedback and selection animation.
33#[derive(Clone)]
34pub struct RadioButtonState {
35    ripple: RippleState,
36    selection: Arc<RwLock<RadioSelectionState>>,
37}
38
39impl Default for RadioButtonState {
40    fn default() -> Self {
41        Self::new(false)
42    }
43}
44
45impl RadioButtonState {
46    /// Creates a new radio button state with the given initial selection.
47    pub fn new(selected: bool) -> Self {
48        Self {
49            ripple: RippleState::new(),
50            selection: Arc::new(RwLock::new(RadioSelectionState::new(selected))),
51        }
52    }
53
54    /// Returns whether the radio button is currently selected.
55    pub fn is_selected(&self) -> bool {
56        self.selection.read().selected
57    }
58
59    /// Sets the selection state, starting an animation when the value changes.
60    pub fn set_selected(&self, selected: bool) {
61        let mut selection = self.selection.write();
62        if selection.selected != selected {
63            selection.selected = selected;
64            selection.start_progress = selection.progress;
65            selection.last_change_time = Some(Instant::now());
66        }
67    }
68
69    /// Marks the radio button as selected, returning `true` if this triggered a state change.
70    pub fn select(&self) -> bool {
71        let mut selection = self.selection.write();
72        if selection.selected {
73            return false;
74        }
75        selection.selected = true;
76        selection.start_progress = selection.progress;
77        selection.last_change_time = Some(Instant::now());
78        true
79    }
80
81    fn update_animation(&self) {
82        let mut selection = self.selection.write();
83        if let Some(start) = selection.last_change_time {
84            let elapsed = start.elapsed();
85            let fraction =
86                (elapsed.as_secs_f32() / RADIO_ANIMATION_DURATION.as_secs_f32()).min(1.0);
87            let target = if selection.selected { 1.0 } else { 0.0 };
88            selection.progress =
89                selection.start_progress + (target - selection.start_progress) * fraction;
90            if fraction >= 1.0 {
91                selection.last_change_time = None;
92                selection.progress = target;
93                selection.start_progress = target;
94            }
95        }
96    }
97
98    fn animation_progress(&self) -> f32 {
99        self.selection.read().progress
100    }
101
102    fn ripple_state(&self) -> RippleState {
103        self.ripple.clone()
104    }
105}
106
107struct RadioSelectionState {
108    selected: bool,
109    progress: f32,
110    start_progress: f32,
111    last_change_time: Option<Instant>,
112}
113
114impl RadioSelectionState {
115    fn new(selected: bool) -> Self {
116        let progress = if selected { 1.0 } else { 0.0 };
117        Self {
118            selected,
119            progress,
120            start_progress: progress,
121            last_change_time: None,
122        }
123    }
124}
125
126/// Arguments for configuring the `radio_button` component.
127#[derive(Builder, Clone)]
128#[builder(pattern = "owned")]
129pub struct RadioButtonArgs {
130    /// Callback invoked when the radio transitions to the selected state.
131    #[builder(default = "Arc::new(|_| {})")]
132    pub on_select: Arc<dyn Fn(bool) + Send + Sync>,
133    /// Visual diameter of the radio glyph (outer ring) in density-independent pixels.
134    #[builder(default = "Dp(20.0)")]
135    pub size: Dp,
136    /// Minimum interactive touch target for the control.
137    #[builder(default = "Dp(48.0)")]
138    pub touch_target_size: Dp,
139    /// Stroke width applied to the outer ring.
140    #[builder(default = "Dp(2.0)")]
141    pub stroke_width: Dp,
142    /// Diameter of the inner dot when fully selected.
143    #[builder(default = "Dp(10.0)")]
144    pub dot_size: Dp,
145    /// Ring and dot color when selected.
146    #[builder(default = "material_color::global_material_scheme().primary")]
147    pub selected_color: Color,
148    /// Ring color when not selected.
149    #[builder(default = "material_color::global_material_scheme().on_surface_variant")]
150    pub unselected_color: Color,
151    /// Ring and dot color when disabled but selected.
152    #[builder(default = "material_color::global_material_scheme().on_surface.with_alpha(0.38)")]
153    pub disabled_selected_color: Color,
154    /// Ring color when disabled and not selected.
155    #[builder(default = "material_color::global_material_scheme().on_surface.with_alpha(0.38)")]
156    pub disabled_unselected_color: Color,
157    /// Whether the control is interactive.
158    #[builder(default = "true")]
159    pub enabled: bool,
160    /// Optional accessibility label read by assistive technologies.
161    #[builder(default, setter(strip_option, into))]
162    pub accessibility_label: Option<String>,
163    /// Optional accessibility description.
164    #[builder(default, setter(strip_option, into))]
165    pub accessibility_description: Option<String>,
166}
167
168impl Default for RadioButtonArgs {
169    fn default() -> Self {
170        RadioButtonArgsBuilder::default()
171            .build()
172            .expect("RadioButtonArgsBuilder default build should succeed")
173    }
174}
175
176fn interpolate_color(a: Color, b: Color, t: f32) -> Color {
177    let factor = t.clamp(0.0, 1.0);
178    Color {
179        r: a.r + (b.r - a.r) * factor,
180        g: a.g + (b.g - a.g) * factor,
181        b: a.b + (b.b - a.b) * factor,
182        a: a.a + (b.a - a.a) * factor,
183    }
184}
185
186/// # radio_button
187///
188/// Render a Material Design 3 radio button with a smooth animated selection dot.
189///
190/// ## Usage
191///
192/// Use in single-choice groups where exactly one option should be active.
193///
194/// ## Parameters
195///
196/// - `args` — configures sizing, colors, and callbacks; see [`RadioButtonArgs`].
197/// - `state` — a clonable [`RadioButtonState`] that manages selection animation and ripple feedback.
198///
199/// ## Examples
200///
201/// ```
202/// use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
203/// use tessera_ui::tessera;
204/// use tessera_ui_basic_components::radio_button::{radio_button, RadioButtonArgsBuilder, RadioButtonState};
205///
206/// #[derive(Clone, Default)]
207/// struct DemoState {
208///     selected: Arc<AtomicBool>,
209///     radio: RadioButtonState,
210/// }
211///
212/// #[tessera]
213/// fn radio_demo(state: DemoState) {
214///     let on_select = Arc::new({
215///         let selected = state.selected.clone();
216///         move |is_selected| {
217///             selected.store(is_selected, Ordering::SeqCst);
218///         }
219///     });
220///
221///     radio_button(
222///         RadioButtonArgsBuilder::default()
223///             .on_select(on_select)
224///             .build()
225///             .unwrap(),
226///         state.radio.clone(),
227///     );
228///
229///     state.radio.set_selected(true);
230///     assert!(state.radio.is_selected());
231///     state.radio.set_selected(false);
232///     assert!(!state.radio.is_selected());
233/// }
234/// ```
235#[tessera]
236pub fn radio_button(args: impl Into<RadioButtonArgs>, state: RadioButtonState) {
237    let args: RadioButtonArgs = args.into();
238
239    let state_for_accessibility = state.clone();
240    let state_for_animation = state.clone();
241    let accessibility_label = args.accessibility_label.clone();
242    let accessibility_description = args.accessibility_description.clone();
243    let on_select_for_accessibility = args.on_select.clone();
244    let enabled_for_accessibility = args.enabled;
245    input_handler(Box::new(move |input| {
246        state_for_animation.update_animation();
247        let selected = state_for_animation.is_selected();
248
249        let mut builder = input.accessibility().role(Role::RadioButton);
250
251        if let Some(label) = accessibility_label.as_ref() {
252            builder = builder.label(label.clone());
253        }
254        if let Some(description) = accessibility_description.as_ref() {
255            builder = builder.description(description.clone());
256        }
257
258        builder = builder.toggled(if selected {
259            Toggled::True
260        } else {
261            Toggled::False
262        });
263
264        if enabled_for_accessibility {
265            builder = builder.focusable().action(Action::Click);
266        } else {
267            builder = builder.disabled();
268        }
269
270        builder.commit();
271
272        if enabled_for_accessibility {
273            let state = state_for_accessibility.clone();
274            let on_select = on_select_for_accessibility.clone();
275            input.set_accessibility_action_handler(move |action| {
276                if action == Action::Click && state.select() {
277                    on_select(true);
278                }
279            });
280        }
281    }));
282
283    state.update_animation();
284    let progress = state.animation_progress();
285    let eased_progress = animation::easing(progress);
286    let is_selected = state.is_selected();
287
288    let target_size = Dp(args.touch_target_size.0.max(args.size.0));
289    let padding_dp = Dp(((target_size.0 - args.size.0) / 2.0).max(0.0));
290
291    let ring_color = if args.enabled {
292        interpolate_color(args.unselected_color, args.selected_color, progress)
293    } else if is_selected {
294        args.disabled_selected_color
295    } else {
296        args.disabled_unselected_color
297    };
298
299    let base_state_layer_color = if args.enabled {
300        ring_color
301    } else if is_selected {
302        args.disabled_selected_color
303    } else {
304        args.disabled_unselected_color
305    };
306
307    let hover_style = args.enabled.then_some(SurfaceStyle::Filled {
308        color: base_state_layer_color.with_alpha(HOVER_STATE_LAYER_OPACITY),
309    });
310
311    let ripple_color = if args.enabled {
312        base_state_layer_color.with_alpha(RIPPLE_OPACITY)
313    } else {
314        Color::TRANSPARENT
315    };
316
317    let target_dot_color = if args.enabled {
318        args.selected_color
319    } else {
320        args.disabled_selected_color
321    };
322    let active_dot_color = interpolate_color(Color::TRANSPARENT, target_dot_color, eased_progress);
323
324    let ring_style = SurfaceStyle::Outlined {
325        color: ring_color,
326        width: args.stroke_width,
327    };
328
329    let on_click = if args.enabled {
330        Some(Arc::new(closure!(clone args.on_select, clone state, || {
331            if state.select() {
332                on_select(true);
333            }
334        })) as Arc<dyn Fn() + Send + Sync>)
335    } else {
336        None
337    };
338
339    let mut root_builder = SurfaceArgsBuilder::default()
340        .width(DimensionValue::Fixed(target_size.to_px()))
341        .height(DimensionValue::Fixed(target_size.to_px()))
342        .padding(padding_dp)
343        .shape(Shape::Ellipse)
344        .style(SurfaceStyle::Filled {
345            color: Color::TRANSPARENT,
346        })
347        .hover_style(hover_style)
348        .ripple_color(ripple_color)
349        .accessibility_role(Role::RadioButton);
350
351    if let Some(on_click) = on_click.clone() {
352        root_builder = root_builder.on_click(on_click);
353    }
354
355    surface(
356        root_builder.build().expect("builder construction failed"),
357        args.enabled.then(|| state.ripple_state()),
358        {
359            let args = args.clone();
360            move || {
361                surface(
362                    SurfaceArgsBuilder::default()
363                        .width(DimensionValue::Fixed(args.size.to_px()))
364                        .height(DimensionValue::Fixed(args.size.to_px()))
365                        .shape(Shape::Ellipse)
366                        .style(ring_style)
367                        .build()
368                        .expect("builder construction failed"),
369                    None,
370                    {
371                        let dot_size_px = args.dot_size.to_px();
372                        move || {
373                            let animated_size =
374                                (dot_size_px.0 as f32 * eased_progress).round() as i32;
375                            if animated_size > 0 {
376                                boxed(
377                                    BoxedArgsBuilder::default()
378                                        .alignment(Alignment::Center)
379                                        .width(DimensionValue::Fixed(args.size.to_px()))
380                                        .height(DimensionValue::Fixed(args.size.to_px()))
381                                        .build()
382                                        .expect("builder construction failed"),
383                                    |scope| {
384                                        scope.child({
385                                            let dot_color = active_dot_color;
386                                            move || {
387                                                surface(
388                                                    SurfaceArgsBuilder::default()
389                                                        .width(DimensionValue::Fixed(Px(
390                                                            animated_size,
391                                                        )))
392                                                        .height(DimensionValue::Fixed(Px(
393                                                            animated_size,
394                                                        )))
395                                                        .shape(Shape::Ellipse)
396                                                        .style(SurfaceStyle::Filled {
397                                                            color: dot_color,
398                                                        })
399                                                        .build()
400                                                        .expect("builder construction failed"),
401                                                    None,
402                                                    || {},
403                                                );
404                                            }
405                                        });
406                                    },
407                                );
408                            }
409                        }
410                    },
411                );
412            }
413        },
414    );
415}