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//! # use iced::widget::Widget;
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) -> impl Widget<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]
57//! }
58//! ```
59use crate::core::alignment;
60use crate::core::border::{self, Border};
61use crate::core::layout;
62use crate::core::mouse;
63use crate::core::renderer;
64use crate::core::text;
65use crate::core::touch;
66use crate::core::widget;
67use crate::core::widget::tree::{self, Tree};
68use crate::core::window;
69use crate::core::{
70    Background, Color, Event, Font, Layout, Length, Pixels, Rectangle, Shell, Size, Theme, Widget,
71};
72
73/// A circular button representing a choice.
74///
75/// # Example
76/// ```no_run
77/// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; }
78/// # use iced::widget::Widget;
79/// #
80/// use iced::widget::{column, radio};
81///
82/// struct State {
83///    selection: Option<Choice>,
84/// }
85///
86/// #[derive(Debug, Clone, Copy)]
87/// enum Message {
88///     RadioSelected(Choice),
89/// }
90///
91/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
92/// enum Choice {
93///     A,
94///     B,
95///     C,
96///     All,
97/// }
98///
99/// fn view(state: &State) -> impl Widget<Message> {
100///     let a = radio(
101///         "A",
102///         Choice::A,
103///         state.selection,
104///         Message::RadioSelected,
105///     );
106///
107///     let b = radio(
108///         "B",
109///         Choice::B,
110///         state.selection,
111///         Message::RadioSelected,
112///     );
113///
114///     let c = radio(
115///         "C",
116///         Choice::C,
117///         state.selection,
118///         Message::RadioSelected,
119///     );
120///
121///     let all = radio(
122///         "All of the above",
123///         Choice::All,
124///         state.selection,
125///         Message::RadioSelected
126///     );
127///
128///     column![a, b, c, all]
129/// }
130/// ```
131pub struct Radio<'a, Message, Theme = crate::Theme>
132where
133    Theme: Catalog,
134{
135    is_selected: bool,
136    on_click: Message,
137    label: String,
138    width: Length,
139    size: f32,
140    spacing: f32,
141    text_size: Option<Pixels>,
142    line_height: Option<text::LineHeight>,
143    shaping: text::Shaping,
144    wrapping: text::Wrapping,
145    font: Option<Font>,
146    class: Theme::Class<'a>,
147    last_status: Option<Status>,
148}
149
150impl<'a, Message, Theme> Radio<'a, Message, Theme>
151where
152    Message: Clone,
153    Theme: Catalog,
154{
155    /// The default size of a [`Radio`] button.
156    pub const DEFAULT_SIZE: f32 = 16.0;
157
158    /// The default spacing of a [`Radio`] button.
159    pub const DEFAULT_SPACING: f32 = 8.0;
160
161    /// Creates a new [`Radio`] button.
162    ///
163    /// It expects:
164    ///   * the value related to the [`Radio`] button
165    ///   * the label of the [`Radio`] button
166    ///   * the current selected value
167    ///   * a function that will be called when the [`Radio`] is selected. It
168    ///     receives the value of the radio and must produce a `Message`.
169    pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, f: F) -> Self
170    where
171        V: Eq + Copy,
172        F: FnOnce(V) -> Message,
173    {
174        Radio {
175            is_selected: Some(value) == selected,
176            on_click: f(value),
177            label: label.into(),
178            width: Length::Shrink,
179            size: Self::DEFAULT_SIZE,
180            spacing: Self::DEFAULT_SPACING,
181            text_size: None,
182            line_height: None,
183            shaping: text::Shaping::default(),
184            wrapping: text::Wrapping::default(),
185            font: None,
186            class: Theme::default(),
187            last_status: None,
188        }
189    }
190
191    /// Sets the size of the [`Radio`] button.
192    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
193        self.size = size.into().0;
194        self
195    }
196
197    /// Sets the width of the [`Radio`] button.
198    pub fn width(mut self, width: impl Into<Length>) -> Self {
199        self.width = width.into();
200        self
201    }
202
203    /// Sets the spacing between the [`Radio`] button and the text.
204    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
205        self.spacing = spacing.into().0;
206        self
207    }
208
209    /// Sets the text size of the [`Radio`] button.
210    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
211        self.text_size = Some(text_size.into());
212        self
213    }
214
215    /// Sets the text [`text::LineHeight`] of the [`Radio`] button.
216    pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
217        self.line_height = Some(line_height.into());
218        self
219    }
220
221    /// Sets the [`text::Shaping`] strategy of the [`Radio`] button.
222    pub fn shaping(mut self, shaping: text::Shaping) -> Self {
223        self.shaping = shaping;
224        self
225    }
226
227    /// Sets the [`text::Wrapping`] strategy of the [`Radio`] button.
228    pub fn wrapping(mut self, wrapping: text::Wrapping) -> Self {
229        self.wrapping = wrapping;
230        self
231    }
232
233    /// Sets the text font of the [`Radio`] button.
234    pub fn font(mut self, font: impl Into<Font>) -> Self {
235        self.font = Some(font.into());
236        self
237    }
238
239    /// Sets the style of the [`Radio`] button.
240    #[must_use]
241    pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self
242    where
243        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
244    {
245        self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
246        self
247    }
248
249    /// Sets the style class of the [`Radio`] button.
250    #[cfg(feature = "advanced")]
251    #[must_use]
252    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
253        self.class = class.into();
254        self
255    }
256}
257
258impl<Message, Theme> widget::Meta for Radio<'_, Message, Theme> where Theme: Catalog {}
259
260impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for Radio<'_, Message, Theme>
261where
262    Message: Clone,
263    Theme: Catalog,
264    Renderer: text::Renderer,
265{
266    fn tag(&self) -> tree::Tag {
267        tree::Tag::of::<text::paragraph::Plain<Renderer::Paragraph>>()
268    }
269
270    fn state(&self) -> tree::State {
271        tree::State::new(text::paragraph::Plain::<Renderer::Paragraph>::default())
272    }
273
274    fn size(&self) -> Size<Length> {
275        Size {
276            width: self.width,
277            height: Length::Shrink,
278        }
279    }
280
281    fn diff(&mut self, tree: &mut Tree) {
282        // The children of the tree are the radio and the label; they only
283        // carry their geometry, so no state is needed.
284        tree.children.resize_with(2, Tree::empty);
285    }
286
287    fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) {
288        let limits = limits.width(self.width);
289
290        let radio = Size::new(self.size, self.size);
291
292        let state = tree
293            .state
294            .downcast_mut::<text::paragraph::Plain<Renderer::Paragraph>>();
295
296        let label = widget::text::layout(
297            state,
298            renderer,
299            &limits.shrink(Size::new(radio.width + self.spacing, 0.0)),
300            &self.label,
301            widget::text::Format {
302                width: self.width,
303                height: Length::Shrink,
304                line_height: self.line_height,
305                size: self.text_size,
306                font: self.font,
307                align_x: text::Alignment::Default,
308                align_y: alignment::Vertical::Top,
309                shaping: self.shaping,
310                wrapping: self.wrapping,
311                ellipsis: text::Ellipsis::default(),
312            },
313        );
314
315        layout::next_to_each_other(tree, radio, label, self.spacing);
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        match event {
329            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
330            | Event::Touch(touch::Event::FingerPressed { .. })
331                if cursor.is_over(layout.bounds()) =>
332            {
333                shell.publish(self.on_click.clone());
334                shell.capture_event();
335            }
336            _ => {}
337        }
338
339        let current_status = {
340            let is_mouse_over = cursor.is_over(layout.bounds());
341            let is_selected = self.is_selected;
342
343            if is_mouse_over {
344                Status::Hovered { is_selected }
345            } else {
346                Status::Active { is_selected }
347            }
348        };
349
350        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
351            self.last_status = Some(current_status);
352        } else if self
353            .last_status
354            .is_some_and(|last_status| last_status != current_status)
355        {
356            shell.request_redraw();
357        }
358    }
359
360    fn mouse_interaction(
361        &self,
362        _tree: &Tree,
363        layout: Layout,
364        cursor: mouse::Cursor,
365        _viewport: &Rectangle,
366        _renderer: &Renderer,
367    ) -> mouse::Interaction {
368        if cursor.is_over(layout.bounds()) {
369            mouse::Interaction::Pointer
370        } else {
371            mouse::Interaction::default()
372        }
373    }
374
375    fn draw(
376        &self,
377        tree: &Tree,
378        renderer: &mut Renderer,
379        theme: &Theme,
380        defaults: &renderer::Style,
381        layout: Layout,
382        _cursor: mouse::Cursor,
383        viewport: &Rectangle,
384    ) {
385        let mut children = layout.iter(&tree.children);
386
387        let style = theme.style(
388            &self.class,
389            self.last_status.unwrap_or(Status::Active {
390                is_selected: self.is_selected,
391            }),
392        );
393
394        let state = tree
395            .state
396            .downcast_ref::<text::paragraph::Plain<Renderer::Paragraph>>();
397
398        let (radio_layout, _) = children.next().unwrap();
399        let bounds = radio_layout.bounds();
400
401        let size = bounds.width;
402        let dot_size = size / 2.0;
403
404        renderer.fill_quad(
405            renderer::Quad {
406                bounds,
407                border: Border {
408                    radius: (size / 2.0).into(),
409                    width: style.border_width,
410                    color: style.border_color,
411                },
412                ..renderer::Quad::default()
413            },
414            style.background,
415        );
416
417        if self.is_selected {
418            renderer.fill_quad(
419                renderer::Quad {
420                    bounds: Rectangle {
421                        x: bounds.x + dot_size / 2.0,
422                        y: bounds.y + dot_size / 2.0,
423                        width: bounds.width - dot_size,
424                        height: bounds.height - dot_size,
425                    },
426                    border: border::rounded(dot_size / 2.0),
427                    ..renderer::Quad::default()
428                },
429                style.dot_color,
430            );
431        }
432
433        let (label_layout, _) = children.next().unwrap();
434
435        crate::text::draw(
436            renderer,
437            defaults,
438            label_layout.bounds(),
439            state.raw(),
440            crate::text::Style {
441                color: style.text_color,
442                selection: None,
443            },
444            theme.selection(),
445            viewport,
446        );
447    }
448}
449
450/// The possible status of a [`Radio`] button.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum Status {
453    /// The [`Radio`] button can be interacted with.
454    Active {
455        /// Indicates whether the [`Radio`] button is currently selected.
456        is_selected: bool,
457    },
458    /// The [`Radio`] button is being hovered.
459    Hovered {
460        /// Indicates whether the [`Radio`] button is currently selected.
461        is_selected: bool,
462    },
463}
464
465/// The appearance of a radio button.
466#[derive(Debug, Clone, Copy, PartialEq)]
467pub struct Style {
468    /// The [`Background`] of the radio button.
469    pub background: Background,
470    /// The [`Color`] of the dot of the radio button.
471    pub dot_color: Color,
472    /// The border width of the radio button.
473    pub border_width: f32,
474    /// The border [`Color`] of the radio button.
475    pub border_color: Color,
476    /// The text [`Color`] of the radio button.
477    pub text_color: Option<Color>,
478}
479
480/// The theme catalog of a [`Radio`].
481pub trait Catalog {
482    /// The item class of the [`Catalog`].
483    type Class<'a>;
484
485    /// The default class produced by the [`Catalog`].
486    fn default<'a>() -> Self::Class<'a>;
487
488    /// The [`Style`] of a class with the given status.
489    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style;
490
491    /// The global selection [`Color`].
492    fn selection(&self) -> Color;
493}
494
495/// A styling function for a [`Radio`].
496pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme, Status) -> Style + 'a>;
497
498impl Catalog for Theme {
499    type Class<'a> = StyleFn<'a, Self>;
500
501    fn default<'a>() -> Self::Class<'a> {
502        Box::new(default)
503    }
504
505    fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
506        class(self, status)
507    }
508
509    fn selection(&self) -> Color {
510        self.palette().background.strongest.color
511    }
512}
513
514/// The default style of a [`Radio`] button.
515pub fn default(theme: &Theme, status: Status) -> Style {
516    let palette = theme.palette();
517
518    let active = Style {
519        background: Color::TRANSPARENT.into(),
520        dot_color: palette.primary.strong.color,
521        border_width: 1.0,
522        border_color: palette.primary.strong.color,
523        text_color: None,
524    };
525
526    match status {
527        Status::Active { .. } => active,
528        Status::Hovered { .. } => Style {
529            dot_color: palette.primary.strong.color,
530            background: palette.primary.weak.color.into(),
531            ..active
532        },
533    }
534}