Skip to main content

iced_widget/
radio.rs

1//! Radio buttons let users choose a single option from a bunch of options.
2//!
3//! # Example
4//! ```no_run
5//! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
6//! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
7//! #
8//! use iced::widget::{column, radio};
9//!
10//! struct State {
11//!    selection: Option<Choice>,
12//! }
13//!
14//! #[derive(Debug, Clone, Copy)]
15//! enum Message {
16//!     RadioSelected(Choice),
17//! }
18//!
19//! #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20//! enum Choice {
21//!     A,
22//!     B,
23//!     C,
24//!     All,
25//! }
26//!
27//! fn view(state: &State) -> Element<'_, Message> {
28//!     let a = radio(
29//!         "A",
30//!         Choice::A,
31//!         state.selection,
32//!         Message::RadioSelected,
33//!     );
34//!
35//!     let b = radio(
36//!         "B",
37//!         Choice::B,
38//!         state.selection,
39//!         Message::RadioSelected,
40//!     );
41//!
42//!     let c = radio(
43//!         "C",
44//!         Choice::C,
45//!         state.selection,
46//!         Message::RadioSelected,
47//!     );
48//!
49//!     let all = radio(
50//!         "All of the above",
51//!         Choice::All,
52//!         state.selection,
53//!         Message::RadioSelected
54//!     );
55//!
56//!     column![a, b, c, all].into()
57//! }
58//! ```
59use crate::core::alignment;
60use crate::core::border::{self, Border};
61use crate::core::keyboard;
62use crate::core::keyboard::key;
63use crate::core::layout;
64use crate::core::mouse;
65use crate::core::renderer;
66use crate::core::text;
67use crate::core::theme::palette;
68use crate::core::touch;
69use crate::core::widget;
70use crate::core::widget::operation::accessible::{Accessible, Role};
71use crate::core::widget::operation::focusable::Focusable;
72use crate::core::widget::tree::{self, Tree};
73use crate::core::window;
74use crate::core::{
75    Background, Color, Element, Event, Layout, Length, Pixels, Rectangle, Shadow, Shell, Size,
76    Theme, Widget,
77};
78
79/// A circular button representing a choice.
80///
81/// # Example
82/// ```no_run
83/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
84/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>;
85/// #
86/// use iced::widget::{column, radio};
87///
88/// struct State {
89///    selection: Option<Choice>,
90/// }
91///
92/// #[derive(Debug, Clone, Copy)]
93/// enum Message {
94///     RadioSelected(Choice),
95/// }
96///
97/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
98/// enum Choice {
99///     A,
100///     B,
101///     C,
102///     All,
103/// }
104///
105/// fn view(state: &State) -> Element<'_, Message> {
106///     let a = radio(
107///         "A",
108///         Choice::A,
109///         state.selection,
110///         Message::RadioSelected,
111///     );
112///
113///     let b = radio(
114///         "B",
115///         Choice::B,
116///         state.selection,
117///         Message::RadioSelected,
118///     );
119///
120///     let c = radio(
121///         "C",
122///         Choice::C,
123///         state.selection,
124///         Message::RadioSelected,
125///     );
126///
127///     let all = radio(
128///         "All of the above",
129///         Choice::All,
130///         state.selection,
131///         Message::RadioSelected
132///     );
133///
134///     column![a, b, c, all].into()
135/// }
136/// ```
137pub struct Radio<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer>
138where
139    Theme: Catalog,
140    Renderer: text::Renderer,
141{
142    is_selected: bool,
143    on_click: Message,
144    label: String,
145    width: Length,
146    size: f32,
147    spacing: f32,
148    text_size: Option<Pixels>,
149    line_height: text::LineHeight,
150    shaping: text::Shaping,
151    wrapping: text::Wrapping,
152    font: Option<Renderer::Font>,
153    class: Theme::Class<'a>,
154    last_status: Option<Status>,
155}
156
157impl<'a, Message, Theme, Renderer> Radio<'a, Message, Theme, Renderer>
158where
159    Message: Clone,
160    Theme: Catalog,
161    Renderer: text::Renderer,
162{
163    /// The default size of a [`Radio`] button.
164    pub const DEFAULT_SIZE: f32 = 16.0;
165
166    /// The default spacing of a [`Radio`] button.
167    pub const DEFAULT_SPACING: f32 = 8.0;
168
169    /// Creates a new [`Radio`] button.
170    ///
171    /// It expects:
172    ///   * the value related to the [`Radio`] button
173    ///   * the label of the [`Radio`] button
174    ///   * the current selected value
175    ///   * a function that will be called when the [`Radio`] is selected. It
176    ///     receives the value of the radio and must produce a `Message`.
177    pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, f: F) -> Self
178    where
179        V: Eq + Copy,
180        F: FnOnce(V) -> Message,
181    {
182        Radio {
183            is_selected: Some(value) == selected,
184            on_click: f(value),
185            label: label.into(),
186            width: Length::Shrink,
187            size: Self::DEFAULT_SIZE,
188            spacing: Self::DEFAULT_SPACING,
189            text_size: None,
190            line_height: text::LineHeight::default(),
191            shaping: text::Shaping::default(),
192            wrapping: text::Wrapping::default(),
193            font: None,
194            class: Theme::default(),
195            last_status: None,
196        }
197    }
198
199    /// Sets the size of the [`Radio`] button.
200    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
201        self.size = size.into().0;
202        self
203    }
204
205    /// Sets the width of the [`Radio`] button.
206    pub fn width(mut self, width: impl Into<Length>) -> Self {
207        self.width = width.into();
208        self
209    }
210
211    /// Sets the spacing between the [`Radio`] button and the text.
212    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
213        self.spacing = spacing.into().0;
214        self
215    }
216
217    /// Sets the text size of the [`Radio`] button.
218    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
219        self.text_size = Some(text_size.into());
220        self
221    }
222
223    /// Sets the text [`text::LineHeight`] of the [`Radio`] button.
224    pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
225        self.line_height = line_height.into();
226        self
227    }
228
229    /// Sets the [`text::Shaping`] strategy of the [`Radio`] button.
230    pub fn shaping(mut self, shaping: text::Shaping) -> Self {
231        self.shaping = shaping;
232        self
233    }
234
235    /// Sets the [`text::Wrapping`] strategy of the [`Radio`] button.
236    pub fn wrapping(mut self, wrapping: text::Wrapping) -> Self {
237        self.wrapping = wrapping;
238        self
239    }
240
241    /// Sets the text font of the [`Radio`] button.
242    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
243        self.font = Some(font.into());
244        self
245    }
246
247    /// Sets the style of the [`Radio`] button.
248    #[must_use]
249    pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
250    where
251        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
252    {
253        self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
254        self
255    }
256
257    /// Sets the style class of the [`Radio`] button.
258    #[cfg(feature = "advanced")]
259    #[must_use]
260    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
261        self.class = class.into();
262        self
263    }
264}
265
266#[derive(Debug, Clone, Default)]
267struct State<P: text::Paragraph> {
268    is_focused: bool,
269    focus_visible: bool,
270    label: widget::text::State<P>,
271}
272
273impl<P: text::Paragraph> Focusable for State<P> {
274    fn is_focused(&self) -> bool {
275        self.is_focused
276    }
277
278    fn focus(&mut self) {
279        self.is_focused = true;
280        self.focus_visible = true;
281    }
282
283    fn unfocus(&mut self) {
284        self.is_focused = false;
285        self.focus_visible = false;
286    }
287}
288
289impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
290    for Radio<'_, Message, Theme, Renderer>
291where
292    Message: Clone,
293    Theme: Catalog,
294    Renderer: text::Renderer,
295{
296    fn tag(&self) -> tree::Tag {
297        tree::Tag::of::<State<Renderer::Paragraph>>()
298    }
299
300    fn state(&self) -> tree::State {
301        tree::State::new(State::<Renderer::Paragraph>::default())
302    }
303
304    fn size(&self) -> Size<Length> {
305        Size {
306            width: self.width,
307            height: Length::Shrink,
308        }
309    }
310
311    fn layout(
312        &mut self,
313        tree: &mut Tree,
314        renderer: &Renderer,
315        limits: &layout::Limits,
316    ) -> layout::Node {
317        layout::next_to_each_other(
318            &limits.width(self.width),
319            self.spacing,
320            |_| layout::Node::new(Size::new(self.size, self.size)),
321            |limits| {
322                let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
323
324                widget::text::layout(
325                    &mut state.label,
326                    renderer,
327                    limits,
328                    &self.label,
329                    widget::text::Format {
330                        width: self.width,
331                        height: Length::Shrink,
332                        line_height: self.line_height,
333                        size: self.text_size,
334                        font: self.font,
335                        align_x: text::Alignment::Default,
336                        align_y: alignment::Vertical::Top,
337                        shaping: self.shaping,
338                        wrapping: self.wrapping,
339                        ellipsis: text::Ellipsis::default(),
340                    },
341                )
342            },
343        )
344    }
345
346    fn operate(
347        &mut self,
348        tree: &mut Tree,
349        layout: Layout<'_>,
350        _renderer: &Renderer,
351        operation: &mut dyn widget::Operation,
352    ) {
353        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
354
355        operation.accessible(
356            None,
357            layout.bounds(),
358            &Accessible {
359                role: Role::RadioButton,
360                label: Some(&self.label),
361                selected: Some(self.is_selected),
362                ..Accessible::default()
363            },
364        );
365
366        operation.focusable(None, layout.bounds(), state);
367    }
368
369    fn update(
370        &mut self,
371        tree: &mut Tree,
372        event: &Event,
373        layout: Layout<'_>,
374        cursor: mouse::Cursor,
375        _renderer: &Renderer,
376        shell: &mut Shell<'_, Message>,
377        _viewport: &Rectangle,
378    ) {
379        match event {
380            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
381            | Event::Touch(touch::Event::FingerPressed { .. }) => {
382                let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
383
384                if cursor.is_over(layout.bounds()) {
385                    state.is_focused = true;
386                    state.focus_visible = false;
387
388                    shell.publish(self.on_click.clone());
389                    shell.capture_event();
390                } else {
391                    state.is_focused = false;
392                    state.focus_visible = false;
393                }
394            }
395            Event::Keyboard(keyboard::Event::KeyPressed {
396                key: keyboard::Key::Named(key::Named::Space),
397                ..
398            }) => {
399                let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
400
401                if state.is_focused {
402                    shell.publish(self.on_click.clone());
403                    shell.capture_event();
404                }
405            }
406            Event::Keyboard(keyboard::Event::KeyPressed {
407                key: keyboard::Key::Named(key::Named::Escape),
408                ..
409            }) => {
410                let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
411                if state.is_focused {
412                    state.is_focused = false;
413                    state.focus_visible = false;
414                    shell.capture_event();
415                }
416            }
417            _ => {}
418        }
419
420        let current_status = {
421            let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
422            let is_mouse_over = cursor.is_over(layout.bounds());
423            let is_selected = self.is_selected;
424
425            if state.focus_visible {
426                Status::Focused { is_selected }
427            } else if is_mouse_over {
428                Status::Hovered { is_selected }
429            } else {
430                Status::Active { is_selected }
431            }
432        };
433
434        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
435            self.last_status = Some(current_status);
436        } else if self
437            .last_status
438            .is_some_and(|last_status| last_status != current_status)
439        {
440            shell.request_redraw();
441        }
442    }
443
444    fn mouse_interaction(
445        &self,
446        _tree: &Tree,
447        layout: Layout<'_>,
448        cursor: mouse::Cursor,
449        _viewport: &Rectangle,
450        _renderer: &Renderer,
451    ) -> mouse::Interaction {
452        if cursor.is_over(layout.bounds()) {
453            mouse::Interaction::Pointer
454        } else {
455            mouse::Interaction::default()
456        }
457    }
458
459    fn draw(
460        &self,
461        tree: &Tree,
462        renderer: &mut Renderer,
463        theme: &Theme,
464        defaults: &renderer::Style,
465        layout: Layout<'_>,
466        _cursor: mouse::Cursor,
467        viewport: &Rectangle,
468    ) {
469        let mut children = layout.children();
470
471        let style = theme.style(
472            &self.class,
473            self.last_status.unwrap_or(Status::Active {
474                is_selected: self.is_selected,
475            }),
476        );
477
478        {
479            let layout = children.next().unwrap();
480            let bounds = layout.bounds();
481
482            let size = bounds.width;
483            let dot_size = size / 2.0;
484
485            renderer.fill_quad(
486                renderer::Quad {
487                    bounds,
488                    border: Border {
489                        radius: (size / 2.0).into(),
490                        width: style.border_width,
491                        color: style.border_color,
492                    },
493                    shadow: style.shadow,
494                    ..renderer::Quad::default()
495                },
496                style.background,
497            );
498
499            if self.is_selected {
500                renderer.fill_quad(
501                    renderer::Quad {
502                        bounds: Rectangle {
503                            x: bounds.x + dot_size / 2.0,
504                            y: bounds.y + dot_size / 2.0,
505                            width: bounds.width - dot_size,
506                            height: bounds.height - dot_size,
507                        },
508                        border: border::rounded(dot_size / 2.0),
509                        ..renderer::Quad::default()
510                    },
511                    style.dot_color,
512                );
513            }
514        }
515
516        {
517            let label_layout = children.next().unwrap();
518            let state: &State<Renderer::Paragraph> = tree.state.downcast_ref();
519
520            crate::text::draw(
521                renderer,
522                defaults,
523                label_layout.bounds(),
524                state.label.raw(),
525                crate::text::Style {
526                    color: style.text_color,
527                },
528                viewport,
529            );
530        }
531    }
532}
533
534impl<'a, Message, Theme, Renderer> From<Radio<'a, Message, Theme, Renderer>>
535    for Element<'a, Message, Theme, Renderer>
536where
537    Message: 'a + Clone,
538    Theme: 'a + Catalog,
539    Renderer: 'a + text::Renderer,
540{
541    fn from(radio: Radio<'a, Message, Theme, Renderer>) -> Element<'a, Message, Theme, Renderer> {
542        Element::new(radio)
543    }
544}
545
546/// The possible status of a [`Radio`] button.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum Status {
549    /// The [`Radio`] button can be interacted with.
550    Active {
551        /// Indicates whether the [`Radio`] button is currently selected.
552        is_selected: bool,
553    },
554    /// The [`Radio`] button is being hovered.
555    Hovered {
556        /// Indicates whether the [`Radio`] button is currently selected.
557        is_selected: bool,
558    },
559    /// The [`Radio`] button has keyboard focus.
560    Focused {
561        /// Indicates whether the [`Radio`] button is currently selected.
562        is_selected: bool,
563    },
564}
565
566/// The appearance of a radio button.
567#[derive(Debug, Clone, Copy, PartialEq)]
568pub struct Style {
569    /// The [`Background`] of the radio button.
570    pub background: Background,
571    /// The [`Color`] of the dot of the radio button.
572    pub dot_color: Color,
573    /// The border width of the radio button.
574    pub border_width: f32,
575    /// The border [`Color`] of the radio button.
576    pub border_color: Color,
577    /// The text [`Color`] of the radio button.
578    pub text_color: Option<Color>,
579    /// The [`Shadow`] of the radio button.
580    pub shadow: Shadow,
581}
582
583/// The theme catalog of a [`Radio`].
584pub trait Catalog {
585    /// The item class of the [`Catalog`].
586    type Class<'a>;
587
588    /// The default class produced by the [`Catalog`].
589    fn default<'a>() -> Self::Class<'a>;
590
591    /// The [`Style`] of a class with the given status.
592    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
593}
594
595/// A styling function for a [`Radio`].
596pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
597
598impl Catalog for Theme {
599    type Class<'a> = StyleFn<'a, Self>;
600
601    fn default<'a>() -> Self::Class<'a> {
602        Box::new(default)
603    }
604
605    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
606        class(self, status)
607    }
608}
609
610/// The default style of a [`Radio`] button.
611pub fn default(theme: &Theme, status: Status) -> Style {
612    let palette = theme.palette();
613
614    let active = Style {
615        background: Color::TRANSPARENT.into(),
616        dot_color: palette.primary.strong.color,
617        border_width: 1.0,
618        border_color: palette.primary.strong.color,
619        text_color: None,
620        shadow: Shadow::default(),
621    };
622
623    match status {
624        Status::Active { .. } => active,
625        Status::Hovered { .. } => Style {
626            dot_color: palette.primary.strong.color,
627            background: palette.primary.weak.color.into(),
628            ..active
629        },
630        Status::Focused { .. } => {
631            let page_bg = palette.background.base.color;
632            Style {
633                border_color: palette::focus_border_color(
634                    Color::TRANSPARENT,
635                    palette.primary.strong.color,
636                    page_bg,
637                ),
638                border_width: 2.0,
639                shadow: palette::focus_shadow(palette.primary.strong.color, page_bg),
640                ..active
641            }
642        }
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use crate::core::widget::operation::focusable::Focusable;
650
651    type TestState = State<()>;
652
653    #[test]
654    fn focusable_trait() {
655        let mut state = TestState::default();
656        assert!(!state.is_focused());
657        assert!(!state.focus_visible);
658        state.focus();
659        assert!(state.is_focused());
660        assert!(state.focus_visible);
661        state.unfocus();
662        assert!(!state.is_focused());
663        assert!(!state.focus_visible);
664    }
665}