Skip to main content

material_ui_rs/widget/component/
radio.rs

1//! Material 3 radio constructors with token-backed size and motion defaults.
2
3use super::*;
4
5type StyleFn<'a> = Box<dyn Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a>;
6
7/// A Material 3 radio button with animated selected state transitions.
8pub struct Radio<'a, Message, Renderer = iced_widget::Renderer>
9where
10    Renderer: core_text::Renderer,
11{
12    is_selected: bool,
13    on_click: Option<Message>,
14    label: String,
15    width: Length,
16    size: f32,
17    spacing: f32,
18    text_size: Option<Pixels>,
19    text_line_height: LineHeight,
20    text_shaping: text::Shaping,
21    text_wrapping: text::Wrapping,
22    font: Option<Renderer::Font>,
23    style: StyleFn<'a>,
24}
25
26impl<Message, Renderer> std::fmt::Debug for Radio<'_, Message, Renderer>
27where
28    Message: std::fmt::Debug,
29    Renderer: core_text::Renderer,
30{
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("Radio")
33            .field("is_selected", &self.is_selected)
34            .field("on_click", &self.on_click)
35            .field("label", &self.label)
36            .field("width", &self.width)
37            .field("size", &self.size)
38            .field("spacing", &self.spacing)
39            .field("text_size", &self.text_size)
40            .field("text_line_height", &self.text_line_height)
41            .finish_non_exhaustive()
42    }
43}
44
45impl<'a, Message, Renderer> Radio<'a, Message, Renderer>
46where
47    Message: Clone,
48    Renderer: core_text::Renderer,
49{
50    pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, on_select: F) -> Self
51    where
52        V: Eq + Copy,
53        F: FnOnce(V) -> Message,
54    {
55        Self {
56            is_selected: Some(value) == selected,
57            on_click: Some(on_select(value)),
58            label: label.into(),
59            width: Length::Shrink,
60            size: tokens::component::radio::ICON_SIZE,
61            spacing: f32::from(tokens::component::divider::LIST_ITEM_LEADING_SPACE),
62            text_size: Some(Pixels(tokens::component::radio::LABEL_TEXT_SIZE)),
63            text_line_height: absolute_line_height(
64                tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
65            ),
66            text_shaping: text::Shaping::default(),
67            text_wrapping: text::Wrapping::default(),
68            font: None,
69            style: Box::new(crate::style::radio::default),
70        }
71    }
72
73    pub fn on_select_maybe(mut self, on_click: Option<Message>) -> Self {
74        self.on_click = on_click;
75        self
76    }
77
78    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
79        self.size = size.into().0;
80        self
81    }
82
83    pub fn width(mut self, width: impl Into<Length>) -> Self {
84        self.width = width.into();
85        self
86    }
87
88    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
89        self.spacing = spacing.into().0;
90        self
91    }
92
93    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
94        self.text_size = Some(text_size.into());
95        self
96    }
97
98    pub fn text_line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
99        self.text_line_height = line_height.into();
100        self
101    }
102
103    pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
104        self.text_shaping = shaping;
105        self
106    }
107
108    pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self {
109        self.text_wrapping = wrapping;
110        self
111    }
112
113    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
114        self.font = Some(font.into());
115        self
116    }
117
118    pub fn style(
119        mut self,
120        style: impl Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a,
121    ) -> Self {
122        self.style = Box::new(style);
123        self
124    }
125}
126
127impl<Message, Renderer> Radio<'_, Message, Renderer>
128where
129    Renderer: core_text::Renderer,
130{
131    fn current_status(&self, bounds: Rectangle, cursor: mouse::Cursor) -> iced_radio::Status {
132        if cursor.is_over(bounds) && self.on_click.is_some() {
133            iced_radio::Status::Hovered {
134                is_selected: self.is_selected,
135            }
136        } else {
137            iced_radio::Status::Active {
138                is_selected: self.is_selected,
139            }
140        }
141    }
142
143    fn state_layer_color(
144        &self,
145        theme: &Theme,
146        state: &SelectionState<Renderer::Paragraph, iced_radio::Status>,
147        status: iced_radio::Status,
148    ) -> Option<Color> {
149        let is_hovered = matches!(status, iced_radio::Status::Hovered { .. });
150
151        if !state.is_pressed && !is_hovered {
152            return None;
153        }
154
155        let colors = theme.colors();
156        let (color, opacity) = if self.is_selected {
157            if state.is_pressed {
158                (
159                    colors.surface.text,
160                    tokens::state::PRESSED_STATE_LAYER_OPACITY,
161                )
162            } else {
163                (
164                    colors.primary.color,
165                    tokens::state::HOVER_STATE_LAYER_OPACITY,
166                )
167            }
168        } else if state.is_pressed {
169            (
170                colors.primary.color,
171                tokens::state::PRESSED_STATE_LAYER_OPACITY,
172            )
173        } else {
174            (
175                colors.surface.text,
176                tokens::state::HOVER_STATE_LAYER_OPACITY,
177            )
178        };
179
180        Some(alpha_color(color, opacity))
181    }
182}
183
184impl<Message, Renderer> Widget<Message, Theme, Renderer> for Radio<'_, Message, Renderer>
185where
186    Message: Clone,
187    Renderer: core_text::Renderer,
188{
189    fn tag(&self) -> tree::Tag {
190        tree::Tag::of::<SelectionState<Renderer::Paragraph, iced_radio::Status>>()
191    }
192
193    fn state(&self) -> tree::State {
194        let mut state =
195            SelectionState::<Renderer::Paragraph, iced_radio::Status>::new(self.is_selected);
196
197        state.size = AnimatedScalar::new(bool_value(self.is_selected));
198
199        tree::State::new(state)
200    }
201
202    fn size(&self) -> Size<Length> {
203        Size {
204            width: self.width,
205            height: Length::Shrink,
206        }
207    }
208
209    fn layout(
210        &mut self,
211        tree: &mut Tree,
212        renderer: &Renderer,
213        limits: &layout::Limits,
214    ) -> layout::Node {
215        layout::next_to_each_other(
216            &limits.width(self.width),
217            self.spacing,
218            |_| layout::Node::new(Size::new(self.size, self.size)),
219            |limits| {
220                let state = tree
221                    .state
222                    .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
223
224                core_widget::text::layout(
225                    &mut state.text,
226                    renderer,
227                    limits,
228                    &self.label,
229                    core_widget::text::Format {
230                        width: self.width,
231                        height: Length::Shrink,
232                        line_height: self.text_line_height,
233                        size: self.text_size,
234                        font: self.font,
235                        align_x: text::Alignment::Default,
236                        align_y: alignment::Vertical::Top,
237                        shaping: self.text_shaping,
238                        wrapping: self.text_wrapping,
239                    },
240                )
241            },
242        )
243    }
244
245    fn update(
246        &mut self,
247        tree: &mut Tree,
248        event: &Event,
249        layout: Layout<'_>,
250        cursor: mouse::Cursor,
251        _renderer: &Renderer,
252        _clipboard: &mut dyn Clipboard,
253        shell: &mut Shell<'_, Message>,
254        _viewport: &Rectangle,
255    ) {
256        let state = tree
257            .state
258            .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
259        let hit_bounds =
260            selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
261
262        match event {
263            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
264            | Event::Touch(touch::Event::FingerPressed { .. }) => {
265                if self.on_click.is_some() && press_is_over(event, hit_bounds, cursor) {
266                    state.is_pressed = true;
267                    state.press_origin = None;
268                    shell.capture_event();
269                    shell.request_redraw();
270                }
271            }
272            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
273            | Event::Touch(touch::Event::FingerLifted { .. }) => {
274                if state.is_pressed {
275                    let is_released_over = release_is_over(event, hit_bounds, cursor);
276
277                    state.is_pressed = false;
278                    state.press_origin = None;
279
280                    if is_released_over && let Some(on_click) = &self.on_click {
281                        shell.publish(on_click.clone());
282                    }
283
284                    shell.capture_event();
285                    shell.request_redraw();
286                }
287            }
288            Event::Touch(touch::Event::FingerLost { .. }) => {
289                if state.is_pressed {
290                    state.is_pressed = false;
291                    state.press_origin = None;
292                    shell.request_redraw();
293                }
294            }
295            _ => {}
296        }
297
298        let now = match event {
299            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
300            _ => None,
301        };
302
303        if state.target != self.is_selected {
304            let now = now.unwrap_or_else(Instant::now);
305
306            state.target = self.is_selected;
307            state.color.set_target(
308                bool_value(self.is_selected),
309                now,
310                duration_ms(tokens::component::radio::ICON_COLOR_TRANSITION_DURATION_MS),
311                tokens::motion::EASING_LINEAR,
312            );
313
314            if self.is_selected {
315                state.size = AnimatedScalar::new(0.0);
316                state.size.set_target(
317                    1.0,
318                    now,
319                    duration_ms(tokens::component::radio::SELECT_TRANSITION_DURATION_MS),
320                    tokens::component::radio::SELECT_TRANSITION_EASING,
321                );
322            } else {
323                state.size = AnimatedScalar::new(1.0);
324            }
325
326            shell.request_redraw();
327        }
328
329        let current_status = self.current_status(hit_bounds, cursor);
330
331        if let Some(now) = now {
332            if state.advance(now) {
333                shell.request_redraw();
334            }
335
336            state.last_status = Some(current_status);
337        } else if state
338            .last_status
339            .is_some_and(|status| status != current_status)
340            || state.is_animating()
341        {
342            shell.request_redraw();
343        }
344    }
345
346    fn mouse_interaction(
347        &self,
348        _tree: &Tree,
349        layout: Layout<'_>,
350        cursor: mouse::Cursor,
351        _viewport: &Rectangle,
352        _renderer: &Renderer,
353    ) -> mouse::Interaction {
354        let hit_bounds =
355            selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
356
357        if cursor.is_over(hit_bounds) {
358            if self.on_click.is_some() {
359                mouse::Interaction::Pointer
360            } else {
361                mouse::Interaction::NotAllowed
362            }
363        } else {
364            mouse::Interaction::default()
365        }
366    }
367
368    fn draw(
369        &self,
370        tree: &Tree,
371        renderer: &mut Renderer,
372        theme: &Theme,
373        defaults: &renderer::Style,
374        layout: Layout<'_>,
375        _cursor: mouse::Cursor,
376        viewport: &Rectangle,
377    ) {
378        let state = tree
379            .state
380            .downcast_ref::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
381
382        let mut children = layout.children();
383        let control_layout = children.next().unwrap();
384        let bounds = control_layout.bounds();
385        let status = state.last_status.unwrap_or(iced_radio::Status::Active {
386            is_selected: self.is_selected,
387        });
388        let unchecked_status = match status {
389            iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: false },
390            iced_radio::Status::Hovered { .. } => {
391                iced_radio::Status::Hovered { is_selected: false }
392            }
393        };
394        let checked_status = match status {
395            iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: true },
396            iced_radio::Status::Hovered { .. } => iced_radio::Status::Hovered { is_selected: true },
397        };
398        let current_style = (self.style)(theme, status);
399        let unchecked_style = (self.style)(theme, unchecked_status);
400        let checked_style = (self.style)(theme, checked_status);
401
402        if let Some(layer_color) = self.state_layer_color(theme, state, status) {
403            renderer.fill_quad(
404                renderer::Quad {
405                    bounds: scaled_rect(
406                        bounds,
407                        tokens::component::radio::STATE_LAYER_SIZE,
408                        tokens::component::radio::STATE_LAYER_SIZE,
409                    ),
410                    border: border::rounded(tokens::component::radio::STATE_LAYER_SIZE / 2.0),
411                    ..renderer::Quad::default()
412                },
413                Background::Color(layer_color),
414            );
415        }
416
417        let color_progress = state.color.value.clamp(0.0, 1.0);
418        let icon_color = mix(
419            unchecked_style.border_color,
420            checked_style.border_color,
421            color_progress,
422        );
423        let radius = bounds.width.min(bounds.height) / 2.0;
424
425        renderer.fill_quad(
426            renderer::Quad {
427                bounds,
428                border: Border {
429                    radius: radius.into(),
430                    width: tokens::component::radio::OUTER_RING_WIDTH,
431                    color: icon_color,
432                },
433                ..renderer::Quad::default()
434            },
435            Color::TRANSPARENT,
436        );
437
438        if color_progress > 0.0 {
439            let dot_size =
440                tokens::component::radio::INNER_DOT_SIZE * state.size.value.clamp(0.0, 1.0);
441
442            if dot_size > 0.0 {
443                renderer.fill_quad(
444                    renderer::Quad {
445                        bounds: scaled_rect(bounds, dot_size, dot_size),
446                        border: border::rounded(dot_size / 2.0),
447                        ..renderer::Quad::default()
448                    },
449                    Background::Color(alpha_color(checked_style.dot_color, color_progress)),
450                );
451            }
452        }
453
454        let label_layout = children.next().unwrap();
455
456        core_widget::text::draw(
457            renderer,
458            defaults,
459            label_layout.bounds(),
460            state.text.raw(),
461            core_widget::text::Style {
462                color: current_style.text_color,
463            },
464            viewport,
465        );
466    }
467
468    fn operate(
469        &mut self,
470        _tree: &mut Tree,
471        layout: Layout<'_>,
472        _renderer: &Renderer,
473        operation: &mut dyn core_widget::Operation,
474    ) {
475        operation.text(None, layout.bounds(), &self.label);
476    }
477}
478
479impl<'a, Message, Renderer> From<Radio<'a, Message, Renderer>>
480    for Element<'a, Message, Theme, Renderer>
481where
482    Message: Clone + 'a,
483    Renderer: core_text::Renderer + 'a,
484{
485    fn from(radio: Radio<'a, Message, Renderer>) -> Self {
486        Element::new(radio)
487    }
488}
489
490pub fn control<'a, Message, Renderer, V>(
491    label: impl Into<String>,
492    value: V,
493    selected: Option<V>,
494    on_select: impl FnOnce(V) -> Message,
495) -> Radio<'a, Message, Renderer>
496where
497    Message: Clone + 'a,
498    Renderer: core_text::Renderer + 'a,
499    V: Eq + Copy,
500{
501    Radio::new(label, value, selected, on_select)
502        .size(tokens::component::radio::ICON_SIZE)
503        .spacing(f32::from(
504            tokens::component::divider::LIST_ITEM_LEADING_SPACE,
505        ))
506        .text_size(tokens::component::radio::LABEL_TEXT_SIZE)
507        .text_line_height(absolute_line_height(
508            tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
509        ))
510        .style(crate::style::radio::default)
511}
512
513pub fn standard<'a, Message, Renderer, V>(
514    label: impl Into<String>,
515    value: V,
516    selected: Option<V>,
517    on_select: impl FnOnce(V) -> Message,
518) -> Element<'a, Message, Theme, Renderer>
519where
520    Message: Clone + 'a,
521    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
522    V: Eq + Copy,
523{
524    Container::new(control(label, value, selected, on_select))
525        .center_y(Length::Fixed(tokens::component::radio::TARGET_SIZE))
526        .into()
527}