Skip to main content

iced_cube/primitives/
radio.rs

1//! Radio buttons for picking one option from a small set.
2
3use iced::widget::{self, button::Status, column, container, mouse_area, row, space, text};
4use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, mouse};
5
6use crate::theme::{Tokens, fade, mix, radius, space as spacing, text_size};
7
8/// Diameter of the radio circle in logical pixels.
9pub const CIRCLE_SIZE: f32 = 16.0;
10
11const DOT_SIZE: f32 = 8.0;
12
13/// How a [`RadioGroup`] lays out its options.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum Direction {
16    #[default]
17    Vertical,
18    Horizontal,
19}
20
21impl Direction {
22    pub const ALL: [Direction; 2] = [Direction::Vertical, Direction::Horizontal];
23}
24
25/// A single radio button. Convert it into an [`Element`] to render.
26///
27/// A radio without a message is rendered disabled.
28#[derive(Debug)]
29pub struct Radio<'a, V, Message> {
30    label: text::Fragment<'a>,
31    value: V,
32    is_selected: bool,
33    on_select: Option<Message>,
34}
35
36/// Creates a radio for `value`, selected when `selected` holds the same value.
37pub fn radio<'a, V, Message>(
38    label: impl text::IntoFragment<'a>,
39    value: V,
40    selected: Option<V>,
41) -> Radio<'a, V, Message>
42where
43    V: Copy + Eq,
44{
45    Radio {
46        label: label.into_fragment(),
47        value,
48        is_selected: selected == Some(value),
49        on_select: None,
50    }
51}
52
53impl<'a, V: Copy, Message> Radio<'a, V, Message> {
54    /// Sets the message for a click, built from this radio's value.
55    pub fn on_select(mut self, on_select: impl FnOnce(V) -> Message) -> Self {
56        self.on_select = Some(on_select(self.value));
57        self
58    }
59
60    pub fn is_selected(&self) -> bool {
61        self.is_selected
62    }
63
64    pub fn is_enabled(&self) -> bool {
65        self.on_select.is_some()
66    }
67}
68
69impl<'a, V, Message: Clone + 'a> From<Radio<'a, V, Message>> for Element<'a, Message> {
70    fn from(radio: Radio<'a, V, Message>) -> Self {
71        let selected = radio.is_selected;
72        let resting = if radio.on_select.is_some() {
73            Status::Active
74        } else {
75            Status::Disabled
76        };
77
78        let dot: Element<'a, Message> = if selected {
79            container(space())
80                .width(DOT_SIZE)
81                .height(DOT_SIZE)
82                .style(move |theme: &Theme| container::Style {
83                    background: Some(Background::Color(
84                        colours(&Tokens::of(theme), selected, resting).dot,
85                    )),
86                    border: Border::default().rounded(radius::FULL),
87                    ..container::Style::default()
88                })
89                .into()
90        } else {
91            space().into()
92        };
93
94        let circle = widget::button(container(dot).center(Length::Fill))
95            .width(CIRCLE_SIZE)
96            .height(CIRCLE_SIZE)
97            .padding(0)
98            .on_press_maybe(radio.on_select.clone())
99            .style(move |theme, status| style(&Tokens::of(theme), selected, status));
100
101        let label = text(radio.label)
102            .size(text_size::SM)
103            .style(move |theme: &Theme| text::Style {
104                color: Some(colours(&Tokens::of(theme), selected, resting).label),
105            });
106        let label = match radio.on_select {
107            Some(message) => mouse_area(label)
108                .on_press(message)
109                .interaction(mouse::Interaction::Pointer),
110            None => mouse_area(label),
111        };
112
113        row![circle, label]
114            .spacing(spacing::SM)
115            .align_y(Alignment::Center)
116            .into()
117    }
118}
119
120/// A set of radios for the options of one value.
121///
122/// A group without a message renders every radio disabled.
123pub struct RadioGroup<'a, V, Message> {
124    options: Vec<V>,
125    selected: Option<V>,
126    direction: Direction,
127    on_select: Option<Box<dyn Fn(V) -> Message + 'a>>,
128}
129
130impl<V: std::fmt::Debug, Message> std::fmt::Debug for RadioGroup<'_, V, Message> {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("RadioGroup")
133            .field("options", &self.options)
134            .field("selected", &self.selected)
135            .field("direction", &self.direction)
136            .field("enabled", &self.on_select.is_some())
137            .finish()
138    }
139}
140
141/// Creates a group with one radio per option, labelled with its `Display` text.
142pub fn radio_group<'a, V, Message>(
143    options: impl IntoIterator<Item = V>,
144    selected: Option<V>,
145) -> RadioGroup<'a, V, Message>
146where
147    V: Copy + Eq + ToString,
148{
149    RadioGroup {
150        options: options.into_iter().collect(),
151        selected,
152        direction: Direction::default(),
153        on_select: None,
154    }
155}
156
157impl<'a, V, Message> RadioGroup<'a, V, Message> {
158    pub fn direction(mut self, direction: Direction) -> Self {
159        self.direction = direction;
160        self
161    }
162
163    /// Sets the message emitted with the option that was clicked.
164    pub fn on_select(mut self, on_select: impl Fn(V) -> Message + 'a) -> Self {
165        self.on_select = Some(Box::new(on_select));
166        self
167    }
168
169    pub fn is_enabled(&self) -> bool {
170        self.on_select.is_some()
171    }
172}
173
174impl<'a, V, Message> From<RadioGroup<'a, V, Message>> for Element<'a, Message>
175where
176    V: Copy + Eq + ToString + 'a,
177    Message: Clone + 'a,
178{
179    fn from(group: RadioGroup<'a, V, Message>) -> Self {
180        let on_select = group.on_select;
181        let radios = group.options.into_iter().map(|value| {
182            let mut radio = radio(value.to_string(), value, group.selected);
183            radio.on_select = on_select.as_ref().map(|f| f(value));
184            Element::from(radio)
185        });
186
187        match group.direction {
188            Direction::Vertical => column(radios).spacing(spacing::MD).into(),
189            Direction::Horizontal => row(radios)
190                .spacing(spacing::XL)
191                .align_y(Alignment::Center)
192                .wrap()
193                .into(),
194        }
195    }
196}
197
198/// Colours of a radio for its selection and interaction status.
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct Colours {
201    pub background: Color,
202    pub border: Color,
203    pub dot: Color,
204    pub label: Color,
205}
206
207/// Resolves the colours of a radio.
208pub fn colours(tokens: &Tokens, selected: bool, status: Status) -> Colours {
209    let hover = matches!(status, Status::Hovered | Status::Pressed);
210    let pressed = status == Status::Pressed;
211
212    let border = match (selected, hover) {
213        (true, _) => tokens.primary,
214        (false, true) => mix(tokens.border, tokens.foreground, 0.6),
215        (false, false) => mix(tokens.border, tokens.foreground, 0.3),
216    };
217    let colours = Colours {
218        background: if pressed {
219            tokens.accent
220        } else {
221            Color::TRANSPARENT
222        },
223        border,
224        dot: tokens.primary,
225        label: tokens.foreground,
226    };
227
228    if status != Status::Disabled {
229        return colours;
230    }
231    Colours {
232        background: fade(colours.background, 0.5),
233        border: fade(colours.border, 0.5),
234        dot: fade(colours.dot, 0.5),
235        label: fade(colours.label, 0.5),
236    }
237}
238
239/// The iced button style used for the radio circle.
240pub fn style(tokens: &Tokens, selected: bool, status: Status) -> widget::button::Style {
241    let colours = colours(tokens, selected, status);
242    widget::button::Style {
243        background: Some(Background::Color(colours.background)),
244        text_color: colours.label,
245        border: Border {
246            color: colours.border,
247            width: 1.0,
248            radius: radius::FULL.into(),
249        },
250        shadow: Shadow::default(),
251        snap: true,
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::theme::{dark, light};
259
260    const STATES: [Status; 4] = [
261        Status::Active,
262        Status::Hovered,
263        Status::Pressed,
264        Status::Disabled,
265    ];
266
267    #[test]
268    fn radio_is_selected_only_for_its_own_value() {
269        let r: Radio<'_, u8, ()> = radio("One", 1, Some(1));
270        assert!(r.is_selected());
271        let r: Radio<'_, u8, ()> = radio("One", 1, Some(2));
272        assert!(!r.is_selected());
273        let r: Radio<'_, u8, ()> = radio("One", 1, None);
274        assert!(!r.is_selected());
275    }
276
277    #[test]
278    fn radio_without_message_is_disabled() {
279        let r: Radio<'_, u8, u8> = radio("One", 1, None);
280        assert!(!r.is_enabled());
281        assert_eq!(r.on_select(|v| v * 10).on_select, Some(10));
282    }
283
284    #[test]
285    fn group_defaults_to_vertical_and_disabled() {
286        let g: RadioGroup<'_, u8, ()> = radio_group([1, 2, 3], None);
287        assert_eq!(g.direction, Direction::Vertical);
288        assert_eq!(g.options, vec![1, 2, 3]);
289        assert!(!g.is_enabled());
290        assert!(g.on_select(|_| ()).is_enabled());
291    }
292
293    #[test]
294    fn selected_border_uses_primary_in_every_enabled_status() {
295        for theme in [light(), dark()] {
296            let tokens = Tokens::of(&theme);
297            for status in [Status::Active, Status::Hovered, Status::Pressed] {
298                assert_eq!(colours(&tokens, true, status).border, tokens.primary);
299            }
300        }
301    }
302
303    #[test]
304    fn hover_strengthens_the_unselected_border() {
305        for theme in [light(), dark()] {
306            let tokens = Tokens::of(&theme);
307            let active = colours(&tokens, false, Status::Active);
308            let hovered = colours(&tokens, false, Status::Hovered);
309            assert_ne!(active.border, hovered.border);
310        }
311    }
312
313    #[test]
314    fn pressing_fills_the_background() {
315        let tokens = Tokens::of(&light());
316        for selected in [false, true] {
317            assert_eq!(
318                colours(&tokens, selected, Status::Pressed).background,
319                tokens.accent
320            );
321        }
322    }
323
324    #[test]
325    fn disabled_halves_alpha() {
326        for theme in [light(), dark()] {
327            let tokens = Tokens::of(&theme);
328            for selected in [false, true] {
329                let active = colours(&tokens, selected, Status::Active);
330                let disabled = colours(&tokens, selected, Status::Disabled);
331                assert!((disabled.dot.a - active.dot.a * 0.5).abs() < 1e-6);
332                assert!((disabled.label.a - active.label.a * 0.5).abs() < 1e-6);
333            }
334        }
335    }
336
337    #[test]
338    fn every_style_is_a_circle() {
339        for theme in [light(), dark()] {
340            let tokens = Tokens::of(&theme);
341            for selected in [false, true] {
342                for status in STATES {
343                    let style = style(&tokens, selected, status);
344                    assert_eq!(style.border.radius, radius::FULL.into());
345                    assert_eq!(style.border.width, 1.0);
346                }
347            }
348        }
349    }
350}