Skip to main content

iced_widget/
radio_group.rs

1//! A group of radio buttons managed as a single tab stop.
2//!
3//! A [`RadioGroup`] renders N radio options vertically, manages focus
4//! as one unit, and lets the user move between options with arrow keys
5//! following the [WAI-ARIA radio group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
6//!
7//! Each option is drawn using the same circle-and-dot visual as
8//! [`Radio`](crate::Radio), reusing [`radio::Catalog`] for styling.
9use crate::core::alignment;
10use crate::core::border::{self, Border};
11use crate::core::keyboard;
12use crate::core::keyboard::key;
13use crate::core::layout;
14use crate::core::mouse;
15use crate::core::renderer;
16use crate::core::text;
17use crate::core::touch;
18use crate::core::widget;
19use crate::core::widget::operation::accessible::{Accessible, Role};
20use crate::core::widget::operation::focusable::Focusable;
21use crate::core::widget::tree::{self, Tree};
22use crate::core::window;
23use crate::core::{Element, Event, Layout, Length, Pixels, Rectangle, Shell, Size, Widget};
24use crate::radio;
25
26/// A group of radio buttons that acts as a single tab stop with
27/// arrow-key navigation.
28///
29/// Reuses [`radio::Catalog`] for visual styling so every option looks
30/// identical to an individual [`Radio`](crate::Radio) button.
31#[allow(missing_debug_implementations)]
32pub struct RadioGroup<'a, V, Message, Theme = crate::Theme, Renderer = crate::Renderer>
33where
34    V: Copy + Eq,
35    Theme: radio::Catalog,
36    Renderer: text::Renderer,
37{
38    options: Vec<(String, V)>,
39    selected: Option<V>,
40    on_select: Box<dyn Fn(V) -> Message + 'a>,
41    size: f32,
42    spacing: f32,
43    option_spacing: f32,
44    text_size: Option<Pixels>,
45    line_height: text::LineHeight,
46    shaping: text::Shaping,
47    wrapping: text::Wrapping,
48    font: Option<Renderer::Font>,
49    class: Theme::Class<'a>,
50    width: Length,
51}
52
53impl<'a, V, Message, Theme, Renderer> RadioGroup<'a, V, Message, Theme, Renderer>
54where
55    V: Copy + Eq,
56    Theme: radio::Catalog,
57    Renderer: text::Renderer,
58{
59    /// The default vertical spacing between options.
60    pub const DEFAULT_OPTION_SPACING: f32 = 6.0;
61
62    /// Creates a new [`RadioGroup`].
63    ///
64    /// It expects:
65    ///   * an iterator of `(label, value)` pairs
66    ///   * the currently selected value, if any
67    ///   * a function that produces a `Message` when a value is selected
68    pub fn new<F>(
69        options: impl IntoIterator<Item = (impl Into<String>, V)>,
70        selected: Option<V>,
71        on_select: F,
72    ) -> Self
73    where
74        F: Fn(V) -> Message + 'a,
75    {
76        RadioGroup {
77            options: options
78                .into_iter()
79                .map(|(label, value)| (label.into(), value))
80                .collect(),
81            selected,
82            on_select: Box::new(on_select),
83            size: 16.0,
84            spacing: 8.0,
85            option_spacing: Self::DEFAULT_OPTION_SPACING,
86            text_size: None,
87            line_height: text::LineHeight::default(),
88            shaping: text::Shaping::default(),
89            wrapping: text::Wrapping::default(),
90            font: None,
91            class: Theme::default(),
92            width: Length::Shrink,
93        }
94    }
95
96    /// Sets the size of each radio circle.
97    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
98        self.size = size.into().0;
99        self
100    }
101
102    /// Sets the spacing between each radio circle and its label text.
103    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
104        self.spacing = spacing.into().0;
105        self
106    }
107
108    /// Sets the vertical spacing between options.
109    pub fn option_spacing(mut self, spacing: impl Into<Pixels>) -> Self {
110        self.option_spacing = spacing.into().0;
111        self
112    }
113
114    /// Sets the text size of the option labels.
115    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
116        self.text_size = Some(text_size.into());
117        self
118    }
119
120    /// Sets the text font of the option labels.
121    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
122        self.font = Some(font.into());
123        self
124    }
125
126    /// Sets the width of the [`RadioGroup`].
127    pub fn width(mut self, width: impl Into<Length>) -> Self {
128        self.width = width.into();
129        self
130    }
131
132    /// Sets the style of the [`RadioGroup`].
133    #[must_use]
134    pub fn style(mut self, style: impl Fn(&Theme, radio::Status) -> radio::Style + 'a) -> Self
135    where
136        Theme::Class<'a>: From<radio::StyleFn<'a, Theme>>,
137    {
138        self.class = (Box::new(style) as radio::StyleFn<'a, Theme>).into();
139        self
140    }
141
142    /// Sets the style class of the [`RadioGroup`].
143    #[cfg(feature = "advanced")]
144    #[must_use]
145    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
146        self.class = class.into();
147        self
148    }
149}
150
151#[derive(Debug, Clone, Default)]
152struct State<P: text::Paragraph> {
153    active_index: usize,
154    is_focused: bool,
155    focus_visible: bool,
156    labels: Vec<widget::text::State<P>>,
157}
158
159impl<P: text::Paragraph> Focusable for State<P> {
160    fn is_focused(&self) -> bool {
161        self.is_focused
162    }
163
164    fn focus(&mut self) {
165        self.is_focused = true;
166        self.focus_visible = true;
167    }
168
169    fn unfocus(&mut self) {
170        self.is_focused = false;
171        self.focus_visible = false;
172    }
173}
174
175impl<V, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
176    for RadioGroup<'_, V, Message, Theme, Renderer>
177where
178    V: Copy + Eq,
179    Theme: radio::Catalog,
180    Renderer: text::Renderer,
181{
182    fn tag(&self) -> tree::Tag {
183        tree::Tag::of::<State<Renderer::Paragraph>>()
184    }
185
186    fn state(&self) -> tree::State {
187        tree::State::new(State::<Renderer::Paragraph>::default())
188    }
189
190    fn size(&self) -> Size<Length> {
191        Size {
192            width: self.width,
193            height: Length::Shrink,
194        }
195    }
196
197    fn layout(
198        &mut self,
199        tree: &mut Tree,
200        renderer: &Renderer,
201        limits: &layout::Limits,
202    ) -> layout::Node {
203        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
204
205        // Ensure we have the right number of label states.
206        state
207            .labels
208            .resize_with(self.options.len(), Default::default);
209
210        let limits = limits.width(self.width);
211        let mut children = Vec::with_capacity(self.options.len());
212        let mut total_height: f32 = 0.0;
213        let mut max_width: f32 = 0.0;
214
215        for (i, (label, _)) in self.options.iter().enumerate() {
216            let node = layout::next_to_each_other(
217                &limits,
218                self.spacing,
219                |_| layout::Node::new(Size::new(self.size, self.size)),
220                |limits| {
221                    widget::text::layout(
222                        &mut state.labels[i],
223                        renderer,
224                        limits,
225                        label,
226                        widget::text::Format {
227                            width: self.width,
228                            height: Length::Shrink,
229                            line_height: self.line_height,
230                            size: self.text_size,
231                            font: self.font,
232                            align_x: text::Alignment::Default,
233                            align_y: alignment::Vertical::Top,
234                            shaping: self.shaping,
235                            wrapping: self.wrapping,
236                            ellipsis: text::Ellipsis::default(),
237                        },
238                    )
239                },
240            );
241
242            let node_size = node.size();
243
244            if i > 0 {
245                total_height += self.option_spacing;
246            }
247
248            let node = node.move_to((0.0, total_height));
249            total_height += node_size.height;
250            max_width = max_width.max(node_size.width);
251
252            children.push(node);
253        }
254
255        let size = limits.resolve(
256            self.width,
257            Length::Shrink,
258            Size::new(max_width, total_height),
259        );
260
261        layout::Node::with_children(size, children)
262    }
263
264    fn operate(
265        &mut self,
266        tree: &mut Tree,
267        layout: Layout<'_>,
268        _renderer: &Renderer,
269        operation: &mut dyn widget::Operation,
270    ) {
271        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
272        let total = self.options.len();
273
274        // Group container
275        operation.accessible(
276            None,
277            layout.bounds(),
278            &Accessible {
279                role: Role::Group,
280                ..Accessible::default()
281            },
282        );
283
284        operation.container(None, layout.bounds());
285        operation.traverse(&mut |operation| {
286            for (i, ((label, _), child_layout)) in
287                self.options.iter().zip(layout.children()).enumerate()
288            {
289                operation.accessible(
290                    None,
291                    child_layout.bounds(),
292                    &Accessible {
293                        role: Role::RadioButton,
294                        label: Some(label),
295                        selected: Some(self.selected.is_some_and(|s| s == self.options[i].1)),
296                        position_in_set: Some(i + 1),
297                        size_of_set: Some(total),
298                        ..Accessible::default()
299                    },
300                );
301
302                // Text content for the label
303                let mut label_children = child_layout.children();
304                let _circle = label_children.next();
305                if let Some(text_layout) = label_children.next() {
306                    operation.text(None, text_layout.bounds(), label);
307                }
308            }
309        });
310
311        if total > 0 {
312            operation.focusable(None, layout.bounds(), state);
313        } else {
314            state.unfocus();
315        }
316    }
317
318    fn update(
319        &mut self,
320        tree: &mut Tree,
321        event: &Event,
322        layout: Layout<'_>,
323        cursor: mouse::Cursor,
324        _renderer: &Renderer,
325        shell: &mut Shell<'_, Message>,
326        _viewport: &Rectangle,
327    ) {
328        let total = self.options.len();
329
330        if total == 0 {
331            return;
332        }
333
334        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
335
336        // Sync active_index with externally-changed selection.
337        if let Some(selected) = self.selected
338            && let Some(idx) = self.options.iter().position(|(_, v)| *v == selected)
339        {
340            state.active_index = idx;
341        }
342
343        match event {
344            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
345            | Event::Touch(touch::Event::FingerPressed { .. }) => {
346                for (i, child_layout) in layout.children().enumerate() {
347                    if cursor.is_over(child_layout.bounds()) {
348                        state.active_index = i;
349                        state.is_focused = true;
350                        state.focus_visible = false;
351
352                        shell.publish((self.on_select)(self.options[i].1));
353                        shell.capture_event();
354                        return;
355                    }
356                }
357
358                // Click outside all options: clear focus.
359                if cursor.is_over(layout.bounds()) {
360                    // Inside group bounds but not on an option -- do nothing.
361                } else {
362                    state.is_focused = false;
363                    state.focus_visible = false;
364                }
365            }
366            Event::Keyboard(keyboard::Event::KeyPressed {
367                key: keyboard::Key::Named(key::Named::ArrowDown | key::Named::ArrowRight),
368                ..
369            }) => {
370                if state.is_focused {
371                    state.active_index = (state.active_index + 1) % total;
372                    shell.publish((self.on_select)(self.options[state.active_index].1));
373                    shell.capture_event();
374                }
375            }
376            Event::Keyboard(keyboard::Event::KeyPressed {
377                key: keyboard::Key::Named(key::Named::ArrowUp | key::Named::ArrowLeft),
378                ..
379            }) => {
380                if state.is_focused {
381                    state.active_index = (state.active_index + total - 1) % total;
382                    shell.publish((self.on_select)(self.options[state.active_index].1));
383                    shell.capture_event();
384                }
385            }
386            Event::Keyboard(keyboard::Event::KeyPressed {
387                key: keyboard::Key::Named(key::Named::Space),
388                ..
389            }) => {
390                if state.is_focused {
391                    shell.publish((self.on_select)(self.options[state.active_index].1));
392                    shell.capture_event();
393                }
394            }
395            Event::Keyboard(keyboard::Event::KeyPressed {
396                key: keyboard::Key::Named(key::Named::Escape),
397                ..
398            }) => {
399                if state.is_focused {
400                    state.is_focused = false;
401                    state.focus_visible = false;
402                    shell.capture_event();
403                }
404            }
405            _ => {}
406        }
407
408        // Redraw tracking: request redraw when status changes.
409        if let Event::Window(window::Event::RedrawRequested(_)) = event {
410            // Nothing to store; status is derived on the fly.
411        } else {
412            // A status change (focus, hover) may need a redraw.
413            shell.request_redraw();
414        }
415    }
416
417    fn mouse_interaction(
418        &self,
419        _tree: &Tree,
420        layout: Layout<'_>,
421        cursor: mouse::Cursor,
422        _viewport: &Rectangle,
423        _renderer: &Renderer,
424    ) -> mouse::Interaction {
425        for child_layout in layout.children() {
426            if cursor.is_over(child_layout.bounds()) {
427                return mouse::Interaction::Pointer;
428            }
429        }
430
431        mouse::Interaction::default()
432    }
433
434    fn draw(
435        &self,
436        tree: &Tree,
437        renderer: &mut Renderer,
438        theme: &Theme,
439        defaults: &renderer::Style,
440        layout: Layout<'_>,
441        cursor: mouse::Cursor,
442        viewport: &Rectangle,
443    ) {
444        let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
445
446        for (i, ((_label, value), option_layout)) in
447            self.options.iter().zip(layout.children()).enumerate()
448        {
449            let is_selected = self.selected.is_some_and(|s| s == *value);
450            let is_active = i == state.active_index;
451            let is_mouse_over = cursor.is_over(option_layout.bounds());
452
453            let status = if is_active && state.focus_visible {
454                radio::Status::Focused { is_selected }
455            } else if is_mouse_over {
456                radio::Status::Hovered { is_selected }
457            } else {
458                radio::Status::Active { is_selected }
459            };
460
461            let style = theme.style(&self.class, status);
462
463            let mut children = option_layout.children();
464
465            // Draw the radio circle.
466            {
467                let circle_layout = children.next().unwrap();
468                let bounds = circle_layout.bounds();
469                let size = bounds.width;
470                let dot_size = size / 2.0;
471
472                renderer.fill_quad(
473                    renderer::Quad {
474                        bounds,
475                        border: Border {
476                            radius: (size / 2.0).into(),
477                            width: style.border_width,
478                            color: style.border_color,
479                        },
480                        ..renderer::Quad::default()
481                    },
482                    style.background,
483                );
484
485                if is_selected {
486                    renderer.fill_quad(
487                        renderer::Quad {
488                            bounds: Rectangle {
489                                x: bounds.x + dot_size / 2.0,
490                                y: bounds.y + dot_size / 2.0,
491                                width: bounds.width - dot_size,
492                                height: bounds.height - dot_size,
493                            },
494                            border: border::rounded(dot_size / 2.0),
495                            ..renderer::Quad::default()
496                        },
497                        style.dot_color,
498                    );
499                }
500            }
501
502            // Draw the label text.
503            {
504                let label_layout = children.next().unwrap();
505
506                crate::text::draw(
507                    renderer,
508                    defaults,
509                    label_layout.bounds(),
510                    state.labels[i].raw(),
511                    crate::text::Style {
512                        color: style.text_color,
513                    },
514                    viewport,
515                );
516            }
517        }
518    }
519}
520
521impl<'a, V, Message, Theme, Renderer> From<RadioGroup<'a, V, Message, Theme, Renderer>>
522    for Element<'a, Message, Theme, Renderer>
523where
524    V: 'a + Copy + Eq,
525    Message: 'a,
526    Theme: 'a + radio::Catalog,
527    Renderer: 'a + text::Renderer,
528{
529    fn from(
530        radio_group: RadioGroup<'a, V, Message, Theme, Renderer>,
531    ) -> Element<'a, Message, Theme, Renderer> {
532        Element::new(radio_group)
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::core::widget::operation::focusable::Focusable;
540
541    type TestState = State<()>;
542
543    #[test]
544    fn focusable_trait() {
545        let mut state = TestState::default();
546        assert!(!state.is_focused());
547        assert!(!state.focus_visible);
548        state.focus();
549        assert!(state.is_focused());
550        assert!(state.focus_visible);
551        state.unfocus();
552        assert!(!state.is_focused());
553        assert!(!state.focus_visible);
554    }
555
556    #[test]
557    fn default_state_starts_at_zero() {
558        let state = TestState::default();
559        assert_eq!(state.active_index, 0);
560        assert!(!state.is_focused());
561    }
562}