Skip to main content

iced_cube/primitives/
checkbox.rs

1//! Checkboxes with checked, unchecked and indeterminate states.
2
3use iced::keyboard::key::Named;
4use iced::widget::{self, button::Status, container, mouse_area, row, space, text};
5use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, mouse};
6
7use crate::icon::{Glyph, opacity, themed};
8use crate::keys::{self, Chord, Keymap};
9use crate::theme::{Tokens, fade, mix, radius, space as spacing, text_size};
10
11/// Side length of the check box in logical pixels.
12pub const BOX_SIZE: f32 = 16.0;
13
14const ICON_SIZE: f32 = 14.0;
15
16/// The value a checkbox shows.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
18pub enum CheckState {
19    #[default]
20    Unchecked,
21    Checked,
22    /// Partly checked, such as a "select all" box over a mixed selection.
23    Indeterminate,
24}
25
26impl CheckState {
27    pub const ALL: [CheckState; 3] = [
28        CheckState::Unchecked,
29        CheckState::Checked,
30        CheckState::Indeterminate,
31    ];
32
33    /// The state after a click. Indeterminate boxes become checked.
34    pub const fn toggled(self) -> Self {
35        match self {
36            CheckState::Checked => CheckState::Unchecked,
37            CheckState::Unchecked | CheckState::Indeterminate => CheckState::Checked,
38        }
39    }
40
41    pub const fn is_checked(self) -> bool {
42        matches!(self, CheckState::Checked)
43    }
44
45    /// Whether the box is drawn filled, which is true when checked or indeterminate.
46    pub const fn is_filled(self) -> bool {
47        !matches!(self, CheckState::Unchecked)
48    }
49
50    fn glyph(self) -> Option<Glyph> {
51        match self {
52            CheckState::Unchecked => None,
53            CheckState::Checked => Some(crate::lucide!(Check)),
54            CheckState::Indeterminate => Some(crate::lucide!(Minus)),
55        }
56    }
57}
58
59impl From<bool> for CheckState {
60    fn from(checked: bool) -> Self {
61        if checked {
62            CheckState::Checked
63        } else {
64            CheckState::Unchecked
65        }
66    }
67}
68
69/// A checkbox builder. Convert it into an [`Element`] to render.
70///
71/// A checkbox without a message is rendered disabled.
72#[derive(Debug)]
73pub struct Checkbox<'a, Message> {
74    state: CheckState,
75    label: Option<text::Fragment<'a>>,
76    on_toggle: Option<Message>,
77}
78
79/// Creates a checkbox showing `state`. Accepts a `bool` or a [`CheckState`].
80pub fn checkbox<'a, Message>(state: impl Into<CheckState>) -> Checkbox<'a, Message> {
81    Checkbox {
82        state: state.into(),
83        label: None,
84        on_toggle: None,
85    }
86}
87
88impl<'a, Message> Checkbox<'a, Message> {
89    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
90        self.label = Some(label.into_fragment());
91        self
92    }
93
94    /// Sets the message for a click. It receives the new checked value, so an
95    /// indeterminate box reports `true`.
96    pub fn on_toggle(mut self, on_toggle: impl FnOnce(bool) -> Message) -> Self {
97        self.on_toggle = Some(on_toggle(self.state.toggled().is_checked()));
98        self
99    }
100
101    /// Like [`Checkbox::on_toggle`], but `None` disables the checkbox.
102    pub fn on_toggle_maybe(mut self, on_toggle: Option<impl FnOnce(bool) -> Message>) -> Self {
103        let next = self.state.toggled().is_checked();
104        self.on_toggle = on_toggle.map(|f| f(next));
105        self
106    }
107
108    pub fn state(&self) -> CheckState {
109        self.state
110    }
111
112    pub fn is_enabled(&self) -> bool {
113        self.on_toggle.is_some()
114    }
115}
116
117impl<'a, Message: Clone + 'a> From<Checkbox<'a, Message>> for Element<'a, Message> {
118    fn from(checkbox: Checkbox<'a, Message>) -> Self {
119        let state = checkbox.state;
120        let enabled = checkbox.on_toggle.is_some();
121        let resting = if enabled {
122            Status::Active
123        } else {
124            Status::Disabled
125        };
126
127        let mark: Element<'a, Message> = match state.glyph() {
128            Some(glyph) => themed(glyph, ICON_SIZE, opacity(enabled), move |theme| {
129                colours(&Tokens::of(theme), state, resting).icon
130            })
131            .into(),
132            None => space().into(),
133        };
134
135        let indicator = widget::button(container(mark).center(Length::Fill))
136            .width(BOX_SIZE)
137            .height(BOX_SIZE)
138            .padding(0)
139            .on_press_maybe(checkbox.on_toggle.clone())
140            .style(move |theme, status| style(&Tokens::of(theme), state, status));
141
142        let Some(label) = checkbox.label else {
143            return indicator.into();
144        };
145
146        let label = text(label)
147            .size(text_size::SM)
148            .style(move |theme: &Theme| text::Style {
149                color: Some(colours(&Tokens::of(theme), state, resting).label),
150            });
151        let label = match checkbox.on_toggle {
152            Some(message) => mouse_area(label)
153                .on_press(message)
154                .interaction(mouse::Interaction::Pointer),
155            None => mouse_area(label),
156        };
157
158        row![indicator, label]
159            .spacing(spacing::SM)
160            .align_y(Alignment::Center)
161            .into()
162    }
163}
164
165/// Colours of a checkbox in a given state and interaction status.
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct Colours {
168    pub background: Color,
169    pub border: Color,
170    pub icon: Color,
171    pub label: Color,
172}
173
174/// Resolves the colours of a checkbox for a value and interaction status.
175pub fn colours(tokens: &Tokens, state: CheckState, status: Status) -> Colours {
176    let hover = matches!(status, Status::Hovered | Status::Pressed);
177    let pressed = status == Status::Pressed;
178
179    let colours = if state.is_filled() {
180        let target = if tokens.is_dark {
181            Color::BLACK
182        } else {
183            Color::WHITE
184        };
185        let amount = match (hover, pressed) {
186            (_, true) => 0.2,
187            (true, false) => 0.1,
188            _ => 0.0,
189        };
190        let fill = mix(tokens.primary, target, amount);
191        Colours {
192            background: fill,
193            border: Color::TRANSPARENT,
194            icon: tokens.primary_foreground,
195            label: tokens.foreground,
196        }
197    } else {
198        Colours {
199            background: if pressed {
200                tokens.accent
201            } else {
202                Color::TRANSPARENT
203            },
204            border: mix(
205                tokens.border,
206                tokens.foreground,
207                if hover { 0.6 } else { 0.3 },
208            ),
209            icon: tokens.primary_foreground,
210            label: tokens.foreground,
211        }
212    };
213
214    if status != Status::Disabled {
215        return colours;
216    }
217    Colours {
218        background: fade(colours.background, 0.5),
219        border: fade(colours.border, 0.5),
220        icon: fade(colours.icon, 0.5),
221        label: fade(colours.label, 0.5),
222    }
223}
224
225/// The iced button style used for the check box itself.
226pub fn style(tokens: &Tokens, state: CheckState, status: Status) -> widget::button::Style {
227    let colours = colours(tokens, state, status);
228    widget::button::Style {
229        background: Some(Background::Color(colours.background)),
230        text_color: colours.icon,
231        border: Border {
232            color: colours.border,
233            width: 1.0,
234            radius: radius::SM.into(),
235        },
236        shadow: Shadow::default(),
237        snap: true,
238    }
239}
240
241/// What a checkbox keyboard shortcut does. Useful when the app tracks
242/// which checkbox is current.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum Action {
245    Toggle,
246}
247
248impl Action {
249    /// The checked value to send through the checkbox's `on_toggle`
250    /// message, matching a click.
251    pub fn apply(self, state: CheckState) -> bool {
252        match self {
253            Action::Toggle => state.toggled().is_checked(),
254        }
255    }
256}
257
258impl keys::Action for Action {
259    const ALL: &'static [Self] = &[Action::Toggle];
260
261    fn defaults() -> Keymap<Self> {
262        default_keymap()
263    }
264
265    fn name(self) -> &'static str {
266        "Toggle"
267    }
268
269    fn description(self) -> &'static str {
270        "Checks or unchecks the current checkbox. An indeterminate box becomes checked."
271    }
272}
273
274/// The default checkbox shortcuts:
275///
276/// | Keys | Action |
277/// | --- | --- |
278/// | `Space` | [`Action::Toggle`] |
279pub fn default_keymap() -> Keymap<Action> {
280    Keymap::new().bind(Chord::named(Named::Space), Action::Toggle)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::theme::{dark, light};
287
288    #[test]
289    fn toggle_matches_a_click() {
290        assert!(Action::Toggle.apply(CheckState::Unchecked));
291        assert!(!Action::Toggle.apply(CheckState::Checked));
292        assert!(Action::Toggle.apply(CheckState::Indeterminate));
293    }
294
295    #[test]
296    fn default_keymap_and_overrides() {
297        let keymap = default_keymap();
298        assert_eq!(
299            keymap.resolve(
300                &iced::keyboard::Key::Named(Named::Space),
301                Default::default()
302            ),
303            Some(Action::Toggle)
304        );
305        let custom = keymap
306            .clear()
307            .bind("Enter".parse().unwrap(), Action::Toggle);
308        assert_eq!(custom.len(), 1);
309        assert_eq!(
310            custom.resolve(
311                &iced::keyboard::Key::Named(Named::Enter),
312                Default::default()
313            ),
314            Some(Action::Toggle)
315        );
316    }
317
318    const STATES: [Status; 4] = [
319        Status::Active,
320        Status::Hovered,
321        Status::Pressed,
322        Status::Disabled,
323    ];
324
325    #[test]
326    fn default_builder_is_unchecked_unlabelled_and_disabled() {
327        let c: Checkbox<'_, ()> = checkbox(false);
328        assert_eq!(c.state(), CheckState::Unchecked);
329        assert!(c.label.is_none());
330        assert!(!c.is_enabled());
331    }
332
333    #[test]
334    fn bool_converts_to_two_states() {
335        assert_eq!(CheckState::from(true), CheckState::Checked);
336        assert_eq!(CheckState::from(false), CheckState::Unchecked);
337    }
338
339    #[test]
340    fn toggling_cycles_between_checked_and_unchecked() {
341        assert_eq!(CheckState::Unchecked.toggled(), CheckState::Checked);
342        assert_eq!(CheckState::Checked.toggled(), CheckState::Unchecked);
343        assert_eq!(CheckState::Indeterminate.toggled(), CheckState::Checked);
344    }
345
346    #[test]
347    fn on_toggle_receives_the_next_value() {
348        assert_eq!(checkbox(false).on_toggle(|v| v).on_toggle, Some(true));
349        assert_eq!(checkbox(true).on_toggle(|v| v).on_toggle, Some(false));
350        let mixed = checkbox(CheckState::Indeterminate).on_toggle(|v| v);
351        assert_eq!(mixed.on_toggle, Some(true));
352    }
353
354    #[test]
355    fn on_toggle_maybe_none_disables() {
356        let c = checkbox(true).on_toggle_maybe(None::<fn(bool) -> bool>);
357        assert!(!c.is_enabled());
358    }
359
360    #[test]
361    fn only_filled_states_draw_a_mark() {
362        assert!(CheckState::Unchecked.glyph().is_none());
363        assert_eq!(CheckState::Checked.glyph().map(Glyph::name), Some("check"));
364        assert_eq!(
365            CheckState::Indeterminate.glyph().map(Glyph::name),
366            Some("minus")
367        );
368    }
369
370    #[test]
371    fn filled_states_use_primary_and_unchecked_is_transparent() {
372        for theme in [light(), dark()] {
373            let tokens = Tokens::of(&theme);
374            let checked = colours(&tokens, CheckState::Checked, Status::Active);
375            let mixed = colours(&tokens, CheckState::Indeterminate, Status::Active);
376            let unchecked = colours(&tokens, CheckState::Unchecked, Status::Active);
377            assert_eq!(checked.background, tokens.primary);
378            assert_eq!(checked, mixed);
379            assert_eq!(unchecked.background, Color::TRANSPARENT);
380        }
381    }
382
383    #[test]
384    fn hover_and_press_change_the_box_in_every_state() {
385        for theme in [light(), dark()] {
386            let tokens = Tokens::of(&theme);
387            for state in CheckState::ALL {
388                let active = colours(&tokens, state, Status::Active);
389                let hovered = colours(&tokens, state, Status::Hovered);
390                let pressed = colours(&tokens, state, Status::Pressed);
391                assert_ne!(active, hovered, "{state:?}");
392                assert_ne!(hovered, pressed, "{state:?}");
393            }
394        }
395    }
396
397    #[test]
398    fn disabled_halves_alpha_everywhere() {
399        for theme in [light(), dark()] {
400            let tokens = Tokens::of(&theme);
401            for state in CheckState::ALL {
402                let active = colours(&tokens, state, Status::Active);
403                let disabled = colours(&tokens, state, Status::Disabled);
404                assert!((disabled.border.a - active.border.a * 0.5).abs() < 1e-6);
405                assert!((disabled.label.a - active.label.a * 0.5).abs() < 1e-6);
406                assert!((disabled.icon.a - active.icon.a * 0.5).abs() < 1e-6);
407            }
408        }
409    }
410
411    #[test]
412    fn every_style_has_a_rounded_one_pixel_border() {
413        for theme in [light(), dark()] {
414            let tokens = Tokens::of(&theme);
415            for state in CheckState::ALL {
416                for status in STATES {
417                    let style = style(&tokens, state, status);
418                    assert_eq!(style.border.width, 1.0);
419                    assert!(style.background.is_some());
420                }
421            }
422        }
423    }
424}